Compare commits
No commits in common. "master" and "v0.1.2" have entirely different histories.
20
.github/workflows/deploy.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: 'Run build and deploy'
|
name: "Run build and deploy"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@ -8,20 +8,20 @@ jobs:
|
|||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
java-version: '21'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v3
|
uses: gradle/gradle-build-action@v2.4.2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@ -30,6 +30,6 @@ jobs:
|
|||||||
- name: Execute image build and push
|
- name: Execute image build and push
|
||||||
run: |
|
run: |
|
||||||
./gradlew bootBuildImage
|
./gradlew bootBuildImage
|
||||||
docker tag ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
docker tag ghcr.io/ccc-mf/etl-processor ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }}
|
||||||
docker push ghcr.io/${{ github.repository }}
|
docker push ghcr.io/ccc-mf/etl-processor
|
||||||
docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
docker push ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }}
|
16
.github/workflows/test.yml
vendored
@ -11,14 +11,14 @@ jobs:
|
|||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
java-version: '21'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v3
|
uses: gradle/gradle-build-action@v2.4.2
|
||||||
|
|
||||||
- name: Execute tests
|
- name: Execute tests
|
||||||
run: ./gradlew test
|
run: ./gradlew test
|
||||||
@ -26,14 +26,14 @@ jobs:
|
|||||||
integrationTests:
|
integrationTests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
java-version: '21'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v3
|
uses: gradle/gradle-build-action@v2.4.2
|
||||||
|
|
||||||
- name: Execute integration tests
|
- name: Execute integration tests
|
||||||
run: ./gradlew integrationTest
|
run: ./gradlew integrationTest
|
2
.gitignore
vendored
@ -5,8 +5,6 @@ build/
|
|||||||
!**/src/main/**/build/
|
!**/src/main/**/build/
|
||||||
!**/src/test/**/build/
|
!**/src/test/**/build/
|
||||||
|
|
||||||
bindings/ca-certificates/*.pem
|
|
||||||
|
|
||||||
### STS ###
|
### STS ###
|
||||||
.apt_generated
|
.apt_generated
|
||||||
.classpath
|
.classpath
|
||||||
|
314
README.md
@ -1,221 +1,73 @@
|
|||||||
# ETL-Processor for DNPM:DIP [](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml)
|
# ETL-Processor for bwHC data [](https://github.com/CCC-MF/etl-processor/actions/workflows/test.yml)
|
||||||
|
|
||||||
Diese Anwendung versendet ein bwHC-MTB-File im bwHC-Datenmodell 1.0 an DNPM:DIP und pseudonymisiert die Patienten-ID.
|
Diese Anwendung versendet ein bwHC-MTB-File an das bwHC-Backend und pseudonymisiert 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 **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)**.
|
Diese Anwendung erlaubt das Entgegennehmen HTTP/REST-Anfragen aus dem Onkostar-Plugin **[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 das bwHC-backend weitergeleitet.
|
||||||
|
|
||||||
Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Duplikaterkennung
|
#### HTTP/REST-Konfiguration
|
||||||
|
|
||||||
Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über den Konfigurationsparameter
|
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an das bwHC-Backend gesendet.
|
||||||
`APP_DUPLICATION_DETECTION=false` deaktiviert werden.
|
|
||||||
|
|
||||||
### Datenübermittlung über HTTP/REST
|
#### Konfiguration für Apache Kafka
|
||||||
|
|
||||||
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an DNPM:DIP gesendet.
|
|
||||||
|
|
||||||
Ein HTTP Request kann, angenommen die Installation erfolgte auf dem Host `dnpm.example.com` an nachfolgende URLs gesendet werden:
|
|
||||||
|
|
||||||
| HTTP-Request | URL | Consent-Status im Datensatz | Bemerkung |
|
|
||||||
|--------------|-----------------------------------------|-----------------------------|---------------------------------------------------------------------------------|
|
|
||||||
| `POST` | `https://dnpm.example.com/mtb` | `ACTIVE` | Die Anwendung verarbeitet den eingehenden Datensatz |
|
|
||||||
| `POST` | `https://dnpm.example.com/mtb` | `REJECT` | Die Anwendung sendet einen Lösch-Request für die im Datensatz angegebene Pat-ID |
|
|
||||||
| `DELETE` | `https://dnpm.example.com/mtb/12345678` | - | Die Anwendung sendet einen Lösch-Request für Pat-ID `12345678` |
|
|
||||||
|
|
||||||
Anstelle des Pfads `/mtb` kann auch, wie in Version 0.9 und älter üblich, `/mtbfile` verwendet werden.
|
|
||||||
|
|
||||||
### Datenübermittlung mit Apache Kafka
|
|
||||||
|
|
||||||
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung an Apache Kafka übergeben.
|
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung an Apache Kafka ü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
|
||||||
|
|
||||||
## Konfiguration
|
## Pseudonymisierung der Patienten-ID
|
||||||
|
|
||||||
### 🔥 Wichtige Änderungen in Version 0.10
|
|
||||||
|
|
||||||
Ab Version 0.10 wird [DNPM:DIP](https://github.com/dnpm-dip) unterstützt und als Standardendpunkt verwendet.
|
|
||||||
Soll noch das alte bwHC-Backend verwendet werden, so ist die Umgebungsvariable `APP_REST_IS_BWHC` auf `true` zu setzen.
|
|
||||||
|
|
||||||
### 🔥 Breaking Changes nach Version 0.10
|
|
||||||
|
|
||||||
In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfigurationsoptionen entfernt:
|
|
||||||
|
|
||||||
* `APP_KAFKA_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_TOPIC`
|
|
||||||
* `APP_KAFKA_RESPONSE_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`
|
|
||||||
|
|
||||||
Der Pfad zum Versenden von MTB-Daten ist nun offiziell `/mtb`.
|
|
||||||
In Versionen **nach Version 0.10** wird die Unterstützung des Pfads `/mtbfile` entfernt.
|
|
||||||
|
|
||||||
### Pseudonymisierung der Patienten-ID
|
|
||||||
|
|
||||||
Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet.
|
Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet.
|
||||||
Ist diese nicht gesetzt. wird intern eine Anonymisierung der Patienten-ID vorgenommen.
|
Ist diese nicht gesetzt. wird intern eine Anonymisierung der Patienten-ID vorgenommen.
|
||||||
|
|
||||||
* `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Präfix - `UNKNOWN`, wenn nicht gesetzt
|
* `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Prefix - `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**
|
### Eingebaute Pseudonymisierung
|
||||||
|
|
||||||
Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID.
|
Wurde keine oder die Verwendung der eingebauten Pseudonymisierung konfiguriert, so wird für die Patienten-ID der
|
||||||
Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den aktuellen Kontext nicht
|
entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des konfigurierten Prefixes
|
||||||
vergleichbare IDs bereitzustellen.
|
|
||||||
|
|
||||||
#### Eingebaute Anonymisierung
|
|
||||||
|
|
||||||
Wurde keine oder die Verwendung der eingebauten Anonymisierung konfiguriert, so wird für die Patienten-ID der
|
|
||||||
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. `http://localhost:8080/ttp-fhir/fhir/gpas/$$pseudonymizeAllowCreate`)
|
* `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz inklusive Endpoint (
|
||||||
|
z.B. `http://localhost:8080/ttp-fhir/fhir/gpas/$$pseudonymizeAllowCreate`)
|
||||||
* `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname
|
* `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname
|
||||||
* `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_SSLCALOCATION`: Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss.
|
||||||
|
|
||||||
### Anmeldung mit einem Passwort
|
## Mögliche Endpunkte
|
||||||
|
|
||||||
Ein initialer Administrator-Account kann optional konfiguriert werden und sorgt dafür, dass bestimmte Bereiche nur nach
|
|
||||||
einem erfolgreichen Login erreichbar sind.
|
|
||||||
|
|
||||||
* `APP_SECURITY_ADMIN_USER`: Muss angegeben werden zur Aktivierung der Zugriffsbeschränkung.
|
|
||||||
* `APP_SECURITY_ADMIN_PASSWORD`: Das Passwort für den Administrator (Empfohlen).
|
|
||||||
|
|
||||||
Ein Administrator-Passwort muss inklusive des Encoding-Präfixes vorliegen.
|
|
||||||
|
|
||||||
Hier Beispiele für das Beispielpasswort `very-secret`:
|
|
||||||
|
|
||||||
* `{noop}very-secret` (Das Passwort liegt im Klartext vor - nicht empfohlen!)
|
|
||||||
* `{bcrypt}$2y$05$CCkfsMr/wbTleMyjVIK8g.Aa3RCvrvoLXVAsL.f6KeouS88vXD9b6`
|
|
||||||
* `{sha256}9a34717f0646b5e9cfcba70055de62edb026ff4f68671ba3db96aa29297d2df5f1a037d58c745657`
|
|
||||||
|
|
||||||
Wird kein Administrator-Passwort angegeben, wird ein zufälliger Wert generiert und beim Start der Anwendung in den Logs
|
|
||||||
angezeigt.
|
|
||||||
|
|
||||||
#### Weitere (nicht administrative) Nutzer mit OpenID Connect
|
|
||||||
|
|
||||||
Die folgenden Konfigurationsparameter werden benötigt, um die Authentifizierung weiterer Benutzer an einen OIDC-Provider
|
|
||||||
zu delegieren.
|
|
||||||
Ein Admin-Benutzer muss dabei konfiguriert sein.
|
|
||||||
|
|
||||||
* `APP_SECURITY_ENABLE_OIDC`: Aktiviert die Nutzung von OpenID Connect. Damit sind weitere Parameter erforderlich
|
|
||||||
* `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_SECRET`: Client-Secret
|
|
||||||
* `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_SCOPE[0]`: Hier sollte immer `openid` angegeben werden.
|
|
||||||
* `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_CUSTOM_ISSUER_URI`: Die URI des Providers,
|
|
||||||
z.B. `https://auth.example.com/realm/example`
|
|
||||||
* `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_CUSTOM_USER_NAME_ATTRIBUTE`: Name des Attributes, welches den Benutzernamen
|
|
||||||
enthält.
|
|
||||||
Oft verwendet: `preferred_username`
|
|
||||||
|
|
||||||
Ist die Nutzung von OpenID Connect konfiguriert, erscheint ein zusätzlicher Login-Button zur Nutzung mit OpenID Connect
|
|
||||||
und dem konfigurierten `CLIENT_NAME`.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Weitere Informationen zur Konfiguration des OIDC-Providers
|
|
||||||
sind [hier](https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html#oauth2-client)
|
|
||||||
zu finden.
|
|
||||||
|
|
||||||
#### Rollenbasierte Berechtigungen
|
|
||||||
|
|
||||||
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` festgelegt werden.
|
|
||||||
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.
|
|
||||||
|
|
||||||
Hierdurch ist es möglich, einzelne Benutzer einzuschränken oder durch Änderung der Standardrolle auf `guest` nur
|
|
||||||
einzelne Benutzer als vollwertige Nutzer zuzulassen.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Benutzer werden nach dem Entfernen oder der Änderung der vergebenen Rolle automatisch abgemeldet und müssen sich neu anmelden.
|
|
||||||
Sie bekommen dabei wieder die Standardrolle zugewiesen.
|
|
||||||
|
|
||||||
#### Auswirkungen auf den dargestellten Inhalt
|
|
||||||
|
|
||||||
Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die anonymisierte oder
|
|
||||||
pseudonymisierte Patienten-ID sowie den Qualitätsbericht von DNPM:DIP einsehen.
|
|
||||||
|
|
||||||
Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar.
|
|
||||||
|
|
||||||
### Tokenbasierte Authentifizierung für MTBFile-Endpunkt
|
|
||||||
|
|
||||||
Die Anwendung unterstützt das Erstellen und Nutzen einer tokenbasierten Authentifizierung für den MTB-File-Endpunkt.
|
|
||||||
|
|
||||||
Dies kann mit der Umgebungsvariable `APP_SECURITY_ENABLE_TOKENS` aktiviert (`true` oder `false`) werden
|
|
||||||
und ist als Standardeinstellung nicht aktiv.
|
|
||||||
|
|
||||||
Ist diese Einstellung aktiviert worden, ist es Administratoren möglich, Zugriffstokens für Onkostar zu erstellen, die
|
|
||||||
zur Nutzung des MTB-File-Endpunkts eine HTTP-Basic-Authentifizierung voraussetzen.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
In diesem Fall kann der Endpunkt für das Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)** wie folgt konfiguriert werden:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile
|
|
||||||
```
|
|
||||||
|
|
||||||
Ist die Verwendung von Tokens aktiv, werden Anfragen ohne die Angabe der Token-Information abgelehnt.
|
|
||||||
|
|
||||||
Alternativ kann eine Authentifizierung über Benutzername/Passwort oder OIDC erfolgen.
|
|
||||||
|
|
||||||
### Transformation von Werten
|
|
||||||
|
|
||||||
In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht,
|
|
||||||
der von DNPM:DIP akzeptiert wird.
|
|
||||||
|
|
||||||
Diese Anwendung bietet daher die Möglichkeit, eine Transformation vorzunehmen. Hierzu muss der "Pfad" innerhalb des JSON-MTB-Files angegeben werden und
|
|
||||||
welcher Wert wie ersetzt werden soll.
|
|
||||||
|
|
||||||
Hier ein Beispiel für die erste (Index 0 - weitere dann mit 1,2, ...) Transformationsregel:
|
|
||||||
|
|
||||||
* `APP_TRANSFORMATIONS_0_PATH`: Pfad zum Wert in der JSON-MTB-Datei. Beispiel: `diagnoses[*].icd10.version` für **alle** Diagnosen
|
|
||||||
* `APP_TRANSFORMATIONS_0_FROM`: Angabe des Werts, der ersetzt werden soll. Andere Werte bleiben dabei unverändert.
|
|
||||||
* `APP_TRANSFORMATIONS_0_TO`: Angabe des neuen Werts.
|
|
||||||
|
|
||||||
### Mögliche Endpunkte zur Datenübermittlung
|
|
||||||
|
|
||||||
Für REST-Requests als auch zur Nutzung von Kafka-Topics können Endpunkte konfiguriert werden.
|
Für REST-Requests als auch zur Nutzung von Kafka-Topics können Endpunkte konfiguriert werden.
|
||||||
|
|
||||||
Es ist dabei nur die Konfiguration eines Endpunkts zulässig.
|
Es ist dabei nur die Konfiguration eines Endpunkts zulässig.
|
||||||
Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpunkt verwendet.
|
Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpunkt verwendet.
|
||||||
|
|
||||||
#### REST
|
### REST
|
||||||
|
|
||||||
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 das bwHC-Backend gesendet wird:
|
||||||
|
|
||||||
* `APP_REST_URI`: URI der zu benutzenden API der Backend-Instanz. Zum Beispiel:
|
* `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api`
|
||||||
* `http://localhost:9000/bwhc/etl/api` für **bwHC Backend**
|
|
||||||
* `http://localhost:9000/api` für **dnpm:dip**
|
|
||||||
* `APP_REST_USERNAME`: Basic-Auth-Benutzername für den REST-Endpunkt
|
|
||||||
* `APP_REST_PASSWORD`: Basic-Auth-Passwort für den REST-Endpunkt
|
|
||||||
* `APP_REST_IS_BWHC`: `true` für **bwHC Backend**, weglassen oder `false` für **dnpm:dip**
|
|
||||||
|
|
||||||
#### Kafka-Topics
|
### Kafka-Topics
|
||||||
|
|
||||||
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird:
|
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird:
|
||||||
|
|
||||||
* `APP_KAFKA_OUTPUT_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen.
|
* `APP_KAFKA_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen
|
||||||
* `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response".
|
* `APP_KAFKA_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 "_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
|
||||||
|
|
||||||
@ -223,12 +75,10 @@ Wird keine Rückantwort über Apache Kafka empfangen und es gibt keine weitere M
|
|||||||
|
|
||||||
Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden.
|
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` erwartet, welchen es
|
Lässt sich keine Verbindung zu dem bwHC-Backend aufbauen, wird eine Rückantwort mit Status-Code `900` 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 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.
|
||||||
@ -241,7 +91,7 @@ Beispiel - auszuführen innerhalb des Kafka-Containers: Löschen alter Records n
|
|||||||
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 Kafka vorhalten,
|
Möchten Sie hingegen immer nur die letzte Meldung für einen Patienten und eine Erkrankung in Apache Kafka vorhalten,
|
||||||
so ist die nachfolgend genannte Konfiguration der Kafka-Topics hilfreich.
|
so ist die nachfolgend genannte Konfiguration der Kafka-Topics hilfreich.
|
||||||
@ -257,42 +107,19 @@ 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 Konfiguration
|
Da als Key eines Records die (pseudonymisierte) Patienten-ID und die (anonymisierte) Erkrankungs-ID verwendet wird,
|
||||||
der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden Key zur Verfügung.
|
stehen mit obiger Konfiguration der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden
|
||||||
|
Key zur Verfügung.
|
||||||
|
|
||||||
Da der Key sowohl für die Records in Richtung DNPM:DIP, als auch für die Rückantwort identisch aufgebaut ist, lassen sich so
|
Da der Key sowohl für die Records in Richtung bwHC-Backend für die Rückantwort identisch aufgebaut ist, lassen sich so
|
||||||
auch im Falle eines Consent-Widerspruchs die enthaltenen Daten als auch die Offenlegung durch Verifikationsdaten in der
|
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 Erkrankung
|
Es steht dann nur noch die jeweils letzten Information zur Verfügung, dass für einen Patienten/eine Erkrankung
|
||||||
ein Consent-Widerspruch erfolgte.
|
ein Consent-Widerspruch erfolgte.
|
||||||
|
|
||||||
Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für andere Auswertungen verwenden möchten.
|
|
||||||
|
|
||||||
### Antworten und Statusauswertung
|
|
||||||
|
|
||||||
Anfragen an das bwHC-Backend aus Versionen bis 0.9.x wurden wie folgt behandelt:
|
|
||||||
|
|
||||||
| HTTP-Response | Status |
|
|
||||||
|----------------|-----------|
|
|
||||||
| `HTTP 200` | `SUCCESS` |
|
|
||||||
| `HTTP 201` | `WARNING` |
|
|
||||||
| `HTTP 400-...` | `ERROR` |
|
|
||||||
|
|
||||||
Dies konnte dazu führen, dass zwar mit einem `HTTP 201` geantwortet wurde, aber dennoch in der Issue-Liste die
|
|
||||||
Severity `error` aufgetaucht ist.
|
|
||||||
|
|
||||||
Ab Version 0.10 wird die Issue-Liste der Antwort verwendet und die darion enthaltene höchste Severity-Stufe als Ergebnis verwendet.
|
|
||||||
|
|
||||||
| Höchste Severity | Status |
|
|
||||||
|------------------|-----------|
|
|
||||||
| `info` | `SUCCESS` |
|
|
||||||
| `warning` | `WARNING` |
|
|
||||||
| `error`, `fatal` | `ERROR` |
|
|
||||||
|
|
||||||
## Docker-Images
|
## Docker-Images
|
||||||
|
|
||||||
Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-processor
|
Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/CCC-MF/etl-processor/pkgs/container/etl-processor
|
||||||
|
|
||||||
### Images lokal bauen
|
### Images lokal bauen
|
||||||
|
|
||||||
@ -300,37 +127,8 @@ Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/pcvolkm
|
|||||||
./gradlew bootBuildImage
|
./gradlew bootBuildImage
|
||||||
```
|
```
|
||||||
|
|
||||||
### Integration eines eigenen Root CA Zertifikats
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Hier bietet es sich an, das Root CA Zertifikat in das Image zu integrieren.
|
|
||||||
|
|
||||||
#### Integration beim Bauen des Images
|
|
||||||
|
|
||||||
Hier muss die Zeile `"BP_EMBED_CERTS" to "true"` in der Datei `build.gradle.kts` verwendet werden und darf nicht als Kommentar verwendet werden.
|
|
||||||
|
|
||||||
Die PEM-Datei mit dem/den Root CA Zertifikat(en) muss dabei im vorbereiteten Verzeichnis [`bindings/ca-certificates`](bindings/ca-certificates) enthalten sein.
|
|
||||||
|
|
||||||
#### Integration zur Laufzeit
|
|
||||||
|
|
||||||
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 [`bindings/ca-certificates`](bindings/ca-certificates) mit einer PEM-Datei als Docker-Volume eingebunden werden.
|
|
||||||
|
|
||||||
Beispiel für Docker-Compose:
|
|
||||||
|
|
||||||
```
|
|
||||||
...
|
|
||||||
environment:
|
|
||||||
SERVICE_BINDING_ROOT: /bindings
|
|
||||||
...
|
|
||||||
volumes:
|
|
||||||
- "/path/to/bindings/ca-certificates/:/bindings/ca-certificates/:ro"
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
*Ausführen als Docker Container:*
|
*Ausführen als Docker Conatiner:*
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ./deploy
|
cd ./deploy
|
||||||
@ -342,50 +140,6 @@ Wenn gewünscht, Änderungen in der `.env` vornehmen.
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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 REST-Requests basierend
|
|
||||||
auf Docker-Compose mit der gestartet werden kann.
|
|
||||||
|
|
||||||
### Betrieb hinter einem Reverse-Proxy
|
|
||||||
|
|
||||||
Die Anwendung verarbeitet `X-Forwarded`-HTTP-Header und kann daher auch hinter einem Reverse-Proxy betrieben werden.
|
|
||||||
|
|
||||||
Dabei werden, je nachdem welche Header durch den Reverse-Proxy gesendet werden auch Protokoll, Host oder auch Path-Präfix
|
|
||||||
automatisch erkannt und verwendet werden. Dadurch ist z.B. eine abweichende Angabe des Pfads problemlos möglich.
|
|
||||||
|
|
||||||
#### Beispiel *Traefik* (mit Docker-Labels):
|
|
||||||
|
|
||||||
Das folgende Beispiel zeigt die Konfiguration in einer Docker-Compose-Datei mit Service-Labels.
|
|
||||||
|
|
||||||
```
|
|
||||||
...
|
|
||||||
deploy:
|
|
||||||
labels:
|
|
||||||
- "traefik.http.routers.etl.rule=PathPrefix(`/etl-processor`)"
|
|
||||||
- "traefik.http.routers.etl.middlewares=etl-path-strip"
|
|
||||||
- "traefik.http.middlewares.etl-path-strip.stripprefix.prefixes=/etl-processor"
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Beispiel *nginx*
|
|
||||||
|
|
||||||
Das folgende Beispiel zeigt die Konfiguration einer _location_ in einer nginx-Konfigurationsdatei.
|
|
||||||
|
|
||||||
```
|
|
||||||
...
|
|
||||||
location /etl-processor {
|
|
||||||
set $upstream http://<beispiel:8080>/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_pass $upstream;
|
|
||||||
}
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Entwicklungssetup
|
## Entwicklungssetup
|
||||||
|
|
||||||
Zum Starten einer lokalen Entwicklungs- und Testumgebung kann die beiliegende Datei `dev-compose.yml` verwendet werden.
|
Zum Starten einer lokalen Entwicklungs- und Testumgebung kann die beiliegende Datei `dev-compose.yml` verwendet werden.
|
||||||
@ -405,5 +159,3 @@ 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 von https://github.com/pcvolkmer/dnpmdip-devenv realisierbar.
|
|
@ -1,5 +0,0 @@
|
|||||||
# Hinweis für Root CA Zertifikate
|
|
||||||
|
|
||||||
PEM-Datei(en) in das Verzeichnis `ca-certificates` ablegen.
|
|
||||||
|
|
||||||
Die Datei `type` gibt dabei an, dass hier CA Zertifikate zu finden sind.
|
|
@ -1 +0,0 @@
|
|||||||
ca-certificates
|
|
103
build.gradle.kts
@ -1,36 +1,31 @@
|
|||||||
import org.gradle.api.tasks.testing.logging.TestLogEvent
|
import org.gradle.api.tasks.testing.logging.TestLogEvent
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
|
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
war
|
war
|
||||||
id("org.springframework.boot") version "3.4.4"
|
id("org.springframework.boot") version "3.1.3"
|
||||||
id("io.spring.dependency-management") version "1.1.7"
|
id("io.spring.dependency-management") version "1.1.3"
|
||||||
kotlin("jvm") version "1.9.25"
|
kotlin("jvm") version "1.9.10"
|
||||||
kotlin("plugin.spring") version "1.9.25"
|
kotlin("plugin.spring") version "1.9.10"
|
||||||
jacoco
|
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "dev.dnpm"
|
group = "de.ukw.ccc"
|
||||||
version = "0.11.0-SNAPSHOT"
|
version = "0.1.2"
|
||||||
|
|
||||||
var versions = mapOf(
|
var versions = mapOf(
|
||||||
"bwhc-dto-java" to "0.4.0",
|
"bwhc-dto-java" to "0.2.0",
|
||||||
"mtb-dto" to "0.1.0-SNAPSHOT",
|
"hapi-fhir" to "6.6.2",
|
||||||
"hapi-fhir" to "7.6.0",
|
"httpclient5" to "5.2.1",
|
||||||
"mockito-kotlin" to "5.4.0",
|
"mockito-kotlin" to "5.1.0"
|
||||||
"archunit" to "1.3.0",
|
|
||||||
// Webjars
|
|
||||||
"webjars-locator" to "0.52",
|
|
||||||
"echarts" to "5.4.3",
|
|
||||||
"htmx.org" to "1.9.12"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Override Apache Kafka to be used
|
||||||
|
// Fixes: CVE-2023-34455, CVE-2023-34454, CVE-2023-34453
|
||||||
|
extra["kafka.version"] = "3.5.1"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
languageVersion = JavaLanguageVersion.of(21)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
@ -49,18 +44,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")
|
||||||
}
|
}
|
||||||
@ -72,51 +58,34 @@ dependencies {
|
|||||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
|
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
|
||||||
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
|
|
||||||
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
|
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||||
implementation("org.springframework.kafka:spring-kafka")
|
implementation("org.springframework.kafka:spring-kafka")
|
||||||
implementation("org.flywaydb:flyway-database-postgresql")
|
// fix CVE-2022-1471
|
||||||
|
implementation("org.yaml:snakeyaml:2.1")
|
||||||
implementation("org.flywaydb:flyway-mysql")
|
implementation("org.flywaydb:flyway-mysql")
|
||||||
implementation("commons-codec:commons-codec")
|
implementation("commons-codec:commons-codec")
|
||||||
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
|
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
|
||||||
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:${versions["httpclient5"]}")
|
||||||
implementation("com.jayway.jsonpath:json-path")
|
|
||||||
implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}")
|
|
||||||
implementation("org.webjars.npm:echarts:${versions["echarts"]}")
|
|
||||||
implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}")
|
|
||||||
|
|
||||||
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
|
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
|
||||||
runtimeOnly("org.postgresql:postgresql")
|
runtimeOnly("org.postgresql:postgresql")
|
||||||
|
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
|
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
|
||||||
|
|
||||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||||
|
|
||||||
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
|
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
|
||||||
|
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("org.springframework.security:spring-security-test")
|
|
||||||
testImplementation("io.projectreactor:reactor-test")
|
testImplementation("io.projectreactor:reactor-test")
|
||||||
testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}")
|
testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}")
|
||||||
|
|
||||||
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("org.htmlunit:htmlunit")
|
|
||||||
integrationTestImplementation("org.springframework:spring-webflux")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<KotlinCompile> {
|
tasks.withType<KotlinCompile> {
|
||||||
compilerOptions {
|
kotlinOptions {
|
||||||
freeCompilerArgs.add("-Xjsr305=strict")
|
freeCompilerArgs += "-Xjsr305=strict"
|
||||||
jvmTarget.set(JvmTarget.JVM_21)
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,9 +96,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
|
||||||
@ -137,34 +105,11 @@ tasks.register<Test>("integrationTest") {
|
|||||||
shouldRunAfter("test")
|
shouldRunAfter("test")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("allTests") {
|
|
||||||
description = "Run all tests"
|
|
||||||
group = JavaBasePlugin.VERIFICATION_GROUP
|
|
||||||
dependsOn(tasks.withType<Test>())
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.jacocoTestReport {
|
|
||||||
dependsOn("allTests")
|
|
||||||
|
|
||||||
executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec"))
|
|
||||||
|
|
||||||
reports {
|
|
||||||
xml.required = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.named<BootBuildImage>("bootBuildImage") {
|
tasks.named<BootBuildImage>("bootBuildImage") {
|
||||||
imageName.set("ghcr.io/pcvolkmer/etl-processor")
|
imageName.set("ghcr.io/ccc-mf/etl-processor")
|
||||||
|
|
||||||
// Binding for CA Certs
|
|
||||||
bindings.set(listOf(
|
|
||||||
"$rootDir/bindings/ca-certificates/:/platform/bindings/ca-certificates"
|
|
||||||
))
|
|
||||||
|
|
||||||
environment.set(environment.get() + mapOf(
|
environment.set(environment.get() + mapOf(
|
||||||
// Enable this line to embed CA Certs into image on build time
|
"BP_OCI_SOURCE" to "https://github.com/CCC-MF/etl-processor",
|
||||||
//"BP_EMBED_CERTS" to "true",
|
|
||||||
"BP_OCI_SOURCE" to "https://github.com/pcvolkmer/etl-processor",
|
|
||||||
"BP_OCI_LICENSES" to "AGPLv3",
|
"BP_OCI_LICENSES" to "AGPLv3",
|
||||||
"BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files"
|
"BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files"
|
||||||
))
|
))
|
||||||
|
@ -18,11 +18,6 @@ services:
|
|||||||
APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID}
|
APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID}
|
||||||
APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC}
|
APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC}
|
||||||
APP_REST_URI: ${DNPM_BWHC_REST_URI}
|
APP_REST_URI: ${DNPM_BWHC_REST_URI}
|
||||||
APP_REST_USERNAME: ${DNPM_BWHC_REST_USERNAME}
|
|
||||||
APP_REST_PASSWORD: ${DNPM_BWHC_REST_PASSWORD}
|
|
||||||
APP_REST_IS_BWHC: ${DNPM_BWHC_REST_IS_BWHC}
|
|
||||||
APP_SECURITY_ADMIN_USER: ${DNPM_ADMIN_USER}
|
|
||||||
APP_SECURITY_ADMIN_PASSWORD: ${DNPM_ADMIN_PASSWORD}
|
|
||||||
SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL}
|
SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL}
|
||||||
SPRING_DATASOURCE_PASSWORD: ${DNPM_MARIADB_USER_PW}
|
SPRING_DATASOURCE_PASSWORD: ${DNPM_MARIADB_USER_PW}
|
||||||
SPRING_DATASOURCE_USERNAME: ${DNPM_MARIADB_DB}
|
SPRING_DATASOURCE_USERNAME: ${DNPM_MARIADB_DB}
|
||||||
|
@ -2,10 +2,6 @@
|
|||||||
DNPM_MONITORING_HTTP_PORT=8088
|
DNPM_MONITORING_HTTP_PORT=8088
|
||||||
DNPM_LOG_LEVEL=INFO
|
DNPM_LOG_LEVEL=INFO
|
||||||
|
|
||||||
# ADMIN USER CREDENTIALS
|
|
||||||
DNPM_ADMIN_USER=admin
|
|
||||||
DNPM_ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
# GPAS or BUILDIN
|
# GPAS or BUILDIN
|
||||||
DNPM_PSEUDONYMIZE_GENERATOR=BUILDIN
|
DNPM_PSEUDONYMIZE_GENERATOR=BUILDIN
|
||||||
DNPM_APP_PSEUDONYMIZE_PREFIX=ANONYM
|
DNPM_APP_PSEUDONYMIZE_PREFIX=ANONYM
|
||||||
@ -28,9 +24,6 @@ DNPM_DATASOURCE_URL=jdbc:mariadb://dnpm-monitor-db:3306/$DNPM_MARIADB_DB
|
|||||||
## TARGET SYSTEMS CONFIG
|
## TARGET SYSTEMS CONFIG
|
||||||
# in case of direct access to bwhc enter endpoint url here
|
# in case of direct access to bwhc enter endpoint url here
|
||||||
DNPM_BWHC_REST_URI=
|
DNPM_BWHC_REST_URI=
|
||||||
DNPM_BWHC_REST_USERNAME=
|
|
||||||
DNPM_BWHC_REST_PASSWORD=
|
|
||||||
DNPM_BWHC_REST_IS_BWHC=false
|
|
||||||
|
|
||||||
# produce mtb files to this topic - values 'false' disabling kafka processing
|
# produce mtb files to this topic - values 'false' disabling kafka processing
|
||||||
DNPM_KAFKA_TOPIC=false
|
DNPM_KAFKA_TOPIC=false
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
# Note: Make sure, hostname "kafka" points to 127.0.0.1
|
||||||
|
# otherwise connection will not be available
|
||||||
kafka:
|
kafka:
|
||||||
image: bitnami/kafka
|
image: bitnami/kafka
|
||||||
hostname: kafka
|
hostname: kafka
|
||||||
@ -17,9 +19,8 @@ services:
|
|||||||
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
|
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
|
||||||
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
||||||
|
|
||||||
## Use AKHQ as Kafka web frontend
|
|
||||||
akhq:
|
akhq:
|
||||||
image: tchiotludo/akhq:0.25.0
|
image: tchiotludo/akhq:0.21.0
|
||||||
environment:
|
environment:
|
||||||
AKHQ_CONFIGURATION: |
|
AKHQ_CONFIGURATION: |
|
||||||
akhq:
|
akhq:
|
||||||
@ -33,8 +34,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8084:8080"
|
- "8084:8080"
|
||||||
|
|
||||||
|
|
||||||
## For use with MariaDB
|
|
||||||
mariadb:
|
mariadb:
|
||||||
image: mariadb:10
|
image: mariadb:10
|
||||||
ports:
|
ports:
|
||||||
@ -45,7 +44,6 @@ services:
|
|||||||
MARIADB_PASSWORD: dev
|
MARIADB_PASSWORD: dev
|
||||||
MARIADB_ROOT_PASSWORD: dev
|
MARIADB_ROOT_PASSWORD: dev
|
||||||
|
|
||||||
## For use with Postgres
|
|
||||||
# postgres:
|
# postgres:
|
||||||
# image: postgres:alpine
|
# image: postgres:alpine
|
||||||
# ports:
|
# ports:
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
### Example for docker-compose
|
|
||||||
version: '3.7'
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
data:
|
|
||||||
|
|
||||||
services:
|
|
||||||
|
|
||||||
### ETL-Processor
|
|
||||||
etl-processor:
|
|
||||||
image: ghcr.io/ccc-mf/etl-processor:latest
|
|
||||||
ports:
|
|
||||||
- 8080:8080
|
|
||||||
environment:
|
|
||||||
APP_REST_URI: http://bwhc-backend/bwhc/etl/api
|
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres/etl
|
|
||||||
SPRING_DATASOURCE_USERNAME: etl
|
|
||||||
SPRING_DATASOURCE_PASSWORD: etl-password
|
|
||||||
|
|
||||||
### Database
|
|
||||||
postgres:
|
|
||||||
image: postgres:alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: etl
|
|
||||||
POSTGRES_USER: etl
|
|
||||||
POSTGRES_PASSWORD: etl-password
|
|
||||||
volumes:
|
|
||||||
- data:/var/lib/postgresql/data
|
|
BIN
docs/etl.png
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 75 KiB |
BIN
docs/login.png
Before Width: | Height: | Size: 9.2 KiB |
BIN
docs/tokens.png
Before Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 16 KiB |
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
@ -19,40 +19,21 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor
|
package dev.dnpm.etl.processor
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import de.ukw.ccc.bwhc.dto.*
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
|
||||||
import dev.dnpm.etl.processor.output.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.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
import org.mockito.kotlin.*
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
import org.springframework.context.ApplicationContext
|
import org.springframework.context.ApplicationContext
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.test.context.TestPropertySource
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
|
||||||
import org.springframework.test.web.servlet.post
|
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers
|
import org.testcontainers.junit.jupiter.Testcontainers
|
||||||
|
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
@ExtendWith(SpringExtension::class)
|
@ExtendWith(SpringExtension::class)
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@MockitoBean(types = [MtbFileSender::class])
|
@MockBean(MtbFileSender::class)
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.rest.uri=http://example.com",
|
|
||||||
"app.pseudonymize.generator=buildin"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
|
class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -61,86 +42,4 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
|
|||||||
assertThat(context).isNotNull
|
assertThat(context).isNotNull
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
|
||||||
@AutoConfigureMockMvc
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=buildin",
|
|
||||||
"app.transformations[0].path=diagnoses[*].icd10.version",
|
|
||||||
"app.transformations[0].from=2013",
|
|
||||||
"app.transformations[0].to=2014",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class TransformationTest {
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private lateinit var mtbFileSender: MtbFileSender
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private lateinit var mockMvc: MockMvc
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private lateinit var objectMapper: ObjectMapper
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(@Autowired requestRepository: RequestRepository) {
|
|
||||||
requestRepository.deleteAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun mtbFileIsTransformed() {
|
|
||||||
doAnswer {
|
|
||||||
MtbFileSender.Response(RequestStatus.SUCCESS)
|
|
||||||
}.whenever(mtbFileSender).send(any<BwhcV1MtbFileRequest>())
|
|
||||||
|
|
||||||
val mtbFile = MtbFile.builder()
|
|
||||||
.withPatient(
|
|
||||||
Patient.builder()
|
|
||||||
.withId("TEST_12345678")
|
|
||||||
.withBirthDate("2000-08-08")
|
|
||||||
.withGender(Patient.Gender.MALE)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withConsent(
|
|
||||||
Consent.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withStatus(Consent.Status.ACTIVE)
|
|
||||||
.withPatient("TEST_12345678")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withEpisode(
|
|
||||||
Episode.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withPatient("TEST_12345678")
|
|
||||||
.withPeriod(PeriodStart("2023-08-08"))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withDiagnoses(
|
|
||||||
listOf(
|
|
||||||
Diagnosis.builder()
|
|
||||||
.withId("1234")
|
|
||||||
.withIcd10(Icd10.builder().withCode("F79.9").withVersion("2013").build())
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
mockMvc.post("/mtbfile") {
|
|
||||||
content = objectMapper.writeValueAsString(mtbFile)
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
}.andExpect {
|
|
||||||
status {
|
|
||||||
isAccepted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val captor = argumentCaptor<BwhcV1MtbFileRequest>()
|
|
||||||
verify(mtbFileSender).send(captor.capture())
|
|
||||||
assertThat(captor.firstValue.content.diagnoses).hasSize(1).allMatch { diagnosis ->
|
|
||||||
diagnosis.icd10.version == "2014"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,73 +0,0 @@
|
|||||||
package dev.dnpm.etl.processor
|
|
||||||
|
|
||||||
import com.tngtech.archunit.core.domain.JavaClasses
|
|
||||||
import com.tngtech.archunit.core.importer.ClassFileImporter
|
|
||||||
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes
|
|
||||||
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.springframework.data.repository.Repository
|
|
||||||
|
|
||||||
class EtlProcessorArchTest {
|
|
||||||
|
|
||||||
private lateinit var noTestClasses: JavaClasses
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setUp() {
|
|
||||||
this.noTestClasses = ClassFileImporter()
|
|
||||||
.withImportOption { !(it.contains("/test/") || it.contains("/integrationTest/")) }
|
|
||||||
.importPackages("dev.dnpm.etl.processor")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noClassesInInputPackageShouldDependOnMonitoringPackage() {
|
|
||||||
val rule = noClasses()
|
|
||||||
.that()
|
|
||||||
.resideInAPackage("..input")
|
|
||||||
.should().dependOnClassesThat()
|
|
||||||
.resideInAnyPackage("..monitoring")
|
|
||||||
|
|
||||||
rule.check(noTestClasses)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noClassesInInputPackageShouldDependOnRepositories() {
|
|
||||||
val rule = noClasses()
|
|
||||||
.that()
|
|
||||||
.resideInAPackage("..input")
|
|
||||||
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
|
|
||||||
|
|
||||||
rule.check(noTestClasses)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noClassesInOutputPackageShouldDependOnRepositories() {
|
|
||||||
val rule = noClasses()
|
|
||||||
.that()
|
|
||||||
.resideInAPackage("..output")
|
|
||||||
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
|
|
||||||
|
|
||||||
rule.check(noTestClasses)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noClassesInWebPackageShouldDependOnRepositories() {
|
|
||||||
val rule = noClasses()
|
|
||||||
.that()
|
|
||||||
.resideInAPackage("..web")
|
|
||||||
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
|
|
||||||
|
|
||||||
rule.check(noTestClasses)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun repositoryClassNamesShouldEndWithRepository() {
|
|
||||||
val rule = classes()
|
|
||||||
.that()
|
|
||||||
.areInterfaces().and().areAssignableTo(Repository::class.java)
|
|
||||||
.should().haveSimpleNameEndingWith("Repository")
|
|
||||||
|
|
||||||
rule.check(noTestClasses)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* This file is part of ETL-Processor
|
* This file is part of ETL-Processor
|
||||||
*
|
*
|
||||||
* Copyright (c) 2024 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
|
||||||
@ -20,15 +20,9 @@
|
|||||||
package dev.dnpm.etl.processor.config
|
package dev.dnpm.etl.processor.config
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import dev.dnpm.etl.processor.input.KafkaInputListener
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||||
import dev.dnpm.etl.processor.output.KafkaMtbFileSender
|
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.GpasPseudonymGenerator
|
|
||||||
import dev.dnpm.etl.processor.security.TokenRepository
|
|
||||||
import dev.dnpm.etl.processor.security.TokenService
|
|
||||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.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,30 +30,14 @@ 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.security.crypto.password.PasswordEncoder
|
|
||||||
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 = [KafkaAutoConfiguration::class, AppKafkaConfiguration::class, AppRestConfiguration::class])
|
||||||
classes = [
|
|
||||||
AppConfiguration::class,
|
|
||||||
AppSecurityConfiguration::class,
|
|
||||||
KafkaAutoConfiguration::class,
|
|
||||||
AppKafkaConfiguration::class,
|
|
||||||
AppRestConfiguration::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(types = [ObjectMapper::class])
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=BUILDIN",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class AppConfigurationTest {
|
class AppConfigurationTest {
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@ -82,12 +60,15 @@ class AppConfigurationTest {
|
|||||||
@TestPropertySource(
|
@TestPropertySource(
|
||||||
properties = [
|
properties = [
|
||||||
"app.kafka.servers=localhost:9092",
|
"app.kafka.servers=localhost:9092",
|
||||||
"app.kafka.output-topic=test",
|
"app.kafka.topic=test",
|
||||||
"app.kafka.output-response-topic=test-response",
|
"app.kafka.response-topic=test-response",
|
||||||
"app.kafka.group-id=test"
|
"app.kafka.group-id=test"
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@MockitoBean(types = [RequestRepository::class])
|
@MockBeans(value = [
|
||||||
|
MockBean(ObjectMapper::class),
|
||||||
|
MockBean(RequestRepository::class)
|
||||||
|
])
|
||||||
inner class AppConfigurationKafkaTest(private val context: ApplicationContext) {
|
inner class AppConfigurationKafkaTest(private val context: ApplicationContext) {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -103,8 +84,8 @@ class AppConfigurationTest {
|
|||||||
properties = [
|
properties = [
|
||||||
"app.rest.uri=http://localhost:9000",
|
"app.rest.uri=http://localhost:9000",
|
||||||
"app.kafka.servers=localhost:9092",
|
"app.kafka.servers=localhost:9092",
|
||||||
"app.kafka.output-topic=test",
|
"app.kafka.topic=test",
|
||||||
"app.kafka.output-response-topic=test-response",
|
"app.kafka.response-topic=test-response",
|
||||||
"app.kafka.group-id=test"
|
"app.kafka.group-id=test"
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -118,162 +99,4 @@ class AppConfigurationTest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.kafka.servers=localhost:9092",
|
|
||||||
"app.kafka.output-topic=test",
|
|
||||||
"app.kafka.output-response-topic=test-response",
|
|
||||||
"app.kafka.group-id=test"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class AppConfigurationWithoutKafkaInputTest(private val context: ApplicationContext) {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldNotUseKafkaInputListener() {
|
|
||||||
assertThrows<NoSuchBeanDefinitionException> { context.getBean(KafkaInputListener::class.java) }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.kafka.servers=localhost:9092",
|
|
||||||
"app.kafka.input-topic=test_input",
|
|
||||||
"app.kafka.output-topic=test",
|
|
||||||
"app.kafka.output-response-topic=test-response",
|
|
||||||
"app.kafka.group-id=test"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(types = [RequestProcessor::class])
|
|
||||||
inner class AppConfigurationUsingKafkaInputTest(private val context: ApplicationContext) {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldUseKafkaInputListener() {
|
|
||||||
assertThat(context.getBean(KafkaInputListener::class.java)).isNotNull
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.transformations[0].path=consent.status",
|
|
||||||
"app.transformations[0].from=rejected",
|
|
||||||
"app.transformations[0].to=accept",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class AppConfigurationTransformationTest(private val context: ApplicationContext) {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldRecognizeTransformations() {
|
|
||||||
val appConfigProperties = context.getBean(AppConfigProperties::class.java)
|
|
||||||
|
|
||||||
assertThat(appConfigProperties).isNotNull
|
|
||||||
assertThat(appConfigProperties.transformations).hasSize(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class AppConfigurationPseudonymizeTest {
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=buildin"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class AppConfigurationPseudonymizeGeneratorBuildinTest(private val context: ApplicationContext) {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldUseConfiguredGenerator() {
|
|
||||||
assertThat(context.getBean(AnonymizingGenerator::class.java)).isNotNull
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=gpas"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class AppConfigurationPseudonymizeGeneratorGpasTest(private val context: ApplicationContext) {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldUseConfiguredGenerator() {
|
|
||||||
assertThat(context.getBean(GpasPseudonymGenerator::class.java)).isNotNull
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.security.enable-tokens=true"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(
|
|
||||||
types = [
|
|
||||||
InMemoryUserDetailsManager::class,
|
|
||||||
PasswordEncoder::class,
|
|
||||||
TokenRepository::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class AppConfigurationTokenEnabledTest(private val context: ApplicationContext) {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun checkTokenService() {
|
|
||||||
assertThat(context.getBean(TokenService::class.java)).isNotNull
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@MockitoBean(
|
|
||||||
types = [
|
|
||||||
InMemoryUserDetailsManager::class,
|
|
||||||
PasswordEncoder::class,
|
|
||||||
TokenRepository::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class AppConfigurationTokenDisabledTest(private val context: ApplicationContext) {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun checkTokenService() {
|
|
||||||
assertThrows<NoSuchBeanDefinitionException> { context.getBean(TokenService::class.java) }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.rest.uri=http://localhost:9000",
|
|
||||||
"app.max-retry-attempts=5"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class AppConfigurationRetryTest(private val context: ApplicationContext) {
|
|
||||||
|
|
||||||
private val maxRetryAttempts = 5
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldUseRetryTemplateWithConfiguredMaxAttempts() {
|
|
||||||
val retryTemplate = context.getBean(RetryTemplate::class.java)
|
|
||||||
assertThat(retryTemplate).isNotNull
|
|
||||||
|
|
||||||
assertThrows<RuntimeException> {
|
|
||||||
retryTemplate.execute<Void, RuntimeException> {
|
|
||||||
assertThat(it.retryCount).isLessThan(maxRetryAttempts)
|
|
||||||
throw RuntimeException()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
@ -1,30 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* 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
|
|
||||||
|
|
||||||
import org.mockito.ArgumentMatchers
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
inline fun <reified T> anyValueClass(): T {
|
|
||||||
val unboxedClass = T::class.java.declaredFields.first().type
|
|
||||||
return ArgumentMatchers.any(unboxedClass as Class<T>)
|
|
||||||
?: T::class.java.getDeclaredMethod("box-impl", unboxedClass)
|
|
||||||
.invoke(null, null) as T
|
|
||||||
}
|
|
@ -1,225 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* 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.input
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import de.ukw.ccc.bwhc.dto.*
|
|
||||||
import dev.dnpm.etl.processor.anyValueClass
|
|
||||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
|
||||||
import dev.dnpm.etl.processor.security.TokenRepository
|
|
||||||
import dev.dnpm.etl.processor.security.UserRoleRepository
|
|
||||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.mockito.kotlin.any
|
|
||||||
import org.mockito.kotlin.never
|
|
||||||
import org.mockito.kotlin.times
|
|
||||||
import org.mockito.kotlin.verify
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
|
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
|
||||||
import org.springframework.test.context.ContextConfiguration
|
|
||||||
import org.springframework.test.context.TestPropertySource
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
|
||||||
import org.springframework.test.web.servlet.delete
|
|
||||||
import org.springframework.test.web.servlet.post
|
|
||||||
|
|
||||||
@WebMvcTest(controllers = [MtbFileRestController::class])
|
|
||||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
|
||||||
@ContextConfiguration(
|
|
||||||
classes = [
|
|
||||||
MtbFileRestController::class,
|
|
||||||
AppSecurityConfiguration::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(types = [TokenRepository::class, RequestProcessor::class])
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=BUILDIN",
|
|
||||||
"app.security.admin-user=admin",
|
|
||||||
"app.security.admin-password={noop}very-secret",
|
|
||||||
"app.security.enable-tokens=true"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class MtbFileRestControllerTest {
|
|
||||||
|
|
||||||
private lateinit var mockMvc: MockMvc
|
|
||||||
|
|
||||||
private lateinit var requestProcessor: RequestProcessor
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Autowired mockMvc: MockMvc,
|
|
||||||
@Autowired requestProcessor: RequestProcessor
|
|
||||||
) {
|
|
||||||
this.mockMvc = mockMvc
|
|
||||||
this.requestProcessor = requestProcessor
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldGrantPermissionToSendMtbFile() {
|
|
||||||
mockMvc.post("/mtbfile") {
|
|
||||||
with(user("onkostarserver").roles("MTBFILE"))
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
|
||||||
}.andExpect {
|
|
||||||
status { isAccepted() }
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
|
|
||||||
mockMvc.post("/mtbfile") {
|
|
||||||
with(user("onkostarserver").roles("ADMIN"))
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
|
||||||
}.andExpect {
|
|
||||||
status { isAccepted() }
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldDenyPermissionToSendMtbFile() {
|
|
||||||
mockMvc.post("/mtbfile") {
|
|
||||||
with(anonymous())
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
|
||||||
}.andExpect {
|
|
||||||
status { isUnauthorized() }
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, never()).processMtbFile(any<MtbFile>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldDenyPermissionToSendMtbFileForUser() {
|
|
||||||
mockMvc.post("/mtbfile") {
|
|
||||||
with(user("fakeuser").roles("USER"))
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
|
||||||
}.andExpect {
|
|
||||||
status { isForbidden() }
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, never()).processMtbFile(any<MtbFile>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldGrantPermissionToDeletePatientData() {
|
|
||||||
mockMvc.delete("/mtbfile/12345678") {
|
|
||||||
with(user("onkostarserver").roles("MTBFILE"))
|
|
||||||
}.andExpect {
|
|
||||||
status { isAccepted() }
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldDenyPermissionToDeletePatientData() {
|
|
||||||
mockMvc.delete("/mtbfile/12345678") {
|
|
||||||
with(anonymous())
|
|
||||||
}.andExpect {
|
|
||||||
status { isUnauthorized() }
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, never()).processDeletion(anyValueClass())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@MockitoBean(types = [UserRoleRepository::class, ClientRegistrationRepository::class])
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=BUILDIN",
|
|
||||||
"app.security.admin-user=admin",
|
|
||||||
"app.security.admin-password={noop}very-secret",
|
|
||||||
"app.security.enable-tokens=true",
|
|
||||||
"app.security.enable-oidc=true"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class WithOidcEnabled {
|
|
||||||
@Test
|
|
||||||
fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
|
|
||||||
mockMvc.post("/mtbfile") {
|
|
||||||
with(user("onkostarserver").roles("ADMIN"))
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
|
||||||
}.andExpect {
|
|
||||||
status { isAccepted() }
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldGrantPermissionToSendMtbFileToUser() {
|
|
||||||
mockMvc.post("/mtbfile") {
|
|
||||||
with(user("onkostarserver").roles("USER"))
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
|
||||||
}.andExpect {
|
|
||||||
status { isAccepted() }
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
val mtbFile: MtbFile = MtbFile.builder()
|
|
||||||
.withPatient(
|
|
||||||
Patient.builder()
|
|
||||||
.withId("PID")
|
|
||||||
.withBirthDate("2000-08-08")
|
|
||||||
.withGender(Patient.Gender.MALE)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withConsent(
|
|
||||||
Consent.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withStatus(Consent.Status.ACTIVE)
|
|
||||||
.withPatient("PID")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withEpisode(
|
|
||||||
Episode.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withPatient("PID")
|
|
||||||
.withPeriod(PeriodStart("2023-08-08"))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.monitoring
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.*
|
|
||||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
|
||||||
import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest
|
|
||||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
|
||||||
import org.springframework.test.context.TestPropertySource
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
@Testcontainers
|
|
||||||
@ExtendWith(SpringExtension::class)
|
|
||||||
@DataJdbcTest
|
|
||||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
|
||||||
@Transactional
|
|
||||||
@MockitoBean(types = [MtbFileSender::class])
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=buildin",
|
|
||||||
"app.rest.uri=http://example.com"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class RequestRepositoryTest : AbstractTestcontainerTest() {
|
|
||||||
|
|
||||||
private lateinit var requestRepository: RequestRepository
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setUp(
|
|
||||||
@Autowired requestRepository: RequestRepository
|
|
||||||
) {
|
|
||||||
this.requestRepository = requestRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldSaveRequest() {
|
|
||||||
val request = Request(
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("TEST_12345678901"),
|
|
||||||
PatientId("P1"),
|
|
||||||
Fingerprint("0123456789abcdef1"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.WARNING,
|
|
||||||
Instant.parse("2023-07-07T00:00:00Z")
|
|
||||||
)
|
|
||||||
|
|
||||||
requestRepository.save(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,135 +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.pseudonym
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.config.GPasConfigProperties
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.assertThrows
|
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.HttpMethod
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.retry.policy.SimpleRetryPolicy
|
|
||||||
import org.springframework.retry.support.RetryTemplateBuilder
|
|
||||||
import org.springframework.test.web.client.MockRestServiceServer
|
|
||||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
|
|
||||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
|
|
||||||
import org.springframework.test.web.client.response.MockRestResponseCreators.withException
|
|
||||||
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
class GpasPseudonymGeneratorTest {
|
|
||||||
|
|
||||||
private lateinit var mockRestServiceServer: MockRestServiceServer
|
|
||||||
private lateinit var generator: GpasPseudonymGenerator
|
|
||||||
private lateinit var restTemplate: RestTemplate
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
|
|
||||||
val gPasConfigProperties = GPasConfigProperties(
|
|
||||||
"https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate",
|
|
||||||
"test",
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
this.restTemplate = RestTemplate()
|
|
||||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
|
||||||
this.generator = GpasPseudonymGenerator(gPasConfigProperties, retryTemplate, restTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldReturnExpectedPseudonym() {
|
|
||||||
this.mockRestServiceServer.expect {
|
|
||||||
method(HttpMethod.POST)
|
|
||||||
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
|
|
||||||
}.andRespond {
|
|
||||||
withStatus(HttpStatus.OK).body(getDummyResponseBody("1234", "test", "test1234ABCDEF567890"))
|
|
||||||
.createResponse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertThat(this.generator.generate("ID1234")).isEqualTo("test1234ABCDEF567890")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldThrowExceptionIfGpasNotAvailable() {
|
|
||||||
this.mockRestServiceServer.expect {
|
|
||||||
method(HttpMethod.POST)
|
|
||||||
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
|
|
||||||
}.andRespond {
|
|
||||||
withException(IOException("Simulated IO error")).createResponse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() {
|
|
||||||
this.mockRestServiceServer.expect {
|
|
||||||
method(HttpMethod.POST)
|
|
||||||
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
|
|
||||||
}.andRespond {
|
|
||||||
withStatus(HttpStatus.FOUND)
|
|
||||||
.header(HttpHeaders.LOCATION, "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
|
|
||||||
.createResponse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun getDummyResponseBody(original: String, target: String, pseudonym: String) = """{
|
|
||||||
"resourceType": "Parameters",
|
|
||||||
"parameter": [
|
|
||||||
{
|
|
||||||
"name": "pseudonym",
|
|
||||||
"part": [
|
|
||||||
{
|
|
||||||
"name": "original",
|
|
||||||
"valueIdentifier": {
|
|
||||||
"system": "https://ths-greifswald.de/gpas",
|
|
||||||
"value": "$original"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "target",
|
|
||||||
"valueIdentifier": {
|
|
||||||
"system": "https://ths-greifswald.de/gpas",
|
|
||||||
"value": "$target"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pseudonym",
|
|
||||||
"valueIdentifier": {
|
|
||||||
"system": "https://ths-greifswald.de/gpas",
|
|
||||||
"value": "$pseudonym"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}""".trimIndent()
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.services
|
package dev.dnpm.etl.processor.services
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.*
|
import dev.dnpm.etl.processor.AbstractTestcontainerTest
|
||||||
import dev.dnpm.etl.processor.monitoring.Request
|
import dev.dnpm.etl.processor.monitoring.Request
|
||||||
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
|
||||||
@ -31,24 +31,18 @@ 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.test.context.TestPropertySource
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
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
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
@ExtendWith(SpringExtension::class)
|
@ExtendWith(SpringExtension::class)
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@Transactional
|
@Transactional
|
||||||
@MockitoBean(types = [MtbFileSender::class])
|
@MockBean(MtbFileSender::class)
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=buildin",
|
|
||||||
"app.rest.uri=http://example.com"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||||
|
|
||||||
private lateinit var requestRepository: RequestRepository
|
private lateinit var requestRepository: RequestRepository
|
||||||
@ -65,7 +59,7 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldResultInEmptyRequestList() {
|
fun shouldResultInEmptyRequestList() {
|
||||||
val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
|
val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
|
||||||
|
|
||||||
assertThat(actual).isEmpty()
|
assertThat(actual).isEmpty()
|
||||||
}
|
}
|
||||||
@ -75,33 +69,33 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
|||||||
this.requestRepository.saveAll(
|
this.requestRepository.saveAll(
|
||||||
listOf(
|
listOf(
|
||||||
Request(
|
Request(
|
||||||
randomRequestId(),
|
uuid = UUID.randomUUID().toString(),
|
||||||
PatientPseudonym("TEST_12345678901"),
|
patientId = "TEST_12345678901",
|
||||||
PatientId("P1"),
|
pid = "P1",
|
||||||
Fingerprint("0123456789abcdef1"),
|
fingerprint = "0123456789abcdef1",
|
||||||
RequestType.MTB_FILE,
|
type = RequestType.MTB_FILE,
|
||||||
RequestStatus.SUCCESS,
|
status = RequestStatus.SUCCESS,
|
||||||
Instant.parse("2023-07-07T02:00:00Z")
|
processedAt = Instant.parse("2023-07-07T02:00:00Z")
|
||||||
),
|
),
|
||||||
// Should be ignored - wrong patient ID -->
|
// Should be ignored - wrong patient ID -->
|
||||||
Request(
|
Request(
|
||||||
randomRequestId(),
|
uuid = UUID.randomUUID().toString(),
|
||||||
PatientPseudonym("TEST_12345678902"),
|
patientId = "TEST_12345678902",
|
||||||
PatientId("P2"),
|
pid = "P2",
|
||||||
Fingerprint("0123456789abcdef2"),
|
fingerprint = "0123456789abcdef2",
|
||||||
RequestType.MTB_FILE,
|
type = RequestType.MTB_FILE,
|
||||||
RequestStatus.WARNING,
|
status = RequestStatus.WARNING,
|
||||||
Instant.parse("2023-08-08T00:00:00Z")
|
processedAt = Instant.parse("2023-08-08T00:00:00Z")
|
||||||
),
|
),
|
||||||
// <--
|
// <--
|
||||||
Request(
|
Request(
|
||||||
randomRequestId(),
|
uuid = UUID.randomUUID().toString(),
|
||||||
PatientPseudonym("TEST_12345678901"),
|
patientId = "TEST_12345678901",
|
||||||
PatientId("P2"),
|
pid = "P2",
|
||||||
Fingerprint("0123456789abcdee1"),
|
fingerprint = "0123456789abcdee1",
|
||||||
RequestType.DELETE,
|
type = RequestType.DELETE,
|
||||||
RequestStatus.SUCCESS,
|
status = RequestStatus.SUCCESS,
|
||||||
Instant.parse("2023-08-08T02:00:00Z")
|
processedAt = Instant.parse("2023-08-08T02:00:00Z")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -111,18 +105,18 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
|||||||
fun shouldResultInSortedRequestList() {
|
fun shouldResultInSortedRequestList() {
|
||||||
setupTestData()
|
setupTestData()
|
||||||
|
|
||||||
val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
|
val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
|
||||||
|
|
||||||
assertThat(actual).hasSize(2)
|
assertThat(actual).hasSize(2)
|
||||||
assertThat(actual[0].fingerprint).isEqualTo(Fingerprint("0123456789abcdee1"))
|
assertThat(actual[0].fingerprint).isEqualTo("0123456789abcdee1")
|
||||||
assertThat(actual[1].fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
|
assertThat(actual[1].fingerprint).isEqualTo("0123456789abcdef1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldReturnDeleteRequestAsLastRequest() {
|
fun shouldReturnDeleteRequestAsLastRequest() {
|
||||||
setupTestData()
|
setupTestData()
|
||||||
|
|
||||||
val actual = requestService.isLastRequestWithKnownStatusDeletion(TEST_PATIENT_PSEUDONYM)
|
val actual = requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901")
|
||||||
|
|
||||||
assertThat(actual).isTrue()
|
assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
@ -131,14 +125,10 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
|||||||
fun shouldReturnLastMtbFileRequest() {
|
fun shouldReturnLastMtbFileRequest() {
|
||||||
setupTestData()
|
setupTestData()
|
||||||
|
|
||||||
val actual = requestService.lastMtbFileRequestForPatientPseudonym(TEST_PATIENT_PSEUDONYM)
|
val actual = requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901")
|
||||||
|
|
||||||
assertThat(actual).isNotNull
|
assertThat(actual).isNotNull
|
||||||
assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
|
assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef1")
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("TEST_12345678901")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,369 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.web
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
|
||||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
|
|
||||||
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
|
||||||
import dev.dnpm.etl.processor.pseudonym.Generator
|
|
||||||
import dev.dnpm.etl.processor.security.Role
|
|
||||||
import dev.dnpm.etl.processor.security.TokenService
|
|
||||||
import dev.dnpm.etl.processor.security.UserRoleService
|
|
||||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
|
||||||
import dev.dnpm.etl.processor.services.TransformationService
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.htmlunit.WebClient
|
|
||||||
import org.htmlunit.html.HtmlPage
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.ArgumentMatchers.anyString
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.mockito.kotlin.argumentCaptor
|
|
||||||
import org.mockito.kotlin.times
|
|
||||||
import org.mockito.kotlin.verify
|
|
||||||
import org.mockito.kotlin.whenever
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.http.MediaType.TEXT_EVENT_STREAM
|
|
||||||
import org.springframework.security.test.context.support.WithMockUser
|
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
|
||||||
import org.springframework.test.context.ContextConfiguration
|
|
||||||
import org.springframework.test.context.TestPropertySource
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
|
||||||
import org.springframework.test.web.reactive.server.WebTestClient
|
|
||||||
import org.springframework.test.web.servlet.*
|
|
||||||
import org.springframework.test.web.servlet.client.MockMvcWebTestClient
|
|
||||||
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
|
|
||||||
import org.springframework.web.context.WebApplicationContext
|
|
||||||
import reactor.core.publisher.Sinks
|
|
||||||
import reactor.test.StepVerifier
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
abstract class MockSink : Sinks.Many<Boolean>
|
|
||||||
|
|
||||||
@WebMvcTest(controllers = [ConfigController::class])
|
|
||||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
|
||||||
@ContextConfiguration(
|
|
||||||
classes = [
|
|
||||||
ConfigController::class,
|
|
||||||
AppConfiguration::class,
|
|
||||||
AppSecurityConfiguration::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=BUILDIN"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(name = "configsUpdateProducer", types = [MockSink::class])
|
|
||||||
@MockitoBean(
|
|
||||||
types = [
|
|
||||||
Generator::class,
|
|
||||||
MtbFileSender::class,
|
|
||||||
RequestProcessor::class,
|
|
||||||
TransformationService::class,
|
|
||||||
GPasConnectionCheckService::class,
|
|
||||||
RestConnectionCheckService::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class ConfigControllerTest {
|
|
||||||
|
|
||||||
private lateinit var mockMvc: MockMvc
|
|
||||||
private lateinit var webClient: WebClient
|
|
||||||
|
|
||||||
private lateinit var requestProcessor: RequestProcessor
|
|
||||||
private lateinit var connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Autowired mockMvc: MockMvc,
|
|
||||||
@Autowired requestProcessor: RequestProcessor,
|
|
||||||
@Autowired connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
|
||||||
) {
|
|
||||||
this.mockMvc = mockMvc
|
|
||||||
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
|
|
||||||
this.requestProcessor = requestProcessor
|
|
||||||
this.connectionCheckUpdateProducer = connectionCheckUpdateProducer
|
|
||||||
|
|
||||||
webClient.options.isThrowExceptionOnScriptError = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestConfigPageIfLoggedIn() {
|
|
||||||
mockMvc.get("/configs") {
|
|
||||||
with(user("admin").roles("ADMIN"))
|
|
||||||
accept(MediaType.TEXT_HTML)
|
|
||||||
}.andExpect {
|
|
||||||
status { isOk() }
|
|
||||||
view { name("configs") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRedirectToLoginPageIfNotLoggedIn() {
|
|
||||||
mockMvc.get("/configs") {
|
|
||||||
with(anonymous())
|
|
||||||
accept(MediaType.TEXT_HTML)
|
|
||||||
}.andExpect {
|
|
||||||
status { isFound() }
|
|
||||||
header {
|
|
||||||
stringValues(HttpHeaders.LOCATION, "http://localhost/login")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.security.enable-tokens=true",
|
|
||||||
"app.security.admin-user=admin"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(
|
|
||||||
types = [
|
|
||||||
TokenService::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class WithTokensEnabled {
|
|
||||||
private lateinit var tokenService: TokenService
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Autowired tokenService: TokenService
|
|
||||||
) {
|
|
||||||
webClient.options.isThrowExceptionOnScriptError = false
|
|
||||||
|
|
||||||
this.tokenService = tokenService
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldSaveNewToken() {
|
|
||||||
mockMvc.post("/configs/tokens") {
|
|
||||||
with(user("admin").roles("ADMIN"))
|
|
||||||
accept(MediaType.TEXT_HTML)
|
|
||||||
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
|
||||||
content = "name=Testtoken"
|
|
||||||
}.andExpect {
|
|
||||||
status { is2xxSuccessful() }
|
|
||||||
view { name("configs/tokens") }
|
|
||||||
}
|
|
||||||
|
|
||||||
val captor = argumentCaptor<String>()
|
|
||||||
verify(tokenService, times(1)).addToken(captor.capture())
|
|
||||||
|
|
||||||
assertThat(captor.firstValue).isEqualTo("Testtoken")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldNotSaveTokenWithExstingName() {
|
|
||||||
whenever(tokenService.addToken(anyString())).thenReturn(Result.failure(RuntimeException("Testfailure")))
|
|
||||||
|
|
||||||
mockMvc.post("/configs/tokens") {
|
|
||||||
with(user("admin").roles("ADMIN"))
|
|
||||||
accept(MediaType.TEXT_HTML)
|
|
||||||
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
|
||||||
content = "name=Testtoken"
|
|
||||||
}.andExpect {
|
|
||||||
status { is2xxSuccessful() }
|
|
||||||
view { name("configs/tokens") }
|
|
||||||
}
|
|
||||||
|
|
||||||
val captor = argumentCaptor<String>()
|
|
||||||
verify(tokenService, times(1)).addToken(captor.capture())
|
|
||||||
|
|
||||||
assertThat(captor.firstValue).isEqualTo("Testtoken")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldDeleteToken() {
|
|
||||||
mockMvc.delete("/configs/tokens/42") {
|
|
||||||
with(user("admin").roles("ADMIN"))
|
|
||||||
accept(MediaType.TEXT_HTML)
|
|
||||||
}.andExpect {
|
|
||||||
status { is2xxSuccessful() }
|
|
||||||
view { name("configs/tokens") }
|
|
||||||
}
|
|
||||||
|
|
||||||
val captor = argumentCaptor<Long>()
|
|
||||||
verify(tokenService, times(1)).deleteToken(captor.capture())
|
|
||||||
|
|
||||||
assertThat(captor.firstValue).isEqualTo(42)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
|
||||||
fun testShouldRenderConfigPageWithTokens() {
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
|
|
||||||
assertThat(
|
|
||||||
page.getElementById("tokens")
|
|
||||||
).isNotNull
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.security.enable-tokens=false"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class WithTokensDisabled {
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
webClient.options.isThrowExceptionOnScriptError = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
|
||||||
fun testShouldRenderConfigPageWithoutTokens() {
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
|
|
||||||
assertThat(
|
|
||||||
page.getElementById("tokens")
|
|
||||||
).isNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.security.enable-tokens=false",
|
|
||||||
"app.security.admin-user=admin",
|
|
||||||
"app.security.admin-password={noop}very-secret"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(
|
|
||||||
types = [
|
|
||||||
UserRoleService::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
inner class WithUserRolesEnabled {
|
|
||||||
private lateinit var userRoleService: UserRoleService
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Autowired userRoleService: UserRoleService
|
|
||||||
) {
|
|
||||||
webClient.options.isThrowExceptionOnScriptError = false
|
|
||||||
|
|
||||||
this.userRoleService = userRoleService
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldDeleteUserRole() {
|
|
||||||
mockMvc.delete("/configs/userroles/42") {
|
|
||||||
with(user("admin").roles("ADMIN"))
|
|
||||||
accept(MediaType.TEXT_HTML)
|
|
||||||
}.andExpect {
|
|
||||||
status { is2xxSuccessful() }
|
|
||||||
view { name("configs/userroles") }
|
|
||||||
}
|
|
||||||
|
|
||||||
val captor = argumentCaptor<Long>()
|
|
||||||
verify(userRoleService, times(1)).deleteUserRole(captor.capture())
|
|
||||||
|
|
||||||
assertThat(captor.firstValue).isEqualTo(42)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldUpdateUserRole() {
|
|
||||||
mockMvc.put("/configs/userroles/42") {
|
|
||||||
with(user("admin").roles("ADMIN"))
|
|
||||||
accept(MediaType.TEXT_HTML)
|
|
||||||
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
|
||||||
content = "role=ADMIN"
|
|
||||||
}.andExpect {
|
|
||||||
status { is2xxSuccessful() }
|
|
||||||
view { name("configs/userroles") }
|
|
||||||
}
|
|
||||||
|
|
||||||
val idCaptor = argumentCaptor<Long>()
|
|
||||||
val roleCaptor = argumentCaptor<Role>()
|
|
||||||
verify(userRoleService, times(1)).updateUserRole(idCaptor.capture(), roleCaptor.capture())
|
|
||||||
|
|
||||||
assertThat(idCaptor.firstValue).isEqualTo(42)
|
|
||||||
assertThat(roleCaptor.firstValue).isEqualTo(Role.ADMIN)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
|
||||||
fun testShouldRenderConfigPageWithUserRoles() {
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
|
|
||||||
assertThat(
|
|
||||||
page.getElementById("userroles")
|
|
||||||
).isNotNull
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class WithUserRolesDisabled {
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
webClient.options.isThrowExceptionOnScriptError = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRenderConfigPageWithoutUserRoles() {
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
|
|
||||||
assertThat(
|
|
||||||
page.getElementById("userroles")
|
|
||||||
).isNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class SseTest {
|
|
||||||
private lateinit var webClient: WebTestClient
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
applicationContext: WebApplicationContext,
|
|
||||||
) {
|
|
||||||
this.webClient = MockMvcWebTestClient
|
|
||||||
.bindToApplicationContext(applicationContext).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestSSE() {
|
|
||||||
val expectedEvent = ConnectionCheckResult.GPasConnectionCheckResult(true, Instant.now(), Instant.now())
|
|
||||||
|
|
||||||
connectionCheckUpdateProducer.tryEmitNext(expectedEvent)
|
|
||||||
connectionCheckUpdateProducer.emitComplete { _, _ -> true }
|
|
||||||
|
|
||||||
val result = webClient.get().uri("http://localhost/configs/events").accept(TEXT_EVENT_STREAM).exchange()
|
|
||||||
.expectStatus().isOk()
|
|
||||||
.expectHeader().contentType(TEXT_EVENT_STREAM)
|
|
||||||
.returnResult(ConnectionCheckResult.GPasConnectionCheckResult::class.java)
|
|
||||||
|
|
||||||
StepVerifier.create(result.responseBody)
|
|
||||||
.expectNext(expectedEvent)
|
|
||||||
.expectComplete()
|
|
||||||
.verify()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,287 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.web
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.*
|
|
||||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
|
||||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
|
||||||
import dev.dnpm.etl.processor.monitoring.Report
|
|
||||||
import dev.dnpm.etl.processor.monitoring.Request
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestType
|
|
||||||
import dev.dnpm.etl.processor.services.RequestService
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.htmlunit.WebClient
|
|
||||||
import org.htmlunit.html.HtmlPage
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.assertThrows
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.mockito.kotlin.any
|
|
||||||
import org.mockito.kotlin.whenever
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
|
||||||
import org.springframework.data.domain.Page
|
|
||||||
import org.springframework.data.domain.PageImpl
|
|
||||||
import org.springframework.data.domain.Pageable
|
|
||||||
import org.springframework.security.test.context.support.WithMockUser
|
|
||||||
import org.springframework.test.context.ContextConfiguration
|
|
||||||
import org.springframework.test.context.TestPropertySource
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
|
||||||
import org.springframework.test.web.servlet.get
|
|
||||||
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
|
|
||||||
import java.io.IOException
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@WebMvcTest(controllers = [HomeController::class])
|
|
||||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
|
||||||
@ContextConfiguration(
|
|
||||||
classes = [
|
|
||||||
HomeController::class,
|
|
||||||
AppConfiguration::class,
|
|
||||||
AppSecurityConfiguration::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=BUILDIN",
|
|
||||||
"app.security.admin-user=admin",
|
|
||||||
"app.security.admin-password={noop}very-secret"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(
|
|
||||||
types = [RequestService::class]
|
|
||||||
)
|
|
||||||
class HomeControllerTest {
|
|
||||||
|
|
||||||
private lateinit var mockMvc: MockMvc
|
|
||||||
private lateinit var webClient: WebClient
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Autowired mockMvc: MockMvc,
|
|
||||||
@Autowired requestService: RequestService
|
|
||||||
) {
|
|
||||||
this.mockMvc = mockMvc
|
|
||||||
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
|
|
||||||
|
|
||||||
whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestHomePage() {
|
|
||||||
mockMvc.get("/").andExpect {
|
|
||||||
status { isOk() }
|
|
||||||
view { name("index") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class WithRequests {
|
|
||||||
|
|
||||||
private lateinit var requestService: RequestService
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Autowired requestService: RequestService
|
|
||||||
) {
|
|
||||||
this.requestService = requestService
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldShowHomePage() {
|
|
||||||
whenever(requestService.findAll(any<Pageable>())).thenReturn(
|
|
||||||
PageImpl(
|
|
||||||
listOf(
|
|
||||||
Request(
|
|
||||||
2L,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("PSEUDO1"),
|
|
||||||
PatientId("PATIENT1"),
|
|
||||||
Fingerprint("ashdkasdh"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.SUCCESS
|
|
||||||
),
|
|
||||||
Request(
|
|
||||||
1L,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("PSEUDO1"),
|
|
||||||
PatientId("PATIENT1"),
|
|
||||||
Fingerprint("asdasdasd"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.ERROR
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/")
|
|
||||||
assertThat(page.querySelectorAll("tbody tr")).hasSize(2)
|
|
||||||
assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
|
||||||
fun testShouldShowRequestDetails() {
|
|
||||||
val requestId = randomRequestId()
|
|
||||||
|
|
||||||
whenever(requestService.findByUuid(anyValueClass())).thenReturn(
|
|
||||||
Optional.of(
|
|
||||||
Request(
|
|
||||||
2L,
|
|
||||||
requestId,
|
|
||||||
PatientPseudonym("PSEUDO1"),
|
|
||||||
PatientId("PATIENT1"),
|
|
||||||
Fingerprint("ashdkasdh"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.SUCCESS,
|
|
||||||
Instant.now(),
|
|
||||||
Report("Test")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}")
|
|
||||||
assertThat(page.querySelectorAll("tbody tr")).hasSize(1)
|
|
||||||
assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
|
||||||
fun testShouldShowPatientDetails() {
|
|
||||||
whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(
|
|
||||||
PageImpl(
|
|
||||||
listOf(
|
|
||||||
Request(
|
|
||||||
2L,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("PSEUDO1"),
|
|
||||||
PatientId("PATIENT1"),
|
|
||||||
Fingerprint("ashdkasdh"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.SUCCESS
|
|
||||||
),
|
|
||||||
Request(
|
|
||||||
1L,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("PSEUDO1"),
|
|
||||||
PatientId("PATIENT1"),
|
|
||||||
Fingerprint("asdasdasd"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.ERROR
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
|
|
||||||
assertThat(page.querySelectorAll("tbody tr")).hasSize(2)
|
|
||||||
assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
|
||||||
fun testShouldShowPatientPseudonym() {
|
|
||||||
whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(
|
|
||||||
PageImpl(
|
|
||||||
listOf(
|
|
||||||
Request(
|
|
||||||
2L,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("PSEUDO1"),
|
|
||||||
PatientId("PATIENT1"),
|
|
||||||
Fingerprint("ashdkasdh"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.SUCCESS
|
|
||||||
),
|
|
||||||
Request(
|
|
||||||
1L,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("PSEUDO1"),
|
|
||||||
PatientId("PATIENT1"),
|
|
||||||
Fingerprint("asdasdasd"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.ERROR
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
|
|
||||||
assertThat(page.querySelectorAll("h2 > span")).hasSize(1)
|
|
||||||
assertThat(page.querySelectorAll("h2 > span").first().textContent).isEqualTo("PSEUDO1")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class WithoutRequests {
|
|
||||||
|
|
||||||
private lateinit var requestService: RequestService
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Autowired requestService: RequestService
|
|
||||||
) {
|
|
||||||
this.requestService = requestService
|
|
||||||
|
|
||||||
whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldShowHomePage() {
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/")
|
|
||||||
assertThat(page.querySelectorAll("tbody tr")).isEmpty()
|
|
||||||
assertThat(page.querySelectorAll("div.notification.info")).hasSize(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
|
||||||
fun testShouldThrowNotFoundExceptionForUnknownReport() {
|
|
||||||
val requestId = randomRequestId()
|
|
||||||
|
|
||||||
whenever(requestService.findByUuid(anyValueClass())).thenReturn(
|
|
||||||
Optional.empty()
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThrows<IOException> {
|
|
||||||
webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}")
|
|
||||||
}.also {
|
|
||||||
assertThat(it).hasRootCauseInstanceOf(NotFoundException::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
|
||||||
fun testShouldShowEmptyPatientDetails() {
|
|
||||||
whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(Page.empty())
|
|
||||||
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
|
|
||||||
assertThat(page.querySelectorAll("tbody tr")).isEmpty()
|
|
||||||
assertThat(page.querySelectorAll("div.notification.info")).hasSize(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,88 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.web
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
|
||||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
|
||||||
import dev.dnpm.etl.processor.security.TokenService
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.htmlunit.WebClient
|
|
||||||
import org.htmlunit.html.HtmlPage
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
|
||||||
import org.springframework.test.context.ContextConfiguration
|
|
||||||
import org.springframework.test.context.TestPropertySource
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
|
||||||
import org.springframework.test.web.servlet.get
|
|
||||||
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
|
|
||||||
|
|
||||||
@WebMvcTest(controllers = [LoginController::class])
|
|
||||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
|
||||||
@ContextConfiguration(
|
|
||||||
classes = [
|
|
||||||
LoginController::class,
|
|
||||||
AppConfiguration::class,
|
|
||||||
AppSecurityConfiguration::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=BUILDIN",
|
|
||||||
"app.security.admin-user=admin",
|
|
||||||
"app.security.admin-password={noop}very-secret",
|
|
||||||
"app.security.enable-tokens=true"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(
|
|
||||||
types = [TokenService::class]
|
|
||||||
)
|
|
||||||
class LoginControllerTest {
|
|
||||||
|
|
||||||
private lateinit var mockMvc: MockMvc
|
|
||||||
private lateinit var webClient: WebClient
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(@Autowired mockMvc: MockMvc) {
|
|
||||||
this.mockMvc = mockMvc
|
|
||||||
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestLoginPage() {
|
|
||||||
mockMvc.get("/login").andExpect {
|
|
||||||
status { isOk() }
|
|
||||||
view { name("login") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldShowLoginForm() {
|
|
||||||
val page = webClient.getPage<HtmlPage>("http://localhost/login")
|
|
||||||
assertThat(
|
|
||||||
page.getElementsByTagName("main").first().firstElementChild.getAttribute("class")
|
|
||||||
).isEqualTo("login-form")
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,73 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.web
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
|
||||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
|
||||||
import org.htmlunit.WebClient
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
|
||||||
import org.springframework.test.context.ContextConfiguration
|
|
||||||
import org.springframework.test.context.TestPropertySource
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
|
||||||
import org.springframework.test.web.servlet.get
|
|
||||||
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
|
|
||||||
|
|
||||||
@WebMvcTest(controllers = [StatisticsController::class])
|
|
||||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
|
||||||
@ContextConfiguration(
|
|
||||||
classes = [
|
|
||||||
StatisticsController::class,
|
|
||||||
AppConfiguration::class,
|
|
||||||
AppSecurityConfiguration::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=BUILDIN",
|
|
||||||
"app.security.admin-user=admin",
|
|
||||||
"app.security.admin-password={noop}very-secret"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class StatisticsControllerTest {
|
|
||||||
|
|
||||||
private lateinit var mockMvc: MockMvc
|
|
||||||
private lateinit var webClient: WebClient
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(@Autowired mockMvc: MockMvc) {
|
|
||||||
this.mockMvc = mockMvc
|
|
||||||
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestLoginPage() {
|
|
||||||
mockMvc.get("/statistics").andExpect {
|
|
||||||
status { isOk() }
|
|
||||||
view { name("statistics") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,314 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.web
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.Fingerprint
|
|
||||||
import dev.dnpm.etl.processor.PatientId
|
|
||||||
import dev.dnpm.etl.processor.PatientPseudonym
|
|
||||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
|
||||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
|
||||||
import dev.dnpm.etl.processor.monitoring.CountedState
|
|
||||||
import dev.dnpm.etl.processor.monitoring.Request
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestType
|
|
||||||
import dev.dnpm.etl.processor.randomRequestId
|
|
||||||
import dev.dnpm.etl.processor.services.RequestService
|
|
||||||
import org.hamcrest.Matchers.equalTo
|
|
||||||
import org.hamcrest.Matchers.hasSize
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.mockito.kotlin.doAnswer
|
|
||||||
import org.mockito.kotlin.whenever
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
|
||||||
import org.springframework.http.MediaType.TEXT_EVENT_STREAM
|
|
||||||
import org.springframework.test.context.ContextConfiguration
|
|
||||||
import org.springframework.test.context.TestPropertySource
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
|
||||||
import org.springframework.test.web.reactive.server.WebTestClient
|
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
|
||||||
import org.springframework.test.web.servlet.client.MockMvcWebTestClient
|
|
||||||
import org.springframework.test.web.servlet.get
|
|
||||||
import org.springframework.web.context.WebApplicationContext
|
|
||||||
import reactor.core.publisher.Sinks
|
|
||||||
import reactor.test.StepVerifier
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
|
|
||||||
@WebMvcTest(controllers = [StatisticsRestController::class])
|
|
||||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
|
||||||
@ContextConfiguration(
|
|
||||||
classes = [
|
|
||||||
StatisticsRestController::class,
|
|
||||||
AppConfiguration::class,
|
|
||||||
AppSecurityConfiguration::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@TestPropertySource(
|
|
||||||
properties = [
|
|
||||||
"app.pseudonymize.generator=BUILDIN",
|
|
||||||
"app.security.admin-user=admin",
|
|
||||||
"app.security.admin-password={noop}very-secret"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@MockitoBean(
|
|
||||||
types = [RequestService::class]
|
|
||||||
)
|
|
||||||
class StatisticsRestControllerTest {
|
|
||||||
|
|
||||||
private lateinit var mockMvc: MockMvc
|
|
||||||
|
|
||||||
private lateinit var statisticsUpdateProducer: Sinks.Many<Any>
|
|
||||||
private lateinit var requestService: RequestService
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Autowired mockMvc: MockMvc,
|
|
||||||
@Autowired statisticsUpdateProducer: Sinks.Many<Any>,
|
|
||||||
@Autowired requestService: RequestService
|
|
||||||
) {
|
|
||||||
this.mockMvc = mockMvc
|
|
||||||
this.statisticsUpdateProducer = statisticsUpdateProducer
|
|
||||||
this.requestService = requestService
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class RequestStatesTest {
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestStatesForMtbFiles() {
|
|
||||||
doAnswer { _ ->
|
|
||||||
listOf(
|
|
||||||
CountedState(42, RequestStatus.WARNING),
|
|
||||||
CountedState(1, RequestStatus.UNKNOWN)
|
|
||||||
)
|
|
||||||
}.whenever(requestService).countStates()
|
|
||||||
|
|
||||||
mockMvc.get("/statistics/requeststates").andExpect {
|
|
||||||
status { isOk() }.also {
|
|
||||||
jsonPath("$", hasSize<Int>(2))
|
|
||||||
jsonPath("$[0].name", equalTo(RequestStatus.WARNING.name))
|
|
||||||
jsonPath("$[0].value", equalTo(42))
|
|
||||||
jsonPath("$[1].name", equalTo(RequestStatus.UNKNOWN.name))
|
|
||||||
jsonPath("$[1].value", equalTo(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestStatesForDeletes() {
|
|
||||||
doAnswer { _ ->
|
|
||||||
listOf(
|
|
||||||
CountedState(42, RequestStatus.SUCCESS),
|
|
||||||
CountedState(1, RequestStatus.ERROR)
|
|
||||||
)
|
|
||||||
}.whenever(requestService).countDeleteStates()
|
|
||||||
|
|
||||||
mockMvc.get("/statistics/requeststates?delete=true").andExpect {
|
|
||||||
status { isOk() }.also {
|
|
||||||
jsonPath("$", hasSize<Int>(2))
|
|
||||||
jsonPath("$[0].name", equalTo(RequestStatus.SUCCESS.name))
|
|
||||||
jsonPath("$[0].value", equalTo(42))
|
|
||||||
jsonPath("$[1].name", equalTo(RequestStatus.ERROR.name))
|
|
||||||
jsonPath("$[1].value", equalTo(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class PatientRequestStatesTest {
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestPatientStatesForMtbFiles() {
|
|
||||||
doAnswer { _ ->
|
|
||||||
listOf(
|
|
||||||
CountedState(42, RequestStatus.WARNING),
|
|
||||||
CountedState(1, RequestStatus.UNKNOWN)
|
|
||||||
)
|
|
||||||
}.whenever(requestService).findPatientUniqueStates()
|
|
||||||
|
|
||||||
mockMvc.get("/statistics/requestpatientstates").andExpect {
|
|
||||||
status { isOk() }.also {
|
|
||||||
jsonPath("$", hasSize<Int>(2))
|
|
||||||
jsonPath("$[0].name", equalTo(RequestStatus.WARNING.name))
|
|
||||||
jsonPath("$[0].value", equalTo(42))
|
|
||||||
jsonPath("$[1].name", equalTo(RequestStatus.UNKNOWN.name))
|
|
||||||
jsonPath("$[1].value", equalTo(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestPatientStatesForDeletes() {
|
|
||||||
doAnswer { _ ->
|
|
||||||
listOf(
|
|
||||||
CountedState(42, RequestStatus.SUCCESS),
|
|
||||||
CountedState(1, RequestStatus.ERROR)
|
|
||||||
)
|
|
||||||
}.whenever(requestService).findPatientUniqueDeleteStates()
|
|
||||||
|
|
||||||
mockMvc.get("/statistics/requestpatientstates?delete=true").andExpect {
|
|
||||||
status { isOk() }.also {
|
|
||||||
jsonPath("$", hasSize<Int>(2))
|
|
||||||
jsonPath("$[0].name", equalTo(RequestStatus.SUCCESS.name))
|
|
||||||
jsonPath("$[0].value", equalTo(42))
|
|
||||||
jsonPath("$[1].name", equalTo(RequestStatus.ERROR.name))
|
|
||||||
jsonPath("$[1].value", equalTo(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class LastMonthStatesTest {
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
val zoneId = ZoneId.of("Europe/Berlin")
|
|
||||||
doAnswer { _ ->
|
|
||||||
listOf(
|
|
||||||
Request(
|
|
||||||
1,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("TEST_12345678901"),
|
|
||||||
PatientId("P1"),
|
|
||||||
Fingerprint("0123456789abcdef1"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.SUCCESS,
|
|
||||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS).toInstant()
|
|
||||||
),
|
|
||||||
Request(
|
|
||||||
2,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("TEST_12345678902"),
|
|
||||||
PatientId("P2"),
|
|
||||||
Fingerprint("0123456789abcdef2"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.WARNING,
|
|
||||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS).toInstant()
|
|
||||||
),
|
|
||||||
Request(
|
|
||||||
3,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("TEST_12345678901"),
|
|
||||||
PatientId("P2"),
|
|
||||||
Fingerprint("0123456789abcdee1"),
|
|
||||||
RequestType.DELETE,
|
|
||||||
RequestStatus.ERROR,
|
|
||||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS).toInstant()
|
|
||||||
),
|
|
||||||
Request(
|
|
||||||
4,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("TEST_12345678902"),
|
|
||||||
PatientId("P2"),
|
|
||||||
Fingerprint("0123456789abcdef2"),
|
|
||||||
RequestType.MTB_FILE,
|
|
||||||
RequestStatus.DUPLICATION,
|
|
||||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS).toInstant()
|
|
||||||
),
|
|
||||||
Request(
|
|
||||||
5,
|
|
||||||
randomRequestId(),
|
|
||||||
PatientPseudonym("TEST_12345678902"),
|
|
||||||
PatientId("P2"),
|
|
||||||
Fingerprint("0123456789abcdef2"),
|
|
||||||
RequestType.DELETE,
|
|
||||||
RequestStatus.UNKNOWN,
|
|
||||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).toInstant()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.whenever(requestService).findAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestLastMonthForMtbFiles() {
|
|
||||||
mockMvc.get("/statistics/requestslastmonth").andExpect {
|
|
||||||
status { isOk() }.also {
|
|
||||||
jsonPath("$", hasSize<Int>(31))
|
|
||||||
}.also {
|
|
||||||
jsonPath("$[28].nameValues.error", equalTo(0))
|
|
||||||
jsonPath("$[28].nameValues.warning", equalTo(1))
|
|
||||||
jsonPath("$[28].nameValues.success", equalTo(1))
|
|
||||||
jsonPath("$[28].nameValues.duplication", equalTo(0))
|
|
||||||
jsonPath("$[28].nameValues.unknown", equalTo(0))
|
|
||||||
jsonPath("$[29].nameValues.error", equalTo(0))
|
|
||||||
jsonPath("$[29].nameValues.warning", equalTo(0))
|
|
||||||
jsonPath("$[29].nameValues.success", equalTo(0))
|
|
||||||
jsonPath("$[29].nameValues.duplication", equalTo(1))
|
|
||||||
jsonPath("$[29].nameValues.unknown", equalTo(0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestLastMonthForDeletes() {
|
|
||||||
mockMvc.get("/statistics/requestslastmonth?delete=true").andExpect {
|
|
||||||
status { isOk() }.also {
|
|
||||||
jsonPath("$", hasSize<Int>(31))
|
|
||||||
}.also {
|
|
||||||
jsonPath("$[29].nameValues.error", equalTo(1))
|
|
||||||
jsonPath("$[29].nameValues.warning", equalTo(0))
|
|
||||||
jsonPath("$[29].nameValues.success", equalTo(0))
|
|
||||||
jsonPath("$[29].nameValues.duplication", equalTo(0))
|
|
||||||
jsonPath("$[29].nameValues.unknown", equalTo(0))
|
|
||||||
jsonPath("$[30].nameValues.error", equalTo(0))
|
|
||||||
jsonPath("$[30].nameValues.warning", equalTo(0))
|
|
||||||
jsonPath("$[30].nameValues.success", equalTo(0))
|
|
||||||
jsonPath("$[30].nameValues.duplication", equalTo(0))
|
|
||||||
jsonPath("$[30].nameValues.unknown", equalTo(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class SseTest {
|
|
||||||
private lateinit var webClient: WebTestClient
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
applicationContext: WebApplicationContext,
|
|
||||||
) {
|
|
||||||
this.webClient = MockMvcWebTestClient
|
|
||||||
.bindToApplicationContext(applicationContext).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testShouldRequestSSE() {
|
|
||||||
statisticsUpdateProducer.emitComplete { _, _ -> true }
|
|
||||||
|
|
||||||
val result = webClient.get().uri("http://localhost/statistics/events").accept(TEXT_EVENT_STREAM).exchange()
|
|
||||||
.expectStatus().isOk()
|
|
||||||
.expectHeader().contentType(TEXT_EVENT_STREAM)
|
|
||||||
.returnResult(String::class.java)
|
|
||||||
|
|
||||||
StepVerifier.create(result.responseBody)
|
|
||||||
.expectComplete()
|
|
||||||
.verify()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* This file is part of ETL-Processor
|
* This file is part of ETL-Processor
|
||||||
*
|
*
|
||||||
* Copyright (c) 2024 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
|
||||||
@ -22,16 +22,52 @@ package dev.dnpm.etl.processor.pseudonym;
|
|||||||
import ca.uhn.fhir.context.FhirContext;
|
import ca.uhn.fhir.context.FhirContext;
|
||||||
import ca.uhn.fhir.parser.IParser;
|
import ca.uhn.fhir.parser.IParser;
|
||||||
import dev.dnpm.etl.processor.config.GPasConfigProperties;
|
import dev.dnpm.etl.processor.config.GPasConfigProperties;
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.ConnectException;
|
||||||
|
import java.security.KeyManagementException;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.security.KeyStoreException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.cert.CertificateException;
|
||||||
|
import java.security.cert.CertificateFactory;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.TrustManagerFactory;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
|
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.Registry;
|
||||||
|
import org.apache.hc.core5.http.config.RegistryBuilder;
|
||||||
import org.hl7.fhir.r4.model.Identifier;
|
import org.hl7.fhir.r4.model.Identifier;
|
||||||
import org.hl7.fhir.r4.model.Parameters;
|
import org.hl7.fhir.r4.model.Parameters;
|
||||||
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
|
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
|
||||||
import org.hl7.fhir.r4.model.StringType;
|
import org.hl7.fhir.r4.model.StringType;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.*;
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||||
|
import org.springframework.retry.RetryCallback;
|
||||||
|
import org.springframework.retry.RetryContext;
|
||||||
|
import org.springframework.retry.RetryListener;
|
||||||
|
import org.springframework.retry.RetryPolicy;
|
||||||
|
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
|
||||||
|
import org.springframework.retry.policy.SimpleRetryPolicy;
|
||||||
import org.springframework.retry.support.RetryTemplate;
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
public class GpasPseudonymGenerator implements Generator {
|
public class GpasPseudonymGenerator implements Generator {
|
||||||
@ -40,18 +76,29 @@ public class GpasPseudonymGenerator implements Generator {
|
|||||||
private final String gPasUrl;
|
private final String gPasUrl;
|
||||||
private final String psnTargetDomain;
|
private final String psnTargetDomain;
|
||||||
private final HttpHeaders httpHeader;
|
private final HttpHeaders httpHeader;
|
||||||
private final RetryTemplate retryTemplate;
|
private final RetryTemplate retryTemplate = defaultTemplate();
|
||||||
private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class);
|
private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class);
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private SSLContext customSslContext;
|
||||||
|
private RestTemplate restTemplate;
|
||||||
|
|
||||||
|
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg) {
|
||||||
|
|
||||||
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, RestTemplate restTemplate) {
|
|
||||||
this.retryTemplate = retryTemplate;
|
|
||||||
this.restTemplate = restTemplate;
|
|
||||||
this.gPasUrl = gpasCfg.getUri();
|
this.gPasUrl = gpasCfg.getUri();
|
||||||
this.psnTargetDomain = gpasCfg.getTarget();
|
this.psnTargetDomain = gpasCfg.getTarget();
|
||||||
httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword());
|
httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword());
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (StringUtils.isNotBlank(gpasCfg.getSslCaLocation())) {
|
||||||
|
customSslContext = getSslContext(gpasCfg.getSslCaLocation());
|
||||||
|
log.debug(String.format("%s has been initialized with SSL certificate %s",
|
||||||
|
this.getClass().getName(), gpasCfg.getSslCaLocation()));
|
||||||
|
}
|
||||||
|
} catch (IOException | KeyManagementException | KeyStoreException | CertificateException |
|
||||||
|
NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
log.debug(String.format("%s has been initialized", this.getClass().getName()));
|
log.debug(String.format("%s has been initialized", this.getClass().getName()));
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -61,7 +108,7 @@ public class GpasPseudonymGenerator implements Generator {
|
|||||||
var gPasRequestBody = getGpasRequestBody(id);
|
var gPasRequestBody = getGpasRequestBody(id);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@ -75,26 +122,12 @@ 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 identifier.getSystem() + "|" + identifier.getValue();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allow only filename friendly values
|
|
||||||
*
|
|
||||||
* @param psnValue GAPS pseudonym value
|
|
||||||
* @return cleaned up value
|
|
||||||
*/
|
|
||||||
public static String sanitizeValue(String psnValue) {
|
|
||||||
// pattern to match forbidden characters
|
|
||||||
String forbiddenCharsRegex = "[\\\\/:*?\"<>|;]";
|
|
||||||
|
|
||||||
// Replace all forbidden characters with underscores
|
|
||||||
return psnValue.replaceAll(forbiddenCharsRegex, "_");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -103,11 +136,12 @@ public class GpasPseudonymGenerator implements Generator {
|
|||||||
|
|
||||||
HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader);
|
HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader);
|
||||||
ResponseEntity<String> responseEntity;
|
ResponseEntity<String> responseEntity;
|
||||||
|
var restTemplate = getRestTemplete();
|
||||||
|
|
||||||
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());
|
||||||
@ -119,16 +153,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) {
|
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(psnTargetDomain));
|
.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);
|
||||||
}
|
}
|
||||||
@ -142,7 +176,99 @@ public class GpasPseudonymGenerator implements Generator {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.setBasicAuth(gPasUserName, gPasPassword);
|
String authHeader = gPasUserName + ":" + gPasPassword;
|
||||||
|
byte[] authHeaderBytes = authHeader.getBytes();
|
||||||
|
byte[] encodedAuthHeaderBytes = Base64.getEncoder().encode(authHeaderBytes);
|
||||||
|
String encodedAuthHeader = new String(encodedAuthHeaderBytes);
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(gPasUserName) && StringUtils.isNotBlank(gPasPassword)) {
|
||||||
|
headers.set("Authorization", "Basic " + encodedAuthHeader);
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected RetryTemplate defaultTemplate() {
|
||||||
|
RetryTemplate retryTemplate = new RetryTemplate();
|
||||||
|
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
|
||||||
|
backOffPolicy.setInitialInterval(1000);
|
||||||
|
backOffPolicy.setMultiplier(1.25);
|
||||||
|
retryTemplate.setBackOffPolicy(backOffPolicy);
|
||||||
|
HashMap<Class<? extends Throwable>, Boolean> retryableExceptions = new HashMap<>();
|
||||||
|
retryableExceptions.put(RestClientException.class, true);
|
||||||
|
retryableExceptions.put(ConnectException.class, true);
|
||||||
|
RetryPolicy retryPolicy = new SimpleRetryPolicy(3, retryableExceptions);
|
||||||
|
retryTemplate.setRetryPolicy(retryPolicy);
|
||||||
|
|
||||||
|
retryTemplate.registerListener(new RetryListener() {
|
||||||
|
@Override
|
||||||
|
public <T, E extends Throwable> void onError(RetryContext context,
|
||||||
|
RetryCallback<T, E> callback, Throwable throwable) {
|
||||||
|
log.warn("HTTP Error occurred: {}. Retrying {}", throwable.getMessage(),
|
||||||
|
context.getRetryCount());
|
||||||
|
RetryListener.super.onError(context, callback, throwable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return retryTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read SSL root certificate and return SSLContext
|
||||||
|
*
|
||||||
|
* @param certificateLocation file location to root certificate (PEM)
|
||||||
|
* @return initialized SSLContext
|
||||||
|
* @throws IOException file cannot be read
|
||||||
|
* @throws CertificateException in case we have an invalid certificate of type X.509
|
||||||
|
* @throws KeyStoreException keystore cannot be initialized
|
||||||
|
* @throws NoSuchAlgorithmException missing trust manager algorithmus
|
||||||
|
* @throws KeyManagementException key management failed at init SSLContext
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
protected SSLContext getSslContext(String certificateLocation)
|
||||||
|
throws IOException, CertificateException, KeyStoreException, KeyManagementException, NoSuchAlgorithmException {
|
||||||
|
|
||||||
|
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||||
|
|
||||||
|
FileInputStream fis = new FileInputStream(certificateLocation);
|
||||||
|
X509Certificate ca = (X509Certificate) CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(new BufferedInputStream(fis));
|
||||||
|
|
||||||
|
ks.load(null, null);
|
||||||
|
ks.setCertificateEntry(Integer.toString(1), ca);
|
||||||
|
|
||||||
|
TrustManagerFactory tmf = TrustManagerFactory.getInstance(
|
||||||
|
TrustManagerFactory.getDefaultAlgorithm());
|
||||||
|
tmf.init(ks);
|
||||||
|
|
||||||
|
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||||
|
sslContext.init(null, tmf.getTrustManagers(), null);
|
||||||
|
|
||||||
|
return sslContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected RestTemplate getRestTemplete() {
|
||||||
|
|
||||||
|
if (restTemplate != null) {
|
||||||
|
return restTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customSslContext == null) {
|
||||||
|
restTemplate = new RestTemplate();
|
||||||
|
return restTemplate;
|
||||||
|
}
|
||||||
|
final var sslsf = new SSLConnectionSocketFactory(customSslContext);
|
||||||
|
final Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
|
||||||
|
.register("https", sslsf).register("http", new PlainConnectionSocketFactory()).build();
|
||||||
|
|
||||||
|
final BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager(
|
||||||
|
socketFactoryRegistry);
|
||||||
|
final CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setConnectionManager(connectionManager).build();
|
||||||
|
|
||||||
|
final HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(
|
||||||
|
httpClient);
|
||||||
|
restTemplate = new RestTemplate(requestFactory);
|
||||||
|
return restTemplate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,10 +20,9 @@
|
|||||||
package dev.dnpm.etl.processor
|
package dev.dnpm.etl.processor
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
|
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
|
||||||
@SpringBootApplication(exclude = [SecurityAutoConfiguration::class])
|
@SpringBootApplication
|
||||||
class EtlProcessorApplication
|
class EtlProcessorApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
@ -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
|
||||||
@ -19,15 +19,12 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.config
|
package dev.dnpm.etl.processor.config
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.security.Role
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||||
|
|
||||||
@ConfigurationProperties(AppConfigProperties.NAME)
|
@ConfigurationProperties(AppConfigProperties.NAME)
|
||||||
data class AppConfigProperties(
|
data class AppConfigProperties(
|
||||||
var bwhcUri: String?,
|
var bwhcUri: String?,
|
||||||
var transformations: List<TransformationProperties> = listOf(),
|
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN
|
||||||
var maxRetryAttempts: Int = 3,
|
|
||||||
var duplicationDetection: Boolean = true
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME = "app"
|
const val NAME = "app"
|
||||||
@ -36,7 +33,6 @@ data class AppConfigProperties(
|
|||||||
|
|
||||||
@ConfigurationProperties(PseudonymizeConfigProperties.NAME)
|
@ConfigurationProperties(PseudonymizeConfigProperties.NAME)
|
||||||
data class PseudonymizeConfigProperties(
|
data class PseudonymizeConfigProperties(
|
||||||
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN,
|
|
||||||
val prefix: String = "UNKNOWN",
|
val prefix: String = "UNKNOWN",
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
@ -50,7 +46,9 @@ data class GPasConfigProperties(
|
|||||||
val target: String = "etl-processor",
|
val target: String = "etl-processor",
|
||||||
val username: String?,
|
val username: String?,
|
||||||
val password: String?,
|
val password: String?,
|
||||||
) {
|
val sslCaLocation: String?,
|
||||||
|
|
||||||
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME = "app.pseudonymize.gpas"
|
const val NAME = "app.pseudonymize.gpas"
|
||||||
}
|
}
|
||||||
@ -59,21 +57,17 @@ data class GPasConfigProperties(
|
|||||||
@ConfigurationProperties(RestTargetProperties.NAME)
|
@ConfigurationProperties(RestTargetProperties.NAME)
|
||||||
data class RestTargetProperties(
|
data class RestTargetProperties(
|
||||||
val uri: String?,
|
val uri: String?,
|
||||||
val username: String?,
|
|
||||||
val password: String?,
|
|
||||||
val isBwhc: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME = "app.rest"
|
const val NAME = "app.rest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ConfigurationProperties(KafkaProperties.NAME)
|
@ConfigurationProperties(KafkaTargetProperties.NAME)
|
||||||
data class KafkaProperties(
|
data class KafkaTargetProperties(
|
||||||
val inputTopic: String?,
|
val topic: String = "etl-processor",
|
||||||
val outputTopic: String = "etl-processor",
|
val responseTopic: String = "${topic}_response",
|
||||||
val outputResponseTopic: String = "${outputTopic}_response",
|
val groupId: String = "${topic}_group",
|
||||||
val groupId: String = "${outputTopic}_group",
|
|
||||||
val servers: String = ""
|
val servers: String = ""
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
@ -81,26 +75,7 @@ data class KafkaProperties(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ConfigurationProperties(SecurityConfigProperties.NAME)
|
|
||||||
data class SecurityConfigProperties(
|
|
||||||
val adminUser: String?,
|
|
||||||
val adminPassword: String?,
|
|
||||||
val enableTokens: Boolean = false,
|
|
||||||
val enableOidc: Boolean = false,
|
|
||||||
val defaultNewUserRole: Role = Role.USER
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
const val NAME = "app.security"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class PseudonymGenerator {
|
enum class PseudonymGenerator {
|
||||||
BUILDIN,
|
BUILDIN,
|
||||||
GPAS
|
GPAS
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TransformationProperties(
|
|
||||||
val path: String,
|
|
||||||
val from: String,
|
|
||||||
val to: String
|
|
||||||
)
|
|
@ -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
|
||||||
@ -20,40 +20,16 @@
|
|||||||
package dev.dnpm.etl.processor.config
|
package dev.dnpm.etl.processor.config
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||||
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
|
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
|
||||||
import dev.dnpm.etl.processor.pseudonym.Generator
|
import dev.dnpm.etl.processor.pseudonym.Generator
|
||||||
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
|
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
|
||||||
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
|
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
|
||||||
import dev.dnpm.etl.processor.security.TokenRepository
|
|
||||||
import dev.dnpm.etl.processor.security.TokenService
|
|
||||||
import dev.dnpm.etl.processor.services.Transformation
|
|
||||||
import dev.dnpm.etl.processor.services.TransformationService
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
|
|
||||||
import org.springframework.retry.RetryCallback
|
|
||||||
import org.springframework.retry.RetryContext
|
|
||||||
import org.springframework.retry.RetryListener
|
|
||||||
import org.springframework.retry.policy.SimpleRetryPolicy
|
|
||||||
import org.springframework.retry.support.RetryTemplate
|
|
||||||
import org.springframework.retry.support.RetryTemplateBuilder
|
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
|
||||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
|
||||||
import org.springframework.web.client.HttpClientErrorException
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
import reactor.core.publisher.Sinks
|
import reactor.core.publisher.Sinks
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import kotlin.time.toJavaDuration
|
|
||||||
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(
|
@EnableConfigurationProperties(
|
||||||
@ -63,23 +39,15 @@ import kotlin.time.toJavaDuration
|
|||||||
GPasConfigProperties::class
|
GPasConfigProperties::class
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@EnableScheduling
|
|
||||||
class AppConfiguration {
|
class AppConfiguration {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(AppConfiguration::class.java)
|
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun restTemplate(): RestTemplate {
|
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties): Generator {
|
||||||
return RestTemplate()
|
return GpasPseudonymGenerator(configProperties)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
|
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "BUILDIN", matchIfMissing = true)
|
||||||
@Bean
|
|
||||||
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator {
|
|
||||||
return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "BUILDIN", matchIfMissing = true)
|
|
||||||
@Bean
|
@Bean
|
||||||
fun buildinPseudonymGenerator(): Generator {
|
fun buildinPseudonymGenerator(): Generator {
|
||||||
return AnonymizingGenerator()
|
return AnonymizingGenerator()
|
||||||
@ -98,77 +66,10 @@ class AppConfiguration {
|
|||||||
return ReportService(objectMapper)
|
return ReportService(objectMapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun transformationService(
|
|
||||||
objectMapper: ObjectMapper,
|
|
||||||
configProperties: AppConfigProperties
|
|
||||||
): TransformationService {
|
|
||||||
logger.info("Apply ${configProperties.transformations.size} transformation rules")
|
|
||||||
return TransformationService(objectMapper, configProperties.transformations.map {
|
|
||||||
Transformation.of(it.path) from it.from to it.to
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun retryTemplate(configProperties: AppConfigProperties): RetryTemplate {
|
|
||||||
return RetryTemplateBuilder()
|
|
||||||
.notRetryOn(IllegalArgumentException::class.java)
|
|
||||||
.notRetryOn(HttpClientErrorException.BadRequest::class.java)
|
|
||||||
.notRetryOn(HttpClientErrorException.UnprocessableEntity::class.java)
|
|
||||||
.exponentialBackoff(2.seconds.toJavaDuration(), 1.25, 5.seconds.toJavaDuration())
|
|
||||||
.customPolicy(SimpleRetryPolicy(configProperties.maxRetryAttempts))
|
|
||||||
.withListener(object : RetryListener {
|
|
||||||
override fun <T : Any, E : Throwable> onError(
|
|
||||||
context: RetryContext,
|
|
||||||
callback: RetryCallback<T, E>,
|
|
||||||
throwable: Throwable
|
|
||||||
) {
|
|
||||||
logger.warn("Error occured: {}. Retrying {}", throwable.message, context.retryCount)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ConditionalOnProperty(value = ["app.security.enable-tokens"], havingValue = "true")
|
|
||||||
@Bean
|
|
||||||
fun tokenService(userDetailsManager: InMemoryUserDetailsManager, passwordEncoder: PasswordEncoder, tokenRepository: TokenRepository): TokenService {
|
|
||||||
return TokenService(userDetailsManager, passwordEncoder, tokenRepository)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun statisticsUpdateProducer(): Sinks.Many<Any> {
|
fun statisticsUpdateProducer(): Sinks.Many<Any> {
|
||||||
return Sinks.many().multicast().directBestEffort()
|
return Sinks.many().multicast().directBestEffort()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun connectionCheckUpdateProducer(): Sinks.Many<ConnectionCheckResult> {
|
|
||||||
return Sinks.many().multicast().onBackpressureBuffer()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
|
|
||||||
@Bean
|
|
||||||
fun gPasConnectionCheckService(
|
|
||||||
restTemplate: RestTemplate,
|
|
||||||
gPasConfigProperties: GPasConfigProperties,
|
|
||||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
|
||||||
): ConnectionCheckService {
|
|
||||||
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
|
|
||||||
@ConditionalOnMissingBean
|
|
||||||
@Bean
|
|
||||||
fun gPasConnectionCheckServiceOnDeprecatedProperty(
|
|
||||||
restTemplate: RestTemplate,
|
|
||||||
gPasConfigProperties: GPasConfigProperties,
|
|
||||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
|
||||||
): ConnectionCheckService {
|
|
||||||
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun jdbcConfiguration(): AbstractJdbcConfiguration {
|
|
||||||
return AppJdbcConfiguration()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
package dev.dnpm.etl.processor.config
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.Fingerprint
|
|
||||||
import org.springframework.context.annotation.Configuration
|
|
||||||
import org.springframework.core.convert.converter.Converter
|
|
||||||
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
class AppJdbcConfiguration : AbstractJdbcConfiguration() {
|
|
||||||
override fun userConverters(): MutableList<*> {
|
|
||||||
return mutableListOf(StringToFingerprintConverter(), FingerprintToStringConverter())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class StringToFingerprintConverter : Converter<String, Fingerprint> {
|
|
||||||
override fun convert(source: String): Fingerprint {
|
|
||||||
return Fingerprint(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FingerprintToStringConverter : Converter<Fingerprint, String> {
|
|
||||||
override fun convert(source: Fingerprint): String {
|
|
||||||
return source.value
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
||||||
@ -20,13 +20,8 @@
|
|||||||
package dev.dnpm.etl.processor.config
|
package dev.dnpm.etl.processor.config
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import dev.dnpm.etl.processor.input.KafkaInputListener
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.KafkaConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.output.KafkaMtbFileSender
|
import dev.dnpm.etl.processor.output.KafkaMtbFileSender
|
||||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
|
||||||
import dev.dnpm.etl.processor.services.kafka.KafkaResponseProcessor
|
import dev.dnpm.etl.processor.services.kafka.KafkaResponseProcessor
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||||
@ -40,14 +35,12 @@ import org.springframework.kafka.core.ConsumerFactory
|
|||||||
import org.springframework.kafka.core.KafkaTemplate
|
import org.springframework.kafka.core.KafkaTemplate
|
||||||
import org.springframework.kafka.listener.ContainerProperties
|
import org.springframework.kafka.listener.ContainerProperties
|
||||||
import org.springframework.kafka.listener.KafkaMessageListenerContainer
|
import org.springframework.kafka.listener.KafkaMessageListenerContainer
|
||||||
import org.springframework.retry.support.RetryTemplate
|
|
||||||
import reactor.core.publisher.Sinks
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(
|
@EnableConfigurationProperties(
|
||||||
value = [KafkaProperties::class]
|
value = [KafkaTargetProperties::class]
|
||||||
)
|
)
|
||||||
@ConditionalOnProperty(value = ["app.kafka.servers"])
|
@ConditionalOnProperty(value = ["app.kafka.topic", "app.kafka.servers"])
|
||||||
@ConditionalOnMissingBean(MtbFileSender::class)
|
@ConditionalOnMissingBean(MtbFileSender::class)
|
||||||
@Order(-5)
|
@Order(-5)
|
||||||
class AppKafkaConfiguration {
|
class AppKafkaConfiguration {
|
||||||
@ -57,21 +50,20 @@ class AppKafkaConfiguration {
|
|||||||
@Bean
|
@Bean
|
||||||
fun kafkaMtbFileSender(
|
fun kafkaMtbFileSender(
|
||||||
kafkaTemplate: KafkaTemplate<String, String>,
|
kafkaTemplate: KafkaTemplate<String, String>,
|
||||||
kafkaProperties: KafkaProperties,
|
kafkaTargetProperties: KafkaTargetProperties,
|
||||||
retryTemplate: RetryTemplate,
|
|
||||||
objectMapper: ObjectMapper
|
objectMapper: ObjectMapper
|
||||||
): MtbFileSender {
|
): MtbFileSender {
|
||||||
logger.info("Selected 'KafkaMtbFileSender'")
|
logger.info("Selected 'KafkaMtbFileSender'")
|
||||||
return KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper)
|
return KafkaMtbFileSender(kafkaTemplate, kafkaTargetProperties, objectMapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun kafkaResponseListenerContainer(
|
fun kafkaListenerContainer(
|
||||||
consumerFactory: ConsumerFactory<String, String>,
|
consumerFactory: ConsumerFactory<String, String>,
|
||||||
kafkaProperties: KafkaProperties,
|
kafkaTargetProperties: KafkaTargetProperties,
|
||||||
kafkaResponseProcessor: KafkaResponseProcessor
|
kafkaResponseProcessor: KafkaResponseProcessor
|
||||||
): KafkaMessageListenerContainer<String, String> {
|
): KafkaMessageListenerContainer<String, String> {
|
||||||
val containerProperties = ContainerProperties(kafkaProperties.outputResponseTopic)
|
val containerProperties = ContainerProperties(kafkaTargetProperties.responseTopic)
|
||||||
containerProperties.messageListener = kafkaResponseProcessor
|
containerProperties.messageListener = kafkaResponseProcessor
|
||||||
return KafkaMessageListenerContainer(consumerFactory, containerProperties)
|
return KafkaMessageListenerContainer(consumerFactory, containerProperties)
|
||||||
}
|
}
|
||||||
@ -84,33 +76,4 @@ class AppKafkaConfiguration {
|
|||||||
return KafkaResponseProcessor(applicationEventPublisher, objectMapper)
|
return KafkaResponseProcessor(applicationEventPublisher, objectMapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnProperty(value = ["app.kafka.input-topic"])
|
|
||||||
fun kafkaInputListenerContainer(
|
|
||||||
consumerFactory: ConsumerFactory<String, String>,
|
|
||||||
kafkaProperties: KafkaProperties,
|
|
||||||
kafkaInputListener: KafkaInputListener
|
|
||||||
): KafkaMessageListenerContainer<String, String> {
|
|
||||||
val containerProperties = ContainerProperties(kafkaProperties.inputTopic)
|
|
||||||
containerProperties.messageListener = kafkaInputListener
|
|
||||||
return KafkaMessageListenerContainer(consumerFactory, containerProperties)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnProperty(value = ["app.kafka.input-topic"])
|
|
||||||
fun kafkaInputListener(
|
|
||||||
requestProcessor: RequestProcessor,
|
|
||||||
objectMapper: ObjectMapper
|
|
||||||
): KafkaInputListener {
|
|
||||||
return KafkaInputListener(requestProcessor, objectMapper)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun kafkaConnectionCheckService(
|
|
||||||
consumerFactory: ConsumerFactory<String, String>,
|
|
||||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
|
||||||
): ConnectionCheckService {
|
|
||||||
return KafkaConnectionCheckService(consumerFactory.createConsumer(), connectionCheckUpdateProducer)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
@ -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
|
||||||
@ -19,13 +19,8 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.config
|
package dev.dnpm.etl.processor.config
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||||
import dev.dnpm.etl.processor.output.RestBwhcMtbFileSender
|
import dev.dnpm.etl.processor.output.RestMtbFileSender
|
||||||
import dev.dnpm.etl.processor.output.RestDipMtbFileSender
|
|
||||||
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
|
||||||
@ -33,9 +28,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.core.annotation.Order
|
import org.springframework.core.annotation.Order
|
||||||
import org.springframework.retry.support.RetryTemplate
|
|
||||||
import org.springframework.web.client.RestTemplate
|
import org.springframework.web.client.RestTemplate
|
||||||
import reactor.core.publisher.Sinks
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(
|
@EnableConfigurationProperties(
|
||||||
@ -51,28 +44,14 @@ class AppRestConfiguration {
|
|||||||
private val logger = LoggerFactory.getLogger(AppRestConfiguration::class.java)
|
private val logger = LoggerFactory.getLogger(AppRestConfiguration::class.java)
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun restMtbFileSender(
|
fun restTemplate(): RestTemplate {
|
||||||
restTemplate: RestTemplate,
|
return RestTemplate()
|
||||||
restTargetProperties: RestTargetProperties,
|
|
||||||
retryTemplate: RetryTemplate,
|
|
||||||
reportService: ReportService,
|
|
||||||
): MtbFileSender {
|
|
||||||
if (restTargetProperties.isBwhc) {
|
|
||||||
logger.info("Selected 'RestBwhcMtbFileSender'")
|
|
||||||
return RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Selected 'RestDipMtbFileSender'")
|
|
||||||
return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun restConnectionCheckService(
|
fun restMtbFileSender(restTemplate: RestTemplate, restTargetProperties: RestTargetProperties): MtbFileSender {
|
||||||
restTemplate: RestTemplate,
|
logger.info("Selected 'RestMtbFileSender'")
|
||||||
restTargetProperties: RestTargetProperties,
|
return RestMtbFileSender(restTemplate, restTargetProperties)
|
||||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
|
||||||
): ConnectionCheckService {
|
|
||||||
return RestConnectionCheckService(restTemplate, restTargetProperties, connectionCheckUpdateProducer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,196 +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.config
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.security.UserRole
|
|
||||||
import dev.dnpm.etl.processor.security.UserRoleRepository
|
|
||||||
import dev.dnpm.etl.processor.security.UserRoleService
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|
||||||
import org.springframework.context.annotation.Bean
|
|
||||||
import org.springframework.context.annotation.Configuration
|
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
|
||||||
import org.springframework.security.config.annotation.web.invoke
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
|
||||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
|
|
||||||
import org.springframework.security.core.session.SessionRegistry
|
|
||||||
import org.springframework.security.core.session.SessionRegistryImpl
|
|
||||||
import org.springframework.security.core.userdetails.User
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
|
||||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
|
||||||
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority
|
|
||||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
|
|
||||||
private const val LOGIN_PATH = "/login"
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableConfigurationProperties(
|
|
||||||
value = [
|
|
||||||
SecurityConfigProperties::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@ConditionalOnProperty(value = ["app.security.admin-user"])
|
|
||||||
@EnableWebSecurity
|
|
||||||
class AppSecurityConfiguration(
|
|
||||||
private val securityConfigProperties: SecurityConfigProperties
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(AppSecurityConfiguration::class.java)
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun userDetailsService(passwordEncoder: PasswordEncoder): InMemoryUserDetailsManager {
|
|
||||||
val adminUser = if (securityConfigProperties.adminUser.isNullOrBlank()) {
|
|
||||||
logger.warn("Using random Admin User: admin")
|
|
||||||
"admin"
|
|
||||||
} else {
|
|
||||||
securityConfigProperties.adminUser
|
|
||||||
}
|
|
||||||
|
|
||||||
val adminPassword = if (securityConfigProperties.adminPassword.isNullOrBlank()) {
|
|
||||||
val random = UUID.randomUUID().toString()
|
|
||||||
logger.warn("Using random Admin Passwort: {}", random)
|
|
||||||
passwordEncoder.encode(random)
|
|
||||||
} else {
|
|
||||||
securityConfigProperties.adminPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
val user: UserDetails = User.withUsername(adminUser)
|
|
||||||
.password(adminPassword)
|
|
||||||
.roles("ADMIN")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return InMemoryUserDetailsManager(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
|
|
||||||
fun filterChainOidc(
|
|
||||||
http: HttpSecurity,
|
|
||||||
passwordEncoder: PasswordEncoder,
|
|
||||||
userRoleRepository: UserRoleRepository,
|
|
||||||
sessionRegistry: SessionRegistry
|
|
||||||
): SecurityFilterChain {
|
|
||||||
http {
|
|
||||||
authorizeHttpRequests {
|
|
||||||
authorize("/configs/**", hasRole("ADMIN"))
|
|
||||||
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER"))
|
|
||||||
authorize("/report/**", hasAnyRole("ADMIN", "USER"))
|
|
||||||
authorize("*.css", permitAll)
|
|
||||||
authorize("*.ico", permitAll)
|
|
||||||
authorize("*.jpeg", permitAll)
|
|
||||||
authorize("*.js", permitAll)
|
|
||||||
authorize("*.svg", permitAll)
|
|
||||||
authorize("*.css", permitAll)
|
|
||||||
authorize("/login/**", permitAll)
|
|
||||||
authorize(anyRequest, permitAll)
|
|
||||||
}
|
|
||||||
httpBasic {
|
|
||||||
realmName = "ETL-Processor"
|
|
||||||
}
|
|
||||||
formLogin {
|
|
||||||
loginPage = LOGIN_PATH
|
|
||||||
}
|
|
||||||
oauth2Login {
|
|
||||||
loginPage = LOGIN_PATH
|
|
||||||
}
|
|
||||||
sessionManagement {
|
|
||||||
sessionConcurrency {
|
|
||||||
maximumSessions = 1
|
|
||||||
expiredUrl = "$LOGIN_PATH?expired"
|
|
||||||
}
|
|
||||||
sessionFixation {
|
|
||||||
newSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
csrf { disable() }
|
|
||||||
}
|
|
||||||
return http.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
|
|
||||||
fun grantedAuthoritiesMapper(
|
|
||||||
userRoleRepository: UserRoleRepository,
|
|
||||||
appSecurityConfigProperties: SecurityConfigProperties
|
|
||||||
): GrantedAuthoritiesMapper {
|
|
||||||
return GrantedAuthoritiesMapper { grantedAuthority ->
|
|
||||||
grantedAuthority.filterIsInstance<OidcUserAuthority>()
|
|
||||||
.onEach {
|
|
||||||
val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
|
|
||||||
if (userRole.isEmpty) {
|
|
||||||
userRoleRepository.save(
|
|
||||||
UserRole(
|
|
||||||
null,
|
|
||||||
it.userInfo.preferredUsername,
|
|
||||||
appSecurityConfigProperties.defaultNewUserRole
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.map {
|
|
||||||
val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
|
|
||||||
SimpleGrantedAuthority("ROLE_${userRole.get().role.toString().uppercase()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true)
|
|
||||||
fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain {
|
|
||||||
http {
|
|
||||||
authorizeHttpRequests {
|
|
||||||
authorize("/configs/**", hasRole("ADMIN"))
|
|
||||||
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN"))
|
|
||||||
authorize("/report/**", hasRole("ADMIN"))
|
|
||||||
authorize(anyRequest, permitAll)
|
|
||||||
}
|
|
||||||
httpBasic {
|
|
||||||
realmName = "ETL-Processor"
|
|
||||||
}
|
|
||||||
formLogin {
|
|
||||||
loginPage = LOGIN_PATH
|
|
||||||
}
|
|
||||||
csrf { disable() }
|
|
||||||
}
|
|
||||||
return http.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun sessionRegistry(): SessionRegistry {
|
|
||||||
return SessionRegistryImpl()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun passwordEncoder(): PasswordEncoder {
|
|
||||||
return PasswordEncoderFactories.createDelegatingPasswordEncoder()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
|
|
||||||
fun userRoleService(userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): UserRoleService {
|
|
||||||
return UserRoleService(userRoleRepository, sessionRegistry)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,91 +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.input
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import de.ukw.ccc.bwhc.dto.Consent
|
|
||||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
|
||||||
import dev.dnpm.etl.processor.CustomMediaType
|
|
||||||
import dev.dnpm.etl.processor.PatientId
|
|
||||||
import dev.dnpm.etl.processor.RequestId
|
|
||||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
|
||||||
import org.apache.kafka.clients.consumer.ConsumerRecord
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.kafka.listener.MessageListener
|
|
||||||
|
|
||||||
class KafkaInputListener(
|
|
||||||
private val requestProcessor: RequestProcessor,
|
|
||||||
private val objectMapper: ObjectMapper
|
|
||||||
) : MessageListener<String, String> {
|
|
||||||
private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java)
|
|
||||||
|
|
||||||
override fun onMessage(record: ConsumerRecord<String, String>) {
|
|
||||||
when (guessMimeType(record)) {
|
|
||||||
MediaType.APPLICATION_JSON_VALUE -> handleBwhcMessage(record)
|
|
||||||
CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE -> handleDnpmV2Message(record)
|
|
||||||
else -> {
|
|
||||||
/* ignore other messages */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun guessMimeType(record: ConsumerRecord<String, 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 firstRequestIdHeader = record.headers().headers("requestId")?.firstOrNull()
|
|
||||||
val requestId = if (null != firstRequestIdHeader) {
|
|
||||||
RequestId(String(firstRequestIdHeader.value()))
|
|
||||||
} else {
|
|
||||||
RequestId("")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mtbFile.consent.status == Consent.Status.ACTIVE) {
|
|
||||||
logger.debug("Accepted MTB File for processing")
|
|
||||||
if (requestId.isBlank()) {
|
|
||||||
requestProcessor.processMtbFile(mtbFile)
|
|
||||||
} else {
|
|
||||||
requestProcessor.processMtbFile(mtbFile, requestId)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.debug("Accepted MTB File and process deletion")
|
|
||||||
if (requestId.isBlank()) {
|
|
||||||
requestProcessor.processDeletion(patientId)
|
|
||||||
} else {
|
|
||||||
requestProcessor.processDeletion(patientId, requestId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,210 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.monitoring
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.config.GPasConfigProperties
|
|
||||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
|
||||||
import jakarta.annotation.PostConstruct
|
|
||||||
import org.apache.kafka.clients.consumer.Consumer
|
|
||||||
import org.apache.kafka.common.errors.TimeoutException
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier
|
|
||||||
import org.springframework.http.*
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder
|
|
||||||
import reactor.core.publisher.Sinks
|
|
||||||
import java.time.Instant
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import kotlin.time.toJavaDuration
|
|
||||||
|
|
||||||
fun interface ConnectionCheckService {
|
|
||||||
|
|
||||||
fun connectionAvailable(): ConnectionCheckResult
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OutputConnectionCheckService : ConnectionCheckService
|
|
||||||
|
|
||||||
sealed class ConnectionCheckResult {
|
|
||||||
|
|
||||||
abstract val available: Boolean
|
|
||||||
|
|
||||||
abstract val timestamp: Instant
|
|
||||||
|
|
||||||
abstract val lastChange: Instant
|
|
||||||
|
|
||||||
data class KafkaConnectionCheckResult(
|
|
||||||
override val available: Boolean,
|
|
||||||
override val timestamp: Instant,
|
|
||||||
override val lastChange: Instant
|
|
||||||
) : ConnectionCheckResult()
|
|
||||||
|
|
||||||
data class RestConnectionCheckResult(
|
|
||||||
override val available: Boolean,
|
|
||||||
override val timestamp: Instant,
|
|
||||||
override val lastChange: Instant
|
|
||||||
) : ConnectionCheckResult()
|
|
||||||
|
|
||||||
data class GPasConnectionCheckResult(
|
|
||||||
override val available: Boolean,
|
|
||||||
override val timestamp: Instant,
|
|
||||||
override val lastChange: Instant
|
|
||||||
) : ConnectionCheckResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
class KafkaConnectionCheckService(
|
|
||||||
private val consumer: Consumer<String, String>,
|
|
||||||
@Qualifier("connectionCheckUpdateProducer")
|
|
||||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
|
||||||
) : OutputConnectionCheckService {
|
|
||||||
|
|
||||||
private var result = ConnectionCheckResult.KafkaConnectionCheckResult(false, Instant.now(), Instant.now())
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
@Scheduled(cron = "0 * * * * *")
|
|
||||||
fun check() {
|
|
||||||
result = try {
|
|
||||||
val available = null != consumer.listTopics(5.seconds.toJavaDuration())
|
|
||||||
ConnectionCheckResult.KafkaConnectionCheckResult(
|
|
||||||
available,
|
|
||||||
Instant.now(),
|
|
||||||
if (result.available == available) { result.lastChange } else { Instant.now() }
|
|
||||||
)
|
|
||||||
} catch (_: TimeoutException) {
|
|
||||||
ConnectionCheckResult.KafkaConnectionCheckResult(
|
|
||||||
false,
|
|
||||||
Instant.now(),
|
|
||||||
if (!result.available) { result.lastChange } else { Instant.now() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
connectionCheckUpdateProducer.emitNext(
|
|
||||||
result,
|
|
||||||
Sinks.EmitFailureHandler.FAIL_FAST
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun connectionAvailable(): ConnectionCheckResult.KafkaConnectionCheckResult {
|
|
||||||
return this.result
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class RestConnectionCheckService(
|
|
||||||
private val restTemplate: RestTemplate,
|
|
||||||
private val restTargetProperties: RestTargetProperties,
|
|
||||||
@Qualifier("connectionCheckUpdateProducer")
|
|
||||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
|
||||||
) : OutputConnectionCheckService {
|
|
||||||
|
|
||||||
private var result = ConnectionCheckResult.RestConnectionCheckResult(false, Instant.now(), Instant.now())
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
@Scheduled(cron = "0 * * * * *")
|
|
||||||
fun check() {
|
|
||||||
result = try {
|
|
||||||
val available = restTemplate.getForEntity(
|
|
||||||
if (restTargetProperties.isBwhc) {
|
|
||||||
UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString()).path("").toUriString()
|
|
||||||
} else {
|
|
||||||
UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString())
|
|
||||||
.pathSegment("mtb")
|
|
||||||
.pathSegment("kaplan-meier")
|
|
||||||
.pathSegment("config")
|
|
||||||
.toUriString()
|
|
||||||
},
|
|
||||||
String::class.java
|
|
||||||
).statusCode == HttpStatus.OK
|
|
||||||
|
|
||||||
ConnectionCheckResult.RestConnectionCheckResult(
|
|
||||||
available,
|
|
||||||
Instant.now(),
|
|
||||||
if (result.available == available) { result.lastChange } else { Instant.now() }
|
|
||||||
)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
ConnectionCheckResult.RestConnectionCheckResult(
|
|
||||||
false,
|
|
||||||
Instant.now(),
|
|
||||||
if (!result.available) { result.lastChange } else { Instant.now() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
connectionCheckUpdateProducer.emitNext(
|
|
||||||
result,
|
|
||||||
Sinks.EmitFailureHandler.FAIL_FAST
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun connectionAvailable(): ConnectionCheckResult.RestConnectionCheckResult {
|
|
||||||
return this.result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class GPasConnectionCheckService(
|
|
||||||
private val restTemplate: RestTemplate,
|
|
||||||
private val gPasConfigProperties: GPasConfigProperties,
|
|
||||||
@Qualifier("connectionCheckUpdateProducer")
|
|
||||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
|
||||||
) : ConnectionCheckService {
|
|
||||||
|
|
||||||
private var result = ConnectionCheckResult.GPasConnectionCheckResult(false, Instant.now(), Instant.now())
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
@Scheduled(cron = "0 * * * * *")
|
|
||||||
fun check() {
|
|
||||||
result = try {
|
|
||||||
val uri = UriComponentsBuilder.fromUriString(
|
|
||||||
gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/metadata").toString()
|
|
||||||
).build().toUri()
|
|
||||||
|
|
||||||
val headers = HttpHeaders()
|
|
||||||
headers.contentType = MediaType.APPLICATION_JSON
|
|
||||||
if (!gPasConfigProperties.username.isNullOrBlank() && !gPasConfigProperties.password.isNullOrBlank()) {
|
|
||||||
headers.setBasicAuth(gPasConfigProperties.username, gPasConfigProperties.password)
|
|
||||||
}
|
|
||||||
|
|
||||||
val available = restTemplate.exchange(
|
|
||||||
uri,
|
|
||||||
HttpMethod.GET,
|
|
||||||
HttpEntity<Void>(headers),
|
|
||||||
Void::class.java
|
|
||||||
).statusCode == HttpStatus.OK
|
|
||||||
|
|
||||||
ConnectionCheckResult.GPasConnectionCheckResult(
|
|
||||||
available,
|
|
||||||
Instant.now(),
|
|
||||||
if (result.available == available) { result.lastChange } else { Instant.now() }
|
|
||||||
)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
ConnectionCheckResult.GPasConnectionCheckResult(
|
|
||||||
false,
|
|
||||||
Instant.now(),
|
|
||||||
if (!result.available) { result.lastChange } else { Instant.now() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
connectionCheckUpdateProducer.emitNext(
|
|
||||||
result,
|
|
||||||
Sinks.EmitFailureHandler.FAIL_FAST
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun connectionAvailable(): ConnectionCheckResult.GPasConnectionCheckResult {
|
|
||||||
return this.result
|
|
||||||
}
|
|
||||||
}
|
|
@ -19,15 +19,11 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.monitoring
|
package dev.dnpm.etl.processor.monitoring
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonAlias
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.annotation.JsonValue
|
import com.fasterxml.jackson.annotation.JsonValue
|
||||||
import com.fasterxml.jackson.core.JsonParseException
|
import com.fasterxml.jackson.core.JsonParseException
|
||||||
import com.fasterxml.jackson.databind.JsonMappingException
|
import com.fasterxml.jackson.databind.JsonMappingException
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import dev.dnpm.etl.processor.monitoring.ReportService.Issue
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ReportService.Severity
|
|
||||||
import java.util.Optional
|
|
||||||
|
|
||||||
class ReportService(
|
class ReportService(
|
||||||
private val objectMapper: ObjectMapper
|
private val objectMapper: ObjectMapper
|
||||||
@ -38,10 +34,7 @@ class ReportService(
|
|||||||
return listOf()
|
return listOf()
|
||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
objectMapper
|
objectMapper.readValue(dataQualityReport, DataQualityReport::class.java).issues
|
||||||
.readValue(dataQualityReport, DataQualityReport::class.java)
|
|
||||||
.issues
|
|
||||||
.sortedBy { it.severity }
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val otherIssue =
|
val otherIssue =
|
||||||
Issue(Severity.ERROR, "Not parsable data quality report '$dataQualityReport'")
|
Issue(Severity.ERROR, "Not parsable data quality report '$dataQualityReport'")
|
||||||
@ -58,25 +51,10 @@ class ReportService(
|
|||||||
private data class DataQualityReport(val issues: List<Issue>)
|
private data class DataQualityReport(val issues: List<Issue>)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class Issue(
|
data class Issue(val severity: Severity, val message: String)
|
||||||
val severity: Severity,
|
|
||||||
@JsonAlias("details") val message: String,
|
|
||||||
val path: Optional<String> = Optional.empty()
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class Severity(@JsonValue val value: String) {
|
enum class Severity(@JsonValue val value: String) {
|
||||||
FATAL("fatal"),
|
|
||||||
ERROR("error"),
|
ERROR("error"),
|
||||||
WARNING("warning"),
|
WARNING("warning"),
|
||||||
INFO("info")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun List<Issue>.asRequestStatus(): RequestStatus {
|
|
||||||
val severity = this.minOfOrNull { it.severity }
|
|
||||||
return when (severity) {
|
|
||||||
Severity.FATAL, Severity.ERROR -> RequestStatus.ERROR
|
|
||||||
Severity.WARNING -> RequestStatus.WARNING
|
|
||||||
else -> RequestStatus.SUCCESS
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -19,79 +19,50 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.monitoring
|
package dev.dnpm.etl.processor.monitoring
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.*
|
|
||||||
import org.springframework.data.annotation.Id
|
import org.springframework.data.annotation.Id
|
||||||
import org.springframework.data.domain.Page
|
|
||||||
import org.springframework.data.domain.Pageable
|
|
||||||
import org.springframework.data.jdbc.repository.query.Query
|
import org.springframework.data.jdbc.repository.query.Query
|
||||||
import org.springframework.data.relational.core.mapping.Column
|
|
||||||
import org.springframework.data.relational.core.mapping.Embedded
|
import org.springframework.data.relational.core.mapping.Embedded
|
||||||
import org.springframework.data.relational.core.mapping.Table
|
import org.springframework.data.relational.core.mapping.Table
|
||||||
import org.springframework.data.repository.CrudRepository
|
import org.springframework.data.repository.CrudRepository
|
||||||
import org.springframework.data.repository.PagingAndSortingRepository
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
typealias RequestId = UUID
|
||||||
|
|
||||||
@Table("request")
|
@Table("request")
|
||||||
data class Request(
|
data class Request(
|
||||||
@Id val id: Long? = null,
|
@Id val id: Long? = null,
|
||||||
val uuid: RequestId = randomRequestId(),
|
val uuid: String = RequestId.randomUUID().toString(),
|
||||||
val patientPseudonym: PatientPseudonym,
|
val patientId: String,
|
||||||
val pid: PatientId,
|
val pid: String,
|
||||||
@Column("fingerprint")
|
val fingerprint: String,
|
||||||
val fingerprint: Fingerprint,
|
|
||||||
val type: RequestType,
|
val type: RequestType,
|
||||||
var status: RequestStatus,
|
var status: RequestStatus,
|
||||||
var processedAt: Instant = Instant.now(),
|
var processedAt: Instant = Instant.now(),
|
||||||
@Embedded.Nullable var report: Report? = null
|
@Embedded.Nullable var report: Report? = null
|
||||||
) {
|
)
|
||||||
constructor(
|
|
||||||
uuid: RequestId,
|
|
||||||
patientPseudonym: PatientPseudonym,
|
|
||||||
pid: PatientId,
|
|
||||||
fingerprint: Fingerprint,
|
|
||||||
type: RequestType,
|
|
||||||
status: RequestStatus
|
|
||||||
) :
|
|
||||||
this(null, uuid, patientPseudonym, pid, fingerprint, type, status, Instant.now())
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
uuid: RequestId,
|
|
||||||
patientPseudonym: PatientPseudonym,
|
|
||||||
pid: PatientId,
|
|
||||||
fingerprint: Fingerprint,
|
|
||||||
type: RequestType,
|
|
||||||
status: RequestStatus,
|
|
||||||
processedAt: Instant
|
|
||||||
) :
|
|
||||||
this(null, uuid, patientPseudonym, pid, fingerprint, type, status, processedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmRecord
|
|
||||||
data class Report(
|
data class Report(
|
||||||
val description: String,
|
val description: String,
|
||||||
val dataQualityReport: String = ""
|
val dataQualityReport: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
@JvmRecord
|
|
||||||
data class CountedState(
|
data class CountedState(
|
||||||
val count: Int,
|
val count: Int,
|
||||||
val status: RequestStatus,
|
val status: RequestStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRepository<Request, Long> {
|
interface RequestRepository : CrudRepository<Request, Long> {
|
||||||
|
|
||||||
fun findAllByPatientPseudonymOrderByProcessedAtDesc(patientId: PatientPseudonym): List<Request>
|
fun findAllByPatientIdOrderByProcessedAtDesc(patientId: String): List<Request>
|
||||||
|
|
||||||
fun findByUuidEquals(uuid: RequestId): Optional<Request>
|
fun findByUuidEquals(uuid: String): Optional<Request>
|
||||||
|
|
||||||
fun findRequestByPatientPseudonym(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request>
|
|
||||||
|
|
||||||
@Query("SELECT count(*) AS count, status FROM request WHERE type = 'MTB_FILE' GROUP BY status ORDER BY status, count DESC;")
|
@Query("SELECT count(*) AS count, status FROM request WHERE type = 'MTB_FILE' GROUP BY status ORDER BY status, count DESC;")
|
||||||
fun countStates(): List<CountedState>
|
fun countStates(): List<CountedState>
|
||||||
|
|
||||||
@Query("SELECT count(*) AS count, status FROM (" +
|
@Query("SELECT count(*) AS count, status FROM (" +
|
||||||
"SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " +
|
"SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
|
||||||
"WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " +
|
"WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " +
|
||||||
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
|
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
|
||||||
fun findPatientUniqueStates(): List<CountedState>
|
fun findPatientUniqueStates(): List<CountedState>
|
||||||
@ -100,7 +71,7 @@ interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRep
|
|||||||
fun countDeleteStates(): List<CountedState>
|
fun countDeleteStates(): List<CountedState>
|
||||||
|
|
||||||
@Query("SELECT count(*) AS count, status FROM (" +
|
@Query("SELECT count(*) AS count, status FROM (" +
|
||||||
"SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " +
|
"SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
|
||||||
"WHERE type = 'DELETE'" +
|
"WHERE type = 'DELETE'" +
|
||||||
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
|
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
|
||||||
fun findPatientUniqueDeleteStates(): List<CountedState>
|
fun findPatientUniqueDeleteStates(): List<CountedState>
|
||||||
|
@ -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
|
||||||
@ -22,44 +22,31 @@ 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.config.KafkaTargetProperties
|
||||||
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
|
|
||||||
|
|
||||||
class KafkaMtbFileSender(
|
class KafkaMtbFileSender(
|
||||||
private val kafkaTemplate: KafkaTemplate<String, String>,
|
private val kafkaTemplate: KafkaTemplate<String, String>,
|
||||||
private val kafkaProperties: KafkaProperties,
|
private val kafkaTargetProperties: KafkaTargetProperties,
|
||||||
private val retryTemplate: RetryTemplate,
|
|
||||||
private val objectMapper: ObjectMapper
|
private val objectMapper: ObjectMapper
|
||||||
) : MtbFileSender {
|
) : MtbFileSender {
|
||||||
|
|
||||||
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> {
|
val result = kafkaTemplate.send(
|
||||||
val record =
|
kafkaTargetProperties.topic,
|
||||||
ProducerRecord(kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(request))
|
key(request),
|
||||||
when (request) {
|
objectMapper.writeValueAsString(Data(request.requestId, request.mtbFile))
|
||||||
is BwhcV1MtbFileRequest -> record.headers()
|
)
|
||||||
.add("contentType", MediaType.APPLICATION_JSON_VALUE.toByteArray())
|
if (result.get() != null) {
|
||||||
|
logger.debug("Sent file via KafkaMtbFileSender")
|
||||||
is DnpmV2MtbFileRequest -> record.headers()
|
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
||||||
.add("contentType", CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray())
|
} else {
|
||||||
}
|
MtbFileSender.Response(RequestStatus.ERROR)
|
||||||
|
|
||||||
val result = kafkaTemplate.send(record)
|
|
||||||
if (result.get() != null) {
|
|
||||||
logger.debug("Sent file via KafkaMtbFileSender")
|
|
||||||
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
|
||||||
} else {
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("An error occurred sending to kafka", e)
|
logger.error("An error occurred sending to kafka", e)
|
||||||
@ -67,33 +54,28 @@ 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()
|
||||||
.withPatient(request.patientId.value)
|
.withPatient(request.patientId)
|
||||||
.withStatus(Consent.Status.REJECTED)
|
.withStatus(Consent.Status.REJECTED)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
val result = kafkaTemplate.send(
|
||||||
val record =
|
kafkaTargetProperties.topic,
|
||||||
ProducerRecord(
|
key(request),
|
||||||
kafkaProperties.outputTopic,
|
objectMapper.writeValueAsString(Data(request.requestId, dummyMtbFile))
|
||||||
key(request),
|
)
|
||||||
// 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)
|
} else {
|
||||||
} else {
|
MtbFileSender.Response(RequestStatus.ERROR)
|
||||||
MtbFileSender.Response(RequestStatus.ERROR)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("An error occurred sending to kafka", e)
|
logger.error("An error occurred sending to kafka", e)
|
||||||
@ -101,16 +83,14 @@ class KafkaMtbFileSender(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun endpoint(): String {
|
private fun key(request: MtbFileSender.MtbFileRequest): String {
|
||||||
return "${this.kafkaProperties.servers} (${this.kafkaProperties.outputTopic}/${this.kafkaProperties.outputResponseTopic})"
|
return "{\"pid\": \"${request.mtbFile.patient.id}\", " +
|
||||||
|
"\"eid\": \"${request.mtbFile.episode.id}\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun key(request: MtbRequest): String {
|
private fun key(request: MtbFileSender.DeleteRequest): String {
|
||||||
return when (request) {
|
return "{\"pid\": \"${request.patientId}\"}"
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class Data(val requestId: String, val content: MtbFile)
|
||||||
}
|
}
|
@ -19,17 +19,21 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.output
|
package dev.dnpm.etl.processor.output
|
||||||
|
|
||||||
|
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||||
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
|
|
||||||
|
|
||||||
data class Response(val status: RequestStatus, val body: String = "")
|
data class Response(val status: RequestStatus, val body: String = "")
|
||||||
|
|
||||||
|
data class MtbFileRequest(val requestId: String, val mtbFile: MtbFile)
|
||||||
|
|
||||||
|
data class DeleteRequest(val requestId: String, val patientId: String)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Int.asRequestStatus(): RequestStatus {
|
fun Int.asRequestStatus(): RequestStatus {
|
||||||
|
@ -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
|
|
@ -1,51 +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 dev.dnpm.etl.processor.PatientPseudonym
|
|
||||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
|
||||||
import org.springframework.retry.support.RetryTemplate
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder
|
|
||||||
|
|
||||||
class RestBwhcMtbFileSender(
|
|
||||||
restTemplate: RestTemplate,
|
|
||||||
private val restTargetProperties: RestTargetProperties,
|
|
||||||
retryTemplate: RetryTemplate,
|
|
||||||
reportService: ReportService,
|
|
||||||
) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) {
|
|
||||||
|
|
||||||
override fun sendUrl(): String {
|
|
||||||
return UriComponentsBuilder
|
|
||||||
.fromUriString(restTargetProperties.uri.toString())
|
|
||||||
.pathSegment("MTBFile")
|
|
||||||
.toUriString()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deleteUrl(patientId: PatientPseudonym): String {
|
|
||||||
return UriComponentsBuilder
|
|
||||||
.fromUriString(restTargetProperties.uri.toString())
|
|
||||||
.pathSegment("Patient")
|
|
||||||
.pathSegment(patientId.value)
|
|
||||||
.toUriString()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,55 +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 dev.dnpm.etl.processor.PatientPseudonym
|
|
||||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
|
||||||
import org.springframework.retry.support.RetryTemplate
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder
|
|
||||||
|
|
||||||
class RestDipMtbFileSender(
|
|
||||||
restTemplate: RestTemplate,
|
|
||||||
private val restTargetProperties: RestTargetProperties,
|
|
||||||
retryTemplate: RetryTemplate,
|
|
||||||
reportService: ReportService
|
|
||||||
) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) {
|
|
||||||
|
|
||||||
override fun sendUrl(): String {
|
|
||||||
return UriComponentsBuilder
|
|
||||||
.fromUriString(restTargetProperties.uri.toString())
|
|
||||||
.pathSegment("mtb")
|
|
||||||
.pathSegment("etl")
|
|
||||||
.pathSegment("patient-record")
|
|
||||||
.toUriString()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deleteUrl(patientId: PatientPseudonym): String {
|
|
||||||
return UriComponentsBuilder
|
|
||||||
.fromUriString(restTargetProperties.uri.toString())
|
|
||||||
.pathSegment("mtb")
|
|
||||||
.pathSegment("etl")
|
|
||||||
.pathSegment("patient")
|
|
||||||
.pathSegment(patientId.value)
|
|
||||||
.toUriString()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -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
|
||||||
@ -19,77 +19,38 @@
|
|||||||
|
|
||||||
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.monitoring.asRequestStatus
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.http.HttpEntity
|
import org.springframework.http.HttpEntity
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.retry.support.RetryTemplate
|
|
||||||
import org.springframework.web.client.RestClientException
|
import org.springframework.web.client.RestClientException
|
||||||
import org.springframework.web.client.RestClientResponseException
|
|
||||||
import org.springframework.web.client.RestTemplate
|
import org.springframework.web.client.RestTemplate
|
||||||
|
|
||||||
abstract class RestMtbFileSender(
|
class RestMtbFileSender(
|
||||||
private val restTemplate: RestTemplate,
|
private val restTemplate: RestTemplate,
|
||||||
private val restTargetProperties: RestTargetProperties,
|
private val restTargetProperties: RestTargetProperties
|
||||||
private val retryTemplate: RetryTemplate,
|
|
||||||
private val reportService: ReportService
|
|
||||||
) : MtbFileSender {
|
) : MtbFileSender {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java)
|
private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java)
|
||||||
|
|
||||||
abstract fun sendUrl(): String
|
override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
|
||||||
|
|
||||||
abstract fun deleteUrl(patientId: PatientPseudonym): String
|
|
||||||
|
|
||||||
override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response {
|
|
||||||
try {
|
try {
|
||||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
val headers = HttpHeaders()
|
||||||
val headers = getHttpHeaders(request)
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
val entityReq = HttpEntity(request.content, headers)
|
val entityReq = HttpEntity(request.mtbFile, headers)
|
||||||
val response = restTemplate.postForEntity(
|
val response = restTemplate.postForEntity(
|
||||||
sendUrl(),
|
"${restTargetProperties.uri}/MTBFile",
|
||||||
entityReq,
|
entityReq,
|
||||||
String::class.java
|
String::class.java
|
||||||
)
|
)
|
||||||
if (!response.statusCode.is2xxSuccessful) {
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
logger.warn("Error sending to remote system: {}", response.body)
|
logger.warn("Error sending to remote system: {}", response.body)
|
||||||
return@execute MtbFileSender.Response(
|
return MtbFileSender.Response(response.statusCode.asRequestStatus(), "Status-Code: ${response.statusCode.value()}")
|
||||||
reportService.deserialize(response.body).asRequestStatus(),
|
|
||||||
"Status-Code: ${response.statusCode.value()}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
logger.debug("Sent file via RestMtbFileSender")
|
|
||||||
return@execute MtbFileSender.Response(reportService.deserialize(response.body).asRequestStatus(), response.body.orEmpty())
|
|
||||||
}
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
|
|
||||||
} catch (e: RestClientResponseException) {
|
|
||||||
logger.info(restTargetProperties.uri!!.toString())
|
|
||||||
logger.error("Request data not accepted by remote system", e)
|
|
||||||
return MtbFileSender.Response(reportService.deserialize(e.responseBodyAsString).asRequestStatus(), e.responseBodyAsString)
|
|
||||||
}
|
|
||||||
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun send(request: DeleteRequest): MtbFileSender.Response {
|
|
||||||
try {
|
|
||||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
|
||||||
val headers = getHttpHeaders(request)
|
|
||||||
val entityReq = HttpEntity(null, headers)
|
|
||||||
restTemplate.delete(
|
|
||||||
deleteUrl(request.patientId),
|
|
||||||
entityReq,
|
|
||||||
String::class.java
|
|
||||||
)
|
|
||||||
logger.debug("Sent file via RestMtbFileSender")
|
|
||||||
return@execute MtbFileSender.Response(RequestStatus.SUCCESS)
|
|
||||||
}
|
}
|
||||||
|
logger.debug("Sent file via RestMtbFileSender")
|
||||||
|
return MtbFileSender.Response(response.statusCode.asRequestStatus(), response.body.orEmpty())
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
|
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
|
||||||
} catch (e: RestClientException) {
|
} catch (e: RestClientException) {
|
||||||
@ -99,26 +60,25 @@ 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 endpoint(): String {
|
override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
|
||||||
return this.restTargetProperties.uri.orEmpty()
|
try {
|
||||||
}
|
val headers = HttpHeaders()
|
||||||
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
private fun getHttpHeaders(request: MtbRequest): HttpHeaders {
|
val entityReq = HttpEntity(null, headers)
|
||||||
val username = restTargetProperties.username
|
restTemplate.delete(
|
||||||
val password = restTargetProperties.password
|
"${restTargetProperties.uri}/Patient/${request.patientId}",
|
||||||
val headers = HttpHeaders()
|
entityReq,
|
||||||
headers.contentType = when (request) {
|
String::class.java
|
||||||
is BwhcV1MtbFileRequest -> MediaType.APPLICATION_JSON
|
)
|
||||||
is DnpmV2MtbFileRequest -> CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON
|
logger.debug("Sent file via RestMtbFileSender")
|
||||||
else -> MediaType.APPLICATION_JSON
|
return MtbFileSender.Response(RequestStatus.SUCCESS)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
|
||||||
|
} catch (e: RestClientException) {
|
||||||
|
logger.info(restTargetProperties.uri!!.toString())
|
||||||
|
logger.error("Cannot send data to remote system", e)
|
||||||
}
|
}
|
||||||
|
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
|
||||||
if (username.isNullOrBlank() || password.isNullOrBlank()) {
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.setBasicAuth(username, password)
|
|
||||||
return headers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -19,8 +19,6 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.pseudonym
|
package dev.dnpm.etl.processor.pseudonym
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.PatientId
|
|
||||||
import dev.dnpm.etl.processor.PatientPseudonym
|
|
||||||
import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties
|
import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties
|
||||||
|
|
||||||
class PseudonymizeService(
|
class PseudonymizeService(
|
||||||
@ -28,15 +26,11 @@ class PseudonymizeService(
|
|||||||
private val configProperties: PseudonymizeConfigProperties
|
private val configProperties: PseudonymizeConfigProperties
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun patientPseudonym(patientId: PatientId): PatientPseudonym {
|
fun patientPseudonym(patientId: String): String {
|
||||||
return when (generator) {
|
return when (generator) {
|
||||||
is GpasPseudonymGenerator -> PatientPseudonym(generator.generate(patientId.value))
|
is GpasPseudonymGenerator -> generator.generate(patientId)
|
||||||
else -> PatientPseudonym("${configProperties.prefix}_${generator.generate(patientId.value)}")
|
else -> "${configProperties.prefix}_${generator.generate(patientId)}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prefix(): String {
|
|
||||||
return configProperties.prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* This file is part of ETL-Processor
|
* This file is part of ETL-Processor
|
||||||
*
|
*
|
||||||
* Copyright (c) 2024 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
|
||||||
@ -20,296 +20,31 @@
|
|||||||
package dev.dnpm.etl.processor.pseudonym
|
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.pcvolkmer.mv64e.mtb.Mtb
|
|
||||||
import org.apache.commons.codec.digest.DigestUtils
|
|
||||||
|
|
||||||
/** Replaces patient ID with generated patient pseudonym
|
|
||||||
*
|
|
||||||
* @param pseudonymizeService The pseudonymizeService to be used
|
|
||||||
* @return The MTB file containing patient pseudonymes
|
|
||||||
*/
|
|
||||||
infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
|
infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
|
||||||
val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value
|
val patientPseudonym = pseudonymizeService.patientPseudonym(this.patient.id)
|
||||||
|
|
||||||
this.episode?.patient = patientPseudonym
|
this.episode.patient = patientPseudonym
|
||||||
this.carePlans?.forEach { it.patient = patientPseudonym }
|
this.carePlans.forEach { it.patient = patientPseudonym }
|
||||||
this.patient.id = patientPseudonym
|
this.patient.id = patientPseudonym
|
||||||
this.claims?.forEach { it.patient = patientPseudonym }
|
this.claims.forEach { it.patient = patientPseudonym }
|
||||||
this.consent?.patient = patientPseudonym
|
this.consent.patient = patientPseudonym
|
||||||
this.claimResponses?.forEach { it.patient = patientPseudonym }
|
this.claimResponses.forEach { it.patient = patientPseudonym }
|
||||||
this.diagnoses?.forEach { it.patient = patientPseudonym }
|
this.diagnoses.forEach { it.patient = patientPseudonym }
|
||||||
this.ecogStatus?.forEach { it.patient = patientPseudonym }
|
this.ecogStatus.forEach { it.patient = patientPseudonym }
|
||||||
this.familyMemberDiagnoses?.forEach { it.patient = patientPseudonym }
|
this.familyMemberDiagnoses.forEach { it.patient = patientPseudonym }
|
||||||
this.geneticCounsellingRequests?.forEach { it.patient = patientPseudonym }
|
this.geneticCounsellingRequests.forEach { it.patient = patientPseudonym }
|
||||||
this.histologyReevaluationRequests?.forEach { it.patient = patientPseudonym }
|
this.histologyReevaluationRequests.forEach { it.patient = patientPseudonym }
|
||||||
this.histologyReports?.forEach {
|
this.histologyReports.forEach { it.patient = patientPseudonym }
|
||||||
it.patient = patientPseudonym
|
this.lastGuidelineTherapies.forEach { it.patient = patientPseudonym }
|
||||||
it.tumorMorphology?.patient = patientPseudonym
|
this.molecularPathologyFindings.forEach { it.patient = patientPseudonym }
|
||||||
}
|
this.molecularTherapies.forEach { molecularTherapy -> molecularTherapy.history.forEach { it.patient = patientPseudonym } }
|
||||||
this.lastGuidelineTherapies?.forEach { it.patient = patientPseudonym }
|
this.ngsReports.forEach { it.patient = patientPseudonym }
|
||||||
this.molecularPathologyFindings?.forEach { it.patient = patientPseudonym }
|
this.previousGuidelineTherapies.forEach { it.patient = patientPseudonym }
|
||||||
this.molecularTherapies?.forEach { molecularTherapy ->
|
this.rebiopsyRequests.forEach { it.patient = patientPseudonym }
|
||||||
molecularTherapy.history.forEach {
|
this.recommendations.forEach { it.patient = patientPseudonym }
|
||||||
it.patient = patientPseudonym
|
this.recommendations.forEach { it.patient = patientPseudonym }
|
||||||
}
|
this.responses.forEach { it.patient = patientPseudonym }
|
||||||
}
|
this.specimens.forEach { it.patient = patientPseudonym }
|
||||||
this.ngsReports?.forEach { it.patient = patientPseudonym }
|
this.specimens.forEach { it.patient = patientPseudonym }
|
||||||
this.previousGuidelineTherapies?.forEach { it.patient = patientPseudonym }
|
|
||||||
this.rebiopsyRequests?.forEach { it.patient = patientPseudonym }
|
|
||||||
this.recommendations?.forEach { it.patient = patientPseudonym }
|
|
||||||
this.responses?.forEach { it.patient = patientPseudonym }
|
|
||||||
this.studyInclusionRequests?.forEach { it.patient = patientPseudonym }
|
|
||||||
this.specimens?.forEach { it.patient = patientPseudonym }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates new hash of content IDs with given prefix except for patient IDs
|
|
||||||
*
|
|
||||||
* @param pseudonymizeService The pseudonymizeService to be used
|
|
||||||
* @return The MTB file containing rehashed content IDs
|
|
||||||
*/
|
|
||||||
infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) {
|
|
||||||
val prefix = pseudonymizeService.prefix()
|
|
||||||
|
|
||||||
fun anonymize(id: String): String {
|
|
||||||
val hash = DigestUtils.sha256Hex("$prefix-$id").substring(0, 41).lowercase()
|
|
||||||
return "$prefix$hash"
|
|
||||||
}
|
|
||||||
|
|
||||||
this.episode?.apply {
|
|
||||||
id = id?.let {
|
|
||||||
anonymize(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.carePlans?.onEach { carePlan ->
|
|
||||||
carePlan?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
diagnosis = diagnosis?.let { anonymize(it) }
|
|
||||||
geneticCounsellingRequest = geneticCounsellingRequest?.let { anonymize(it) }
|
|
||||||
rebiopsyRequests = rebiopsyRequests.map { it?.let { anonymize(it) } }
|
|
||||||
recommendations = recommendations.map { it?.let { anonymize(it) } }
|
|
||||||
studyInclusionRequests = studyInclusionRequests.map { it?.let { anonymize(it) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.claims?.onEach { claim ->
|
|
||||||
claim?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
therapy = therapy?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.claimResponses?.onEach { claimResponse ->
|
|
||||||
claimResponse?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
claim = claim?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.consent?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
this.diagnoses?.onEach { diagnosis ->
|
|
||||||
diagnosis?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
histologyResults = histologyResults?.map { it?.let { anonymize(it) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.ecogStatus?.onEach { ecogStatus ->
|
|
||||||
ecogStatus?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.familyMemberDiagnoses?.onEach { familyMemberDiagnosis ->
|
|
||||||
familyMemberDiagnosis?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest ->
|
|
||||||
geneticCounsellingRequest?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.histologyReevaluationRequests?.onEach { histologyReevaluationRequest ->
|
|
||||||
histologyReevaluationRequest?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
specimen = specimen?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.histologyReports?.onEach { histologyReport ->
|
|
||||||
histologyReport?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
specimen = specimen?.let { anonymize(it) }
|
|
||||||
tumorMorphology?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
specimen = specimen?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
tumorCellContent?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
specimen = specimen?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.lastGuidelineTherapies?.onEach { lastGuidelineTherapy ->
|
|
||||||
lastGuidelineTherapy?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
diagnosis = diagnosis?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.molecularPathologyFindings?.onEach { molecularPathologyFinding ->
|
|
||||||
molecularPathologyFinding?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
specimen = specimen?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.molecularTherapies?.onEach { molecularTherapy ->
|
|
||||||
molecularTherapy?.apply {
|
|
||||||
history?.onEach { history ->
|
|
||||||
history?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
basedOn = basedOn?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.ngsReports?.onEach { ngsReport ->
|
|
||||||
ngsReport?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
specimen = specimen?.let { anonymize(it) }
|
|
||||||
tumorCellContent?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
specimen = specimen?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
simpleVariants?.onEach { simpleVariant ->
|
|
||||||
simpleVariant?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.previousGuidelineTherapies?.onEach { previousGuidelineTherapy ->
|
|
||||||
previousGuidelineTherapy?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
diagnosis = diagnosis?.let { anonymize(it) }
|
|
||||||
medication.forEach { medication ->
|
|
||||||
medication?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.rebiopsyRequests?.onEach { rebiopsyRequest ->
|
|
||||||
rebiopsyRequest?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
specimen = specimen?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.recommendations?.onEach { recommendation ->
|
|
||||||
recommendation?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
diagnosis = diagnosis?.let { anonymize(it) }
|
|
||||||
ngsReport = ngsReport?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.responses?.onEach { response ->
|
|
||||||
response?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
therapy = therapy?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.studyInclusionRequests?.onEach { studyInclusionRequest ->
|
|
||||||
studyInclusionRequest?.apply {
|
|
||||||
id = id?.let { anonymize(it) }
|
|
||||||
reason = reason?.let { anonymize(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.specimens?.onEach { specimen ->
|
|
||||||
specimen?.apply {
|
|
||||||
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.histologyReports?.forEach {
|
|
||||||
it.patient.id = patientPseudonym
|
|
||||||
it.results.tumorMorphology?.patient?.id = patientPseudonym
|
|
||||||
it.results.tumorCellContent?.patient?.id = patientPseudonym
|
|
||||||
}
|
|
||||||
this.ngsReports?.forEach {
|
|
||||||
it.patient.id = patientPseudonym
|
|
||||||
it.results.simpleVariants?.forEach { it.patient.id = patientPseudonym }
|
|
||||||
it.results.copyNumberVariants?.forEach { it.patient.id = patientPseudonym }
|
|
||||||
it.results.dnaFusions?.forEach { it.patient.id = patientPseudonym }
|
|
||||||
it.results.rnaFusions?.forEach { it.patient.id = patientPseudonym }
|
|
||||||
it.results.tumorCellContent?.patient?.id = patientPseudonym
|
|
||||||
it.results.brcaness?.patient?.id = patientPseudonym
|
|
||||||
it.results.tmb?.patient?.id = patientPseudonym
|
|
||||||
it.results.hrdScore?.patient?.id = patientPseudonym
|
|
||||||
}
|
|
||||||
this.ihcReports?.forEach {
|
|
||||||
it.patient.id = patientPseudonym
|
|
||||||
it.results.msiMmr?.forEach { it.patient.id = patientPseudonym }
|
|
||||||
it.results.proteinExpression?.forEach { it.patient.id = patientPseudonym }
|
|
||||||
}
|
|
||||||
this.responses?.forEach { it.patient.id = patientPseudonym }
|
|
||||||
this.specimens?.forEach { it.patient.id = patientPseudonym }
|
|
||||||
this.priorDiagnosticReports?.forEach { it.patient.id = patientPseudonym }
|
|
||||||
this.performanceStatus.forEach { it.patient.id = patientPseudonym }
|
|
||||||
this.systemicTherapies.forEach {
|
|
||||||
it.history?.forEach {
|
|
||||||
it.patient.id = patientPseudonym
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates new hash of content IDs with given prefix except for patient IDs
|
|
||||||
*
|
|
||||||
* @since 0.11.0
|
|
||||||
*
|
|
||||||
* @param pseudonymizeService The pseudonymizeService to be used
|
|
||||||
* @return The MTB file containing rehashed content IDs
|
|
||||||
*/
|
|
||||||
infix fun Mtb.anonymizeContentWith(pseudonymizeService: PseudonymizeService) {
|
|
||||||
val prefix = pseudonymizeService.prefix()
|
|
||||||
|
|
||||||
fun anonymize(id: String): String {
|
|
||||||
val hash = DigestUtils.sha256Hex("$prefix-$id").substring(0, 41).lowercase()
|
|
||||||
return "$prefix$hash"
|
|
||||||
}
|
|
||||||
|
|
||||||
this.episodesOfCare?.forEach {
|
|
||||||
it?.apply {
|
|
||||||
id = id?.let {
|
|
||||||
anonymize(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO all other properties
|
|
||||||
}
|
}
|
@ -1,92 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* 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.security
|
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct
|
|
||||||
import org.springframework.data.annotation.Id
|
|
||||||
import org.springframework.data.relational.core.mapping.Table
|
|
||||||
import org.springframework.data.repository.CrudRepository
|
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
import org.springframework.security.core.userdetails.User
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
|
||||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class TokenService(
|
|
||||||
private val userDetailsManager: InMemoryUserDetailsManager,
|
|
||||||
private val passwordEncoder: PasswordEncoder,
|
|
||||||
private val tokenRepository: TokenRepository
|
|
||||||
) {
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
fun setup() {
|
|
||||||
tokenRepository.findAll().forEach {
|
|
||||||
userDetailsManager.createUser(
|
|
||||||
User.withUsername(it.username)
|
|
||||||
.password(it.password)
|
|
||||||
.roles("MTBFILE")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addToken(name: String): Result<String> {
|
|
||||||
val username = name.lowercase().replace("""[^a-z0-9]""".toRegex(), "")
|
|
||||||
if (userDetailsManager.userExists(username)) {
|
|
||||||
return Result.failure(RuntimeException("Cannot use token name"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val password = Base64.getEncoder().encodeToString(UUID.randomUUID().toString().encodeToByteArray())
|
|
||||||
val encodedPassword = passwordEncoder.encode(password).toString()
|
|
||||||
|
|
||||||
userDetailsManager.createUser(
|
|
||||||
User.withUsername(username)
|
|
||||||
.password(encodedPassword)
|
|
||||||
.roles("MTBFILE")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
|
|
||||||
tokenRepository.save(Token(name = name, username = username, password = encodedPassword))
|
|
||||||
|
|
||||||
return Result.success("$username:$password")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteToken(id: Long) {
|
|
||||||
val token = tokenRepository.findByIdOrNull(id) ?: return
|
|
||||||
userDetailsManager.deleteUser(token.username)
|
|
||||||
tokenRepository.delete(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun findAll(): List<Token> {
|
|
||||||
return tokenRepository.findAll().toList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Table("token")
|
|
||||||
data class Token(
|
|
||||||
@Id val id: Long? = null,
|
|
||||||
val name: String,
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
val createdAt: Instant = Instant.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
interface TokenRepository : CrudRepository<Token, Long>
|
|
@ -1,45 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* 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.security
|
|
||||||
|
|
||||||
import org.springframework.data.annotation.Id
|
|
||||||
import org.springframework.data.relational.core.mapping.Table
|
|
||||||
import org.springframework.data.repository.CrudRepository
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Table("user_role")
|
|
||||||
data class UserRole(
|
|
||||||
@Id val id: Long? = null,
|
|
||||||
val username: String,
|
|
||||||
var role: Role = Role.GUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class Role(val value: String) {
|
|
||||||
GUEST("guest"),
|
|
||||||
USER("user"),
|
|
||||||
ADMIN("admin")
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserRoleRepository : CrudRepository<UserRole, Long> {
|
|
||||||
|
|
||||||
fun findByUsername(username: String): Optional<UserRole>
|
|
||||||
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* 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.security
|
|
||||||
|
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
import org.springframework.security.core.session.SessionRegistry
|
|
||||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
|
||||||
|
|
||||||
class UserRoleService(
|
|
||||||
private val userRoleRepository: UserRoleRepository,
|
|
||||||
private val sessionRegistry: SessionRegistry
|
|
||||||
) {
|
|
||||||
fun updateUserRole(id: Long, role: Role) {
|
|
||||||
val userRole = userRoleRepository.findByIdOrNull(id) ?: return
|
|
||||||
userRole.role = role
|
|
||||||
userRoleRepository.save(userRole)
|
|
||||||
expireSessionFor(userRole.username)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteUserRole(id: Long) {
|
|
||||||
val userRole = userRoleRepository.findByIdOrNull(id) ?: return
|
|
||||||
userRoleRepository.delete(userRole)
|
|
||||||
expireSessionFor(userRole.username)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun findAll(): List<UserRole> {
|
|
||||||
return userRoleRepository.findAll().toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun expireSessionFor(username: String) {
|
|
||||||
sessionRegistry.allPrincipals
|
|
||||||
.filterIsInstance<OidcUser>()
|
|
||||||
.filter { it.preferredUsername == username }
|
|
||||||
.flatMap {
|
|
||||||
sessionRegistry.getAllSessions(it, true)
|
|
||||||
}
|
|
||||||
.onEach {
|
|
||||||
it.expireNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,17 +21,13 @@ package dev.dnpm.etl.processor.services
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||||
import dev.dnpm.etl.processor.*
|
|
||||||
import dev.dnpm.etl.processor.config.AppConfigProperties
|
|
||||||
import dev.dnpm.etl.processor.monitoring.Report
|
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.anonymizeContentWith
|
|
||||||
import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith
|
import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith
|
||||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
|
||||||
import org.apache.commons.codec.binary.Base32
|
import org.apache.commons.codec.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
|
||||||
@ -42,54 +38,35 @@ import java.util.*
|
|||||||
@Service
|
@Service
|
||||||
class RequestProcessor(
|
class RequestProcessor(
|
||||||
private val pseudonymizeService: PseudonymizeService,
|
private val pseudonymizeService: PseudonymizeService,
|
||||||
private val transformationService: TransformationService,
|
|
||||||
private val sender: MtbFileSender,
|
private val sender: MtbFileSender,
|
||||||
private val requestService: RequestService,
|
private val requestService: RequestService,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher
|
||||||
private val appConfigProperties: AppConfigProperties
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun processMtbFile(mtbFile: MtbFile) {
|
fun processMtbFile(mtbFile: MtbFile) {
|
||||||
processMtbFile(mtbFile, randomRequestId())
|
val requestId = UUID.randomUUID().toString()
|
||||||
}
|
val pid = mtbFile.patient.id
|
||||||
|
|
||||||
fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) {
|
|
||||||
val pid = PatientId(mtbFile.patient.id)
|
|
||||||
mtbFile pseudonymizeWith pseudonymizeService
|
mtbFile pseudonymizeWith pseudonymizeService
|
||||||
mtbFile anonymizeContentWith pseudonymizeService
|
|
||||||
val request = BwhcV1MtbFileRequest(requestId, transformationService.transform(mtbFile))
|
|
||||||
saveAndSend(request, pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun processMtbFile(mtbFile: Mtb) {
|
val request = MtbFileSender.MtbFileRequest(requestId, mtbFile)
|
||||||
processMtbFile(mtbFile, randomRequestId())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun processMtbFile(mtbFile: Mtb, requestId: RequestId) {
|
|
||||||
val pid = PatientId(mtbFile.patient.id)
|
|
||||||
mtbFile pseudonymizeWith pseudonymizeService
|
|
||||||
mtbFile anonymizeContentWith pseudonymizeService
|
|
||||||
val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile))
|
|
||||||
saveAndSend(request, pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) {
|
|
||||||
requestService.save(
|
requestService.save(
|
||||||
Request(
|
Request(
|
||||||
request.requestId,
|
uuid = requestId,
|
||||||
request.patientPseudonym(),
|
patientId = request.mtbFile.patient.id,
|
||||||
pid,
|
pid = pid,
|
||||||
fingerprint(request),
|
fingerprint = fingerprint(request.mtbFile),
|
||||||
RequestType.MTB_FILE,
|
status = RequestStatus.UNKNOWN,
|
||||||
RequestStatus.UNKNOWN
|
type = RequestType.MTB_FILE
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (appConfigProperties.duplicationDetection && isDuplication(request)) {
|
if (isDuplication(mtbFile)) {
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
ResponseEvent(
|
ResponseEvent(
|
||||||
request.requestId,
|
requestId,
|
||||||
Instant.now(),
|
Instant.now(),
|
||||||
RequestStatus.DUPLICATION
|
RequestStatus.DUPLICATION
|
||||||
)
|
)
|
||||||
@ -101,52 +78,45 @@ 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) {
|
||||||
RequestStatus.ERROR, RequestStatus.WARNING -> Optional.of(responseStatus.body)
|
RequestStatus.WARNING -> Optional.of(responseStatus.body)
|
||||||
else -> Optional.empty()
|
else -> Optional.empty()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> isDuplication(pseudonymizedMtbFileRequest: MtbFileRequest<T>): Boolean {
|
private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean {
|
||||||
val patientPseudonym = when (pseudonymizedMtbFileRequest) {
|
|
||||||
is BwhcV1MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
|
|
||||||
is DnpmV2MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
val lastMtbFileRequestForPatient =
|
val lastMtbFileRequestForPatient =
|
||||||
requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym)
|
requestService.lastMtbFileRequestForPatientPseudonym(pseudonymizedMtbFile.patient.id)
|
||||||
val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym)
|
val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(pseudonymizedMtbFile.patient.id)
|
||||||
|
|
||||||
return null != lastMtbFileRequestForPatient
|
return null != lastMtbFileRequestForPatient
|
||||||
&& !isLastRequestDeletion
|
&& !isLastRequestDeletion
|
||||||
&& lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFileRequest)
|
&& lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun processDeletion(patientId: PatientId) {
|
fun processDeletion(patientId: String) {
|
||||||
processDeletion(patientId, randomRequestId())
|
val requestId = UUID.randomUUID().toString()
|
||||||
}
|
|
||||||
|
|
||||||
fun processDeletion(patientId: PatientId, requestId: RequestId) {
|
|
||||||
try {
|
try {
|
||||||
val patientPseudonym = pseudonymizeService.patientPseudonym(patientId)
|
val patientPseudonym = pseudonymizeService.patientPseudonym(patientId)
|
||||||
|
|
||||||
requestService.save(
|
requestService.save(
|
||||||
Request(
|
Request(
|
||||||
requestId,
|
uuid = requestId,
|
||||||
patientPseudonym,
|
patientId = patientPseudonym,
|
||||||
patientId,
|
pid = patientId,
|
||||||
fingerprint(patientPseudonym.value),
|
fingerprint = fingerprint(patientPseudonym),
|
||||||
RequestType.DELETE,
|
status = RequestStatus.UNKNOWN,
|
||||||
RequestStatus.UNKNOWN
|
type = RequestType.DELETE
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val responseStatus = sender.send(DeleteRequest(requestId, patientPseudonym))
|
val responseStatus = sender.send(MtbFileSender.DeleteRequest(requestId, patientPseudonym))
|
||||||
|
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
ResponseEvent(
|
ResponseEvent(
|
||||||
@ -164,9 +134,9 @@ class RequestProcessor(
|
|||||||
requestService.save(
|
requestService.save(
|
||||||
Request(
|
Request(
|
||||||
uuid = requestId,
|
uuid = requestId,
|
||||||
patientPseudonym = emptyPatientPseudonym(),
|
patientId = "???",
|
||||||
pid = patientId,
|
pid = patientId,
|
||||||
fingerprint = Fingerprint.empty(),
|
fingerprint = "",
|
||||||
status = RequestStatus.ERROR,
|
status = RequestStatus.ERROR,
|
||||||
type = RequestType.DELETE,
|
type = RequestType.DELETE,
|
||||||
report = Report("Fehler bei der Pseudonymisierung")
|
report = Report("Fehler bei der Pseudonymisierung")
|
||||||
@ -175,19 +145,14 @@ class RequestProcessor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> fingerprint(request: MtbFileRequest<T>): Fingerprint {
|
private fun fingerprint(mtbFile: MtbFile): String {
|
||||||
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): String {
|
||||||
return Fingerprint(
|
return Base32().encodeAsString(DigestUtils.sha256(s))
|
||||||
Base32().encodeAsString(DigestUtils.sha256(s))
|
.replace("=", "")
|
||||||
.replace("=", "")
|
.lowercase()
|
||||||
.lowercase()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -19,13 +19,11 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.services
|
package dev.dnpm.etl.processor.services
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.PatientPseudonym
|
import dev.dnpm.etl.processor.monitoring.Request
|
||||||
import dev.dnpm.etl.processor.RequestId
|
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||||
import dev.dnpm.etl.processor.monitoring.*
|
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||||
import org.springframework.data.domain.Page
|
import dev.dnpm.etl.processor.monitoring.RequestType
|
||||||
import org.springframework.data.domain.Pageable
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class RequestService(
|
class RequestService(
|
||||||
@ -34,32 +32,15 @@ class RequestService(
|
|||||||
|
|
||||||
fun save(request: Request) = requestRepository.save(request)
|
fun save(request: Request) = requestRepository.save(request)
|
||||||
|
|
||||||
fun findAll(): Iterable<Request> = requestRepository.findAll()
|
fun allRequestsByPatientPseudonym(patientPseudonym: String) = requestRepository
|
||||||
|
.findAllByPatientIdOrderByProcessedAtDesc(patientPseudonym)
|
||||||
|
|
||||||
fun findAll(pageable: Pageable): Page<Request> = requestRepository.findAll(pageable)
|
fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: String) =
|
||||||
|
|
||||||
fun findByUuid(uuid: RequestId): Optional<Request> =
|
|
||||||
requestRepository.findByUuidEquals(uuid)
|
|
||||||
|
|
||||||
fun findRequestByPatientId(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request> = requestRepository.findRequestByPatientPseudonym(patientPseudonym, pageable)
|
|
||||||
|
|
||||||
fun allRequestsByPatientPseudonym(patientPseudonym: PatientPseudonym) = requestRepository
|
|
||||||
.findAllByPatientPseudonymOrderByProcessedAtDesc(patientPseudonym)
|
|
||||||
|
|
||||||
fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: PatientPseudonym) =
|
|
||||||
Companion.lastMtbFileRequestForPatientPseudonym(allRequestsByPatientPseudonym(patientPseudonym))
|
Companion.lastMtbFileRequestForPatientPseudonym(allRequestsByPatientPseudonym(patientPseudonym))
|
||||||
|
|
||||||
fun isLastRequestWithKnownStatusDeletion(patientPseudonym: PatientPseudonym) =
|
fun isLastRequestWithKnownStatusDeletion(patientPseudonym: String) =
|
||||||
Companion.isLastRequestWithKnownStatusDeletion(allRequestsByPatientPseudonym(patientPseudonym))
|
Companion.isLastRequestWithKnownStatusDeletion(allRequestsByPatientPseudonym(patientPseudonym))
|
||||||
|
|
||||||
fun countStates(): Iterable<CountedState> = requestRepository.countStates()
|
|
||||||
|
|
||||||
fun countDeleteStates(): Iterable<CountedState> = requestRepository.countDeleteStates()
|
|
||||||
|
|
||||||
fun findPatientUniqueStates(): List<CountedState> = requestRepository.findPatientUniqueStates()
|
|
||||||
|
|
||||||
fun findPatientUniqueDeleteStates(): List<CountedState> = requestRepository.findPatientUniqueDeleteStates()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests
|
fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests
|
||||||
|
@ -19,8 +19,8 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.services
|
package dev.dnpm.etl.processor.services
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.RequestId
|
|
||||||
import dev.dnpm.etl.processor.monitoring.Report
|
import dev.dnpm.etl.processor.monitoring.Report
|
||||||
|
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.context.event.EventListener
|
import org.springframework.context.event.EventListener
|
||||||
@ -31,7 +31,7 @@ import java.util.*
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ResponseProcessor(
|
class ResponseProcessor(
|
||||||
private val requestService: RequestService,
|
private val requestRepository: RequestRepository,
|
||||||
private val statisticsUpdateProducer: Sinks.Many<Any>
|
private val statisticsUpdateProducer: Sinks.Many<Any>
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ class ResponseProcessor(
|
|||||||
|
|
||||||
@EventListener(classes = [ResponseEvent::class])
|
@EventListener(classes = [ResponseEvent::class])
|
||||||
fun handleResponseEvent(event: ResponseEvent) {
|
fun handleResponseEvent(event: ResponseEvent) {
|
||||||
requestService.findByUuid(event.requestUuid).ifPresentOrElse({
|
requestRepository.findByUuidEquals(event.requestUuid).ifPresentOrElse({
|
||||||
it.processedAt = event.timestamp
|
it.processedAt = event.timestamp
|
||||||
it.status = event.status
|
it.status = event.status
|
||||||
|
|
||||||
@ -76,7 +76,7 @@ class ResponseProcessor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestService.save(it)
|
requestRepository.save(it)
|
||||||
|
|
||||||
statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST)
|
statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST)
|
||||||
}, {
|
}, {
|
||||||
@ -87,7 +87,7 @@ class ResponseProcessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class ResponseEvent(
|
data class ResponseEvent(
|
||||||
val requestUuid: RequestId,
|
val requestUuid: String,
|
||||||
val timestamp: Instant,
|
val timestamp: Instant,
|
||||||
val status: RequestStatus,
|
val status: RequestStatus,
|
||||||
val body: Optional<String> = Optional.empty()
|
val body: Optional<String> = Optional.empty()
|
||||||
|
@ -1,96 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.services
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import com.jayway.jsonpath.JsonPath
|
|
||||||
import com.jayway.jsonpath.PathNotFoundException
|
|
||||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
|
||||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
|
||||||
|
|
||||||
class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List<Transformation>) {
|
|
||||||
fun transform(mtbFile: MtbFile): MtbFile {
|
|
||||||
val json = transform(objectMapper.writeValueAsString(mtbFile))
|
|
||||||
return objectMapper.readValue(json, MtbFile::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun transform(mtbFile: Mtb): Mtb {
|
|
||||||
val json = transform(objectMapper.writeValueAsString(mtbFile))
|
|
||||||
return objectMapper.readValue(json, Mtb::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun transform(content: String): String {
|
|
||||||
var json = content
|
|
||||||
|
|
||||||
transformations.forEach { transformation ->
|
|
||||||
val jsonPath = JsonPath.parse(json)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val before = transformation.path.substringBeforeLast(".")
|
|
||||||
val last = transformation.path.substringAfterLast(".")
|
|
||||||
|
|
||||||
val existingValue = if (transformation.existingValue is Number) transformation.existingValue else transformation.existingValue.toString()
|
|
||||||
val newValue = if (transformation.newValue is Number) transformation.newValue else transformation.newValue.toString()
|
|
||||||
|
|
||||||
jsonPath.set("$.$before.[?]$last", newValue, {
|
|
||||||
it.item(HashMap::class.java)[last] == existingValue
|
|
||||||
})
|
|
||||||
} catch (e: PathNotFoundException) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
json = jsonPath.jsonString()
|
|
||||||
}
|
|
||||||
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getTransformations(): List<Transformation> {
|
|
||||||
return this.transformations
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class Transformation private constructor(val path: String) {
|
|
||||||
|
|
||||||
lateinit var existingValue: Any
|
|
||||||
private set
|
|
||||||
lateinit var newValue: Any
|
|
||||||
private set
|
|
||||||
|
|
||||||
infix fun from(value: Any): Transformation {
|
|
||||||
this.existingValue = value
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
infix fun to(value: Any): Transformation {
|
|
||||||
this.newValue = value
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun of(path: String): Transformation {
|
|
||||||
return Transformation(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -22,7 +22,6 @@ package dev.dnpm.etl.processor.services.kafka
|
|||||||
import com.fasterxml.jackson.annotation.JsonAlias
|
import com.fasterxml.jackson.annotation.JsonAlias
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import dev.dnpm.etl.processor.RequestId
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||||
import dev.dnpm.etl.processor.output.asRequestStatus
|
import dev.dnpm.etl.processor.output.asRequestStatus
|
||||||
import dev.dnpm.etl.processor.services.ResponseEvent
|
import dev.dnpm.etl.processor.services.ResponseEvent
|
||||||
@ -48,7 +47,7 @@ class KafkaResponseProcessor(
|
|||||||
Optional.empty()
|
Optional.empty()
|
||||||
}.ifPresentOrElse({ responseBody ->
|
}.ifPresentOrElse({ responseBody ->
|
||||||
val event = ResponseEvent(
|
val event = ResponseEvent(
|
||||||
RequestId(responseBody.requestId),
|
responseBody.requestId,
|
||||||
Instant.ofEpochMilli(data.timestamp()),
|
Instant.ofEpochMilli(data.timestamp()),
|
||||||
responseBody.statusCode.asRequestStatus(),
|
responseBody.statusCode.asRequestStatus(),
|
||||||
when (responseBody.statusCode.asRequestStatus()) {
|
when (responseBody.statusCode.asRequestStatus()) {
|
||||||
|
@ -1,63 +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
|
|
||||||
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class Fingerprint(val value: String) {
|
|
||||||
override fun hashCode() = value.hashCode()
|
|
||||||
|
|
||||||
override fun equals(other: Any?) = other is Fingerprint && other.value == value
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun empty() = Fingerprint("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmInline
|
|
||||||
value class RequestId(val value: String) {
|
|
||||||
|
|
||||||
fun isBlank() = value.isBlank()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fun randomRequestId() = RequestId(UUID.randomUUID().toString())
|
|
||||||
|
|
||||||
@JvmInline
|
|
||||||
value class PatientId(val value: String)
|
|
||||||
|
|
||||||
@JvmInline
|
|
||||||
value class PatientPseudonym(val value: String)
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
@ -1,201 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.web
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.OutputConnectionCheckService
|
|
||||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
|
||||||
import dev.dnpm.etl.processor.pseudonym.Generator
|
|
||||||
import dev.dnpm.etl.processor.security.Role
|
|
||||||
import dev.dnpm.etl.processor.security.UserRole
|
|
||||||
import dev.dnpm.etl.processor.security.Token
|
|
||||||
import dev.dnpm.etl.processor.security.TokenService
|
|
||||||
import dev.dnpm.etl.processor.services.TransformationService
|
|
||||||
import dev.dnpm.etl.processor.security.UserRoleService
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.http.codec.ServerSentEvent
|
|
||||||
import org.springframework.stereotype.Controller
|
|
||||||
import org.springframework.ui.Model
|
|
||||||
import org.springframework.web.bind.annotation.*
|
|
||||||
import reactor.core.publisher.Flux
|
|
||||||
import reactor.core.publisher.Sinks
|
|
||||||
|
|
||||||
@Controller
|
|
||||||
@RequestMapping(path = ["configs"])
|
|
||||||
class ConfigController(
|
|
||||||
@Qualifier("connectionCheckUpdateProducer")
|
|
||||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>,
|
|
||||||
private val transformationService: TransformationService,
|
|
||||||
private val pseudonymGenerator: Generator,
|
|
||||||
private val mtbFileSender: MtbFileSender,
|
|
||||||
private val connectionCheckServices: List<ConnectionCheckService>,
|
|
||||||
private val tokenService: TokenService?,
|
|
||||||
private val userRoleService: UserRoleService?
|
|
||||||
) {
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
fun index(model: Model): String {
|
|
||||||
val outputConnectionAvailable =
|
|
||||||
connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().firstOrNull()?.connectionAvailable()
|
|
||||||
|
|
||||||
val gPasConnectionAvailable =
|
|
||||||
connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable()
|
|
||||||
|
|
||||||
model.addAttribute("pseudonymGenerator", pseudonymGenerator.javaClass.simpleName)
|
|
||||||
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
|
|
||||||
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
|
|
||||||
model.addAttribute("outputConnectionAvailable", outputConnectionAvailable)
|
|
||||||
model.addAttribute("gPasConnectionAvailable", gPasConnectionAvailable)
|
|
||||||
model.addAttribute("tokensEnabled", tokenService != null)
|
|
||||||
if (tokenService != null) {
|
|
||||||
model.addAttribute("tokens", tokenService.findAll())
|
|
||||||
} else {
|
|
||||||
model.addAttribute("tokens", emptyList<Token>())
|
|
||||||
}
|
|
||||||
model.addAttribute("transformations", transformationService.getTransformations())
|
|
||||||
if (userRoleService != null) {
|
|
||||||
model.addAttribute("userRolesEnabled", true)
|
|
||||||
model.addAttribute("userRoles", userRoleService.findAll())
|
|
||||||
} else {
|
|
||||||
model.addAttribute("userRolesEnabled", false)
|
|
||||||
model.addAttribute("userRoles", emptyList<UserRole>())
|
|
||||||
}
|
|
||||||
return "configs"
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(params = ["outputConnectionAvailable"])
|
|
||||||
fun outputConnectionAvailable(model: Model): String {
|
|
||||||
val outputConnectionAvailable =
|
|
||||||
connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().first().connectionAvailable()
|
|
||||||
|
|
||||||
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
|
|
||||||
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
|
|
||||||
model.addAttribute("outputConnectionAvailable", outputConnectionAvailable)
|
|
||||||
if (tokenService != null) {
|
|
||||||
model.addAttribute("tokensEnabled", true)
|
|
||||||
model.addAttribute("tokens", tokenService.findAll())
|
|
||||||
} else {
|
|
||||||
model.addAttribute("tokens", listOf<Token>())
|
|
||||||
}
|
|
||||||
|
|
||||||
return "configs/outputConnectionAvailable"
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(params = ["gPasConnectionAvailable"])
|
|
||||||
fun gPasConnectionAvailable(model: Model): String {
|
|
||||||
val gPasConnectionAvailable =
|
|
||||||
connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable()
|
|
||||||
|
|
||||||
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
|
|
||||||
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
|
|
||||||
model.addAttribute("gPasConnectionAvailable", gPasConnectionAvailable)
|
|
||||||
if (tokenService != null) {
|
|
||||||
model.addAttribute("tokensEnabled", true)
|
|
||||||
model.addAttribute("tokens", tokenService.findAll())
|
|
||||||
} else {
|
|
||||||
model.addAttribute("tokens", listOf<Token>())
|
|
||||||
}
|
|
||||||
|
|
||||||
return "configs/gPasConnectionAvailable"
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(path = ["tokens"])
|
|
||||||
fun addToken(@ModelAttribute("name") name: String, model: Model): String {
|
|
||||||
if (tokenService == null) {
|
|
||||||
model.addAttribute("tokensEnabled", false)
|
|
||||||
model.addAttribute("success", false)
|
|
||||||
} else {
|
|
||||||
model.addAttribute("tokensEnabled", true)
|
|
||||||
val result = tokenService.addToken(name)
|
|
||||||
result.onSuccess {
|
|
||||||
model.addAttribute("newTokenValue", it)
|
|
||||||
model.addAttribute("success", true)
|
|
||||||
}
|
|
||||||
result.onFailure {
|
|
||||||
model.addAttribute("success", false)
|
|
||||||
}
|
|
||||||
model.addAttribute("tokens", tokenService.findAll())
|
|
||||||
}
|
|
||||||
|
|
||||||
return "configs/tokens"
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping(path = ["tokens/{id}"])
|
|
||||||
fun deleteToken(@PathVariable id: Long, model: Model): String {
|
|
||||||
if (tokenService != null) {
|
|
||||||
tokenService.deleteToken(id)
|
|
||||||
|
|
||||||
model.addAttribute("tokensEnabled", true)
|
|
||||||
model.addAttribute("tokens", tokenService.findAll())
|
|
||||||
} else {
|
|
||||||
model.addAttribute("tokensEnabled", false)
|
|
||||||
model.addAttribute("tokens", listOf<Token>())
|
|
||||||
}
|
|
||||||
return "configs/tokens"
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping(path = ["userroles/{id}"])
|
|
||||||
fun deleteUserRole(@PathVariable id: Long, model: Model): String {
|
|
||||||
if (userRoleService != null) {
|
|
||||||
userRoleService.deleteUserRole(id)
|
|
||||||
|
|
||||||
model.addAttribute("userRolesEnabled", true)
|
|
||||||
model.addAttribute("userRoles", userRoleService.findAll())
|
|
||||||
} else {
|
|
||||||
model.addAttribute("userRolesEnabled", false)
|
|
||||||
model.addAttribute("userRoles", emptyList<UserRole>())
|
|
||||||
}
|
|
||||||
return "configs/userroles"
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping(path = ["userroles/{id}"])
|
|
||||||
fun updateUserRole(@PathVariable id: Long, @ModelAttribute("role") role: Role, model: Model): String {
|
|
||||||
if (userRoleService != null) {
|
|
||||||
userRoleService.updateUserRole(id, role)
|
|
||||||
|
|
||||||
model.addAttribute("userRolesEnabled", true)
|
|
||||||
model.addAttribute("userRoles", userRoleService.findAll())
|
|
||||||
} else {
|
|
||||||
model.addAttribute("userRolesEnabled", false)
|
|
||||||
model.addAttribute("userRoles", emptyList<UserRole>())
|
|
||||||
}
|
|
||||||
return "configs/userroles"
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
|
|
||||||
@ResponseBody
|
|
||||||
fun events(): Flux<ServerSentEvent<Any>> {
|
|
||||||
return connectionCheckUpdateProducer.asFlux().map {
|
|
||||||
val event = when (it) {
|
|
||||||
is ConnectionCheckResult.KafkaConnectionCheckResult -> "output-connection-check"
|
|
||||||
is ConnectionCheckResult.RestConnectionCheckResult -> "output-connection-check"
|
|
||||||
is ConnectionCheckResult.GPasConnectionCheckResult -> "gpas-connection-check"
|
|
||||||
}
|
|
||||||
|
|
||||||
ServerSentEvent.builder<Any>()
|
|
||||||
.event(event).id("none").data(it)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -20,13 +20,9 @@
|
|||||||
package dev.dnpm.etl.processor.web
|
package dev.dnpm.etl.processor.web
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.NotFoundException
|
import dev.dnpm.etl.processor.NotFoundException
|
||||||
import dev.dnpm.etl.processor.PatientPseudonym
|
|
||||||
import dev.dnpm.etl.processor.RequestId
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||||
import dev.dnpm.etl.processor.services.RequestService
|
import dev.dnpm.etl.processor.monitoring.RequestId
|
||||||
import org.springframework.data.domain.Pageable
|
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||||
import org.springframework.data.domain.Sort
|
|
||||||
import org.springframework.data.web.PageableDefault
|
|
||||||
import org.springframework.stereotype.Controller
|
import org.springframework.stereotype.Controller
|
||||||
import org.springframework.ui.Model
|
import org.springframework.ui.Model
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
@ -36,29 +32,13 @@ import org.springframework.web.bind.annotation.RequestMapping
|
|||||||
@Controller
|
@Controller
|
||||||
@RequestMapping(path = ["/"])
|
@RequestMapping(path = ["/"])
|
||||||
class HomeController(
|
class HomeController(
|
||||||
private val requestService: RequestService,
|
private val requestRepository: RequestRepository,
|
||||||
private val reportService: ReportService
|
private val reportService: ReportService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun index(
|
fun index(model: Model): String {
|
||||||
@PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable,
|
val requests = requestRepository.findAll().sortedByDescending { it.processedAt }.take(25)
|
||||||
model: Model
|
|
||||||
): String {
|
|
||||||
val requests = requestService.findAll(pageable)
|
|
||||||
model.addAttribute("requests", requests)
|
|
||||||
|
|
||||||
return "index"
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(path = ["patient/{patientPseudonym}"])
|
|
||||||
fun byPatient(
|
|
||||||
@PathVariable patientPseudonym: PatientPseudonym,
|
|
||||||
@PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable,
|
|
||||||
model: Model
|
|
||||||
): String {
|
|
||||||
val requests = requestService.findRequestByPatientId(patientPseudonym, pageable)
|
|
||||||
model.addAttribute("patientPseudonym", patientPseudonym.value)
|
|
||||||
model.addAttribute("requests", requests)
|
model.addAttribute("requests", requests)
|
||||||
|
|
||||||
return "index"
|
return "index"
|
||||||
@ -66,7 +46,7 @@ class HomeController(
|
|||||||
|
|
||||||
@GetMapping(path = ["/report/{id}"])
|
@GetMapping(path = ["/report/{id}"])
|
||||||
fun report(@PathVariable id: RequestId, model: Model): String {
|
fun report(@PathVariable id: RequestId, model: Model): String {
|
||||||
val request = requestService.findByUuid(id).orElse(null) ?: throw NotFoundException()
|
val request = requestRepository.findByUuidEquals(id.toString()).orElse(null) ?: throw NotFoundException()
|
||||||
model.addAttribute("request", request)
|
model.addAttribute("request", request)
|
||||||
model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport))
|
model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport))
|
||||||
|
|
||||||
|
@ -1,47 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.web
|
|
||||||
|
|
||||||
import dev.dnpm.etl.processor.config.SecurityConfigProperties
|
|
||||||
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties
|
|
||||||
import org.springframework.stereotype.Controller
|
|
||||||
import org.springframework.ui.Model
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
|
|
||||||
@Controller
|
|
||||||
class LoginController(
|
|
||||||
private val securityConfigProperties: SecurityConfigProperties?,
|
|
||||||
private val oAuth2ClientProperties: OAuth2ClientProperties?
|
|
||||||
) {
|
|
||||||
|
|
||||||
@GetMapping(path = ["/login"])
|
|
||||||
fun login(model: Model): String {
|
|
||||||
if (securityConfigProperties?.enableOidc == true) {
|
|
||||||
model.addAttribute(
|
|
||||||
"oidcLogins",
|
|
||||||
oAuth2ClientProperties?.registration?.map { (key, value) -> Pair(key, value.clientName) }.orEmpty()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
model.addAttribute("oidcLogins", emptyList<Pair<String, String>>())
|
|
||||||
}
|
|
||||||
return "login"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -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
|
||||||
@ -17,56 +17,38 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.dnpm.etl.processor.input
|
package dev.dnpm.etl.processor.web
|
||||||
|
|
||||||
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.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.*
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping(path = ["mtbfile", "mtb"])
|
|
||||||
class MtbFileRestController(
|
class MtbFileRestController(
|
||||||
private val requestProcessor: RequestProcessor,
|
private val requestProcessor: RequestProcessor,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java)
|
private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java)
|
||||||
|
|
||||||
@GetMapping
|
@PostMapping(path = ["/mtbfile"])
|
||||||
fun info(): ResponseEntity<String> {
|
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Void> {
|
||||||
return ResponseEntity.ok("Test")
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping( consumes = [ MediaType.APPLICATION_JSON_VALUE ] )
|
|
||||||
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)
|
requestProcessor.processDeletion(mtbFile.patient.id)
|
||||||
requestProcessor.processDeletion(patientId)
|
|
||||||
}
|
}
|
||||||
return ResponseEntity.accepted().build()
|
return ResponseEntity.accepted().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping( consumes = [ CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE] )
|
@DeleteMapping(path = ["/mtbfile/{patientId}"])
|
||||||
fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity<Unit> {
|
fun deleteData(@PathVariable patientId: String): ResponseEntity<Void> {
|
||||||
logger.debug("Accepted MTB File (DNPM V2) for processing")
|
|
||||||
requestProcessor.processMtbFile(mtbFile)
|
|
||||||
return ResponseEntity.accepted().build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping(path = ["{patientId}"])
|
|
||||||
fun deleteData(@PathVariable patientId: String): ResponseEntity<Unit> {
|
|
||||||
logger.debug("Accepted patient ID to process deletion")
|
logger.debug("Accepted patient ID to process deletion")
|
||||||
requestProcessor.processDeletion(PatientId(patientId))
|
requestProcessor.processDeletion(patientId)
|
||||||
return ResponseEntity.accepted().build()
|
return ResponseEntity.accepted().build()
|
||||||
}
|
}
|
||||||
|
|
@ -19,10 +19,9 @@
|
|||||||
|
|
||||||
package dev.dnpm.etl.processor.web
|
package dev.dnpm.etl.processor.web
|
||||||
|
|
||||||
|
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.monitoring.RequestType
|
import dev.dnpm.etl.processor.monitoring.RequestType
|
||||||
import dev.dnpm.etl.processor.services.RequestService
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier
|
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.codec.ServerSentEvent
|
import org.springframework.http.codec.ServerSentEvent
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
@ -39,17 +38,16 @@ import java.time.temporal.ChronoUnit
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping(path = ["/statistics"])
|
@RequestMapping(path = ["/statistics"])
|
||||||
class StatisticsRestController(
|
class StatisticsRestController(
|
||||||
@Qualifier("statisticsUpdateProducer")
|
|
||||||
private val statisticsUpdateProducer: Sinks.Many<Any>,
|
private val statisticsUpdateProducer: Sinks.Many<Any>,
|
||||||
private val requestService: RequestService
|
private val requestRepository: RequestRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping(path = ["requeststates"])
|
@GetMapping(path = ["requeststates"])
|
||||||
fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
|
fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
|
||||||
val states = if (delete) {
|
val states = if (delete) {
|
||||||
requestService.countDeleteStates()
|
requestRepository.countDeleteStates()
|
||||||
} else {
|
} else {
|
||||||
requestService.countStates()
|
requestRepository.countStates()
|
||||||
}
|
}
|
||||||
|
|
||||||
return states
|
return states
|
||||||
@ -79,7 +77,7 @@ class StatisticsRestController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin"))
|
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin"))
|
||||||
val data = requestService.findAll()
|
val data = requestRepository.findAll()
|
||||||
.filter { it.type == requestType }
|
.filter { it.type == requestType }
|
||||||
.filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) }
|
.filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) }
|
||||||
.groupBy { formatter.format(it.processedAt) }
|
.groupBy { formatter.format(it.processedAt) }
|
||||||
@ -115,9 +113,9 @@ class StatisticsRestController(
|
|||||||
@GetMapping(path = ["requestpatientstates"])
|
@GetMapping(path = ["requestpatientstates"])
|
||||||
fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
|
fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
|
||||||
val states = if (delete) {
|
val states = if (delete) {
|
||||||
requestService.findPatientUniqueDeleteStates()
|
requestRepository.findPatientUniqueDeleteStates()
|
||||||
} else {
|
} else {
|
||||||
requestService.findPatientUniqueStates()
|
requestRepository.findPatientUniqueStates()
|
||||||
}
|
}
|
||||||
|
|
||||||
return states.map {
|
return states.map {
|
||||||
@ -154,10 +152,6 @@ class StatisticsRestController(
|
|||||||
.build(),
|
.build(),
|
||||||
ServerSentEvent.builder<Any>()
|
ServerSentEvent.builder<Any>()
|
||||||
.event("deleterequestpatientstates").id("none").data(this.requestPatientStates(true))
|
.event("deleterequestpatientstates").id("none").data(this.requestPatientStates(true))
|
||||||
.build(),
|
|
||||||
|
|
||||||
ServerSentEvent.builder<Any>()
|
|
||||||
.event("newrequest").id("none").data("newrequest")
|
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -3,34 +3,14 @@ spring:
|
|||||||
compose:
|
compose:
|
||||||
file: ./dev-compose.yml
|
file: ./dev-compose.yml
|
||||||
|
|
||||||
security:
|
|
||||||
oauth2:
|
|
||||||
client:
|
|
||||||
registration:
|
|
||||||
custom:
|
|
||||||
client-name: App-Dev
|
|
||||||
client-id: app-dev
|
|
||||||
client-secret: very-secret-ae3f7a-5a9f-1190
|
|
||||||
scope:
|
|
||||||
- openid
|
|
||||||
provider:
|
|
||||||
custom:
|
|
||||||
issuer-uri: https://dnpm.dev/auth/realms/intern
|
|
||||||
user-name-attribute: name
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
rest:
|
#rest:
|
||||||
uri: http://localhost:9000/bwhc/etl/api
|
# uri: http://localhost:9000/bwhc/etl/api
|
||||||
#kafka:
|
kafka:
|
||||||
# topic: test
|
topic: test
|
||||||
# response-topic: test_response
|
response-topic: test_response
|
||||||
# servers: localhost:9094
|
servers: localhost:9094
|
||||||
security:
|
|
||||||
admin-user: admin
|
|
||||||
admin-password: "{noop}very-secret"
|
|
||||||
enable-oidc: "true"
|
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8000
|
port: 8000
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,17 +5,3 @@ spring:
|
|||||||
group-id: ${app.kafka.group-id}
|
group-id: ${app.kafka.group-id}
|
||||||
flyway:
|
flyway:
|
||||||
locations: "classpath:db/migration/{vendor}"
|
locations: "classpath:db/migration/{vendor}"
|
||||||
|
|
||||||
web:
|
|
||||||
resources:
|
|
||||||
cache:
|
|
||||||
cachecontrol:
|
|
||||||
max-age: 1d
|
|
||||||
chain:
|
|
||||||
strategy:
|
|
||||||
content:
|
|
||||||
enabled: true
|
|
||||||
paths: /**/*.js,/**/*.css,/**/*.svg,/**/*.jpeg
|
|
||||||
|
|
||||||
server:
|
|
||||||
forward-headers-strategy: framework
|
|
@ -1,8 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS token
|
|
||||||
(
|
|
||||||
id int auto_increment primary key,
|
|
||||||
name varchar(255) not null,
|
|
||||||
username varchar(255) not null unique,
|
|
||||||
password varchar(255) not null,
|
|
||||||
created_at datetime default utc_timestamp() not null
|
|
||||||
);
|
|
@ -1,7 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS user_role
|
|
||||||
(
|
|
||||||
id int auto_increment primary key,
|
|
||||||
username varchar(255) not null unique,
|
|
||||||
role varchar(255) not null,
|
|
||||||
created_at datetime default utc_timestamp() not null
|
|
||||||
);
|
|
@ -1 +0,0 @@
|
|||||||
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;
|
|
@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS token
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
name varchar(255) not null,
|
|
||||||
username varchar(255) not null unique,
|
|
||||||
password varchar(255) not null,
|
|
||||||
created_at timestamp with time zone default now() not null,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
);
|
|
@ -1,8 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS user_role
|
|
||||||
(
|
|
||||||
id serial,
|
|
||||||
username varchar(255) not null unique,
|
|
||||||
role varchar(255) not null,
|
|
||||||
created_at timestamp with time zone default now() not null,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
);
|
|
@ -1 +0,0 @@
|
|||||||
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;
|
|
Before Width: | Height: | Size: 3.0 KiB |
45
src/main/resources/static/echarts.min.js
vendored
Normal file
@ -1,41 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
||||||
|
|
||||||
<svg
|
|
||||||
width="256"
|
|
||||||
height="256"
|
|
||||||
viewBox="0 0 67.733332 67.733335"
|
|
||||||
version="1.1"
|
|
||||||
id="svg5"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<defs
|
|
||||||
id="defs2" />
|
|
||||||
<g
|
|
||||||
id="layer1">
|
|
||||||
<g
|
|
||||||
id="g26002"
|
|
||||||
transform="matrix(1.5,0,0,1.5,-16.933333,-1.8487648)">
|
|
||||||
<path
|
|
||||||
id="path12437"
|
|
||||||
transform="matrix(0.21771408,0,0,0.21771408,73.025692,24.874779)"
|
|
||||||
style="fill:#f59e00;fill-opacity:1"
|
|
||||||
d="m -110.41995,43.223174 -55.55561,-2e-6 -27.7778,-48.1125685 27.77781,-48.1125655 55.5556,3e-6 27.777803,48.1125679 z" />
|
|
||||||
<path
|
|
||||||
id="path13446"
|
|
||||||
transform="matrix(0.21771408,0,0,0.21771408,54.882836,14.399994)"
|
|
||||||
style="fill:#004d6e;fill-opacity:1"
|
|
||||||
d="m -110.41995,43.223174 -55.55561,-2e-6 -27.7778,-48.1125685 27.77781,-48.1125655 55.5556,3e-6 27.777803,48.1125679 z" />
|
|
||||||
<path
|
|
||||||
id="path13448"
|
|
||||||
transform="matrix(0.21771408,0,0,0.21771408,54.882835,35.349561)"
|
|
||||||
style="fill:#706f6f;fill-opacity:1"
|
|
||||||
d="m -110.41995,43.223174 -55.55561,-2e-6 -27.7778,-48.1125685 27.77781,-48.1125655 55.5556,3e-6 27.777803,48.1125679 z" />
|
|
||||||
<path
|
|
||||||
id="path25844"
|
|
||||||
transform="matrix(0.21771408,0,0,0.21771408,60.930454,24.874778)"
|
|
||||||
style="fill:#ffffff;fill-opacity:1"
|
|
||||||
d="m -110.41995,43.223174 -55.55561,-2e-6 -27.7778,-48.1125685 27.77781,-48.1125655 55.5556,3e-6 27.777803,48.1125679 z" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 1.6 KiB |
@ -4,7 +4,7 @@ const dateFormat = new Intl.DateTimeFormat('de-DE', dateFormatOptions);
|
|||||||
const dateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: 'numeric', second: 'numeric' };
|
const dateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: 'numeric', second: 'numeric' };
|
||||||
const dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions);
|
const dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions);
|
||||||
|
|
||||||
const formatTimeElements = () => {
|
window.onload = () => {
|
||||||
Array.from(document.getElementsByTagName('time')).forEach((timeTag) => {
|
Array.from(document.getElementsByTagName('time')).forEach((timeTag) => {
|
||||||
let date = Date.parse(timeTag.getAttribute('datetime'));
|
let date = Date.parse(timeTag.getAttribute('datetime'));
|
||||||
if (! isNaN(date)) {
|
if (! isNaN(date)) {
|
||||||
@ -13,9 +13,6 @@ const formatTimeElements = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('load', formatTimeElements);
|
|
||||||
window.addEventListener('htmx:afterRequest', formatTimeElements);
|
|
||||||
|
|
||||||
function drawPieChart(url, elemId, title, data) {
|
function drawPieChart(url, elemId, title, data) {
|
||||||
if (data) {
|
if (data) {
|
||||||
update(elemId, data);
|
update(elemId, data);
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1,156 +1,44 @@
|
|||||||
:root {
|
|
||||||
--text: #333;
|
|
||||||
--table-border: rgba(16, 24, 40, .1);
|
|
||||||
|
|
||||||
--dark: brightness(.90);
|
|
||||||
|
|
||||||
--bg-blue: rgb(0, 74, 157);
|
|
||||||
--bg-blue-op: rgba(0, 74, 157, .35);
|
|
||||||
|
|
||||||
--bg-green: rgb(0, 128, 0);
|
|
||||||
--bg-green-op: rgba(0, 128, 0, .35);
|
|
||||||
|
|
||||||
|
|
||||||
--bg-yellow: rgb(255, 140, 0);
|
|
||||||
--bg-yellow-op: rgba(255, 140, 0, .35);
|
|
||||||
|
|
||||||
|
|
||||||
--bg-red: rgb(255, 0, 0);
|
|
||||||
--bg-red-op: rgba(255, 0, 0, .35);
|
|
||||||
|
|
||||||
--bg-gray: rgb(112, 128, 144);
|
|
||||||
--bg-gray-op: rgba(112, 128, 144, .35);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
background: linear-gradient(-5deg, var(--bg-blue-op), transparent 10em);
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0 0 5em 0;
|
margin: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
color: var(--text);
|
color: #333;
|
||||||
|
|
||||||
min-height: 100vh;
|
|
||||||
|
|
||||||
background: url(bg.jpeg) no-repeat;
|
|
||||||
background-size: contain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1em 0;
|
background: #d5dad5;
|
||||||
|
height: 3rem;
|
||||||
line-height: 1.5rem;
|
|
||||||
max-width: 1140px;
|
max-width: 1140px;
|
||||||
|
|
||||||
border-bottom: 1px solid var(--table-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a.nav-home {
|
nav a {
|
||||||
float: left;
|
color: #004a8f;
|
||||||
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5em;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
font-size: 2em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.nav-home > img {
|
|
||||||
width: 1.5em;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul {
|
|
||||||
margin: 0 0 0 auto;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li.login {
|
|
||||||
margin: 0 0 0 1em;
|
|
||||||
padding: 0 0 0 2em;
|
|
||||||
border-left: 1px solid var(--table-border);
|
|
||||||
line-height: 3.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li.login a {
|
|
||||||
text-decoration: none;
|
|
||||||
text-transform: none;
|
|
||||||
padding: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav .login .user-name {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li.login > span {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 .5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li.login .user-icon {
|
|
||||||
flex-direction: column;
|
|
||||||
display: inline flex;
|
|
||||||
vertical-align: middle;
|
|
||||||
inline-size: 4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li.login .user-icon img {
|
|
||||||
margin: 0 0 -1em 0;
|
|
||||||
width: 80%;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li.login .user-icon span {
|
|
||||||
padding: 0 .6em;
|
|
||||||
color: white;
|
|
||||||
font-size: .8em;
|
|
||||||
font-weight: bold;
|
|
||||||
border-radius: 4px;
|
|
||||||
line-height: normal;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li.login .user-icon span.guest {
|
|
||||||
background: darkslategray;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li.login .user-icon span.user {
|
|
||||||
background: darkgreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav > ul > li.login .user-icon span.admin {
|
|
||||||
background: darkred;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav li a {
|
|
||||||
color: var(--bg-blue);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
line-height: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav li a:hover {
|
nav a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
nav > ul {
|
||||||
color: var(--bg-blue);
|
margin: 0 3rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav > ul > li {
|
||||||
|
background: #fbfbfb;
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
padding: 2px 1rem;
|
||||||
|
border-left: 1px solid #d5dad5;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav > ul > li:first-of-type {
|
||||||
|
border-left: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumps {
|
.breadcrumps {
|
||||||
@ -169,30 +57,22 @@ a {
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumps ul li + li:before {
|
.breadcrumps ul li+li:before {
|
||||||
padding: .4rem;
|
padding: .4rem;
|
||||||
color: gray;
|
color: gray;
|
||||||
content: "/\00a0";
|
content: "/\00a0";
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumps ul li a {
|
.breadcrumps ul li a {
|
||||||
color: var(--text);
|
color: #333333;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.centered {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 1140px;
|
max-width: 1140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
|
||||||
margin: 3em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
form {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@ -234,139 +114,16 @@ form.samplecode-input input:focus-visible {
|
|||||||
background: lightgreen;
|
background: lightgreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form {
|
|
||||||
width: fit-content;
|
|
||||||
margin: 3em auto;
|
|
||||||
padding: 2em 5em;
|
|
||||||
|
|
||||||
border: 1px solid var(--table-border);
|
|
||||||
border-radius: .5em;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form form {
|
|
||||||
width: 20em;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: grid;
|
|
||||||
grid-gap: .5em;
|
|
||||||
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form img {
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 4em;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userrole-form {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userrole-form form {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
text-align: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form form *,
|
|
||||||
.token-form form * {
|
|
||||||
padding: 0.5em;
|
|
||||||
border: 1px solid var(--table-border);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form form hr,
|
|
||||||
.token-form form hr,
|
|
||||||
.userrole-form form hr {
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form button,
|
|
||||||
.login-form a.btn,
|
|
||||||
.token-form button {
|
|
||||||
margin: 1em 0;
|
|
||||||
background: var(--bg-blue);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userrole-form form select {
|
|
||||||
padding: 0.5em;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
line-height: 1.2rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border {
|
|
||||||
padding: 1.5em;
|
|
||||||
border: 1px solid var(--table-border);
|
|
||||||
border-radius: .5em;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
table, .chart {
|
|
||||||
border: 1px solid var(--table-border);
|
|
||||||
padding: 1.5em;
|
|
||||||
|
|
||||||
border-spacing: 0;
|
|
||||||
border-radius: .5em;
|
|
||||||
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
table {
|
||||||
|
border-top: 1px solid lightgray;
|
||||||
|
border-left: 1px solid lightgray;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.config-table td:first-child {
|
|
||||||
width: 24em;
|
|
||||||
min-width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.config-table td > button:last-of-type {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border > table {
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-control {
|
|
||||||
border-radius: .5em;
|
|
||||||
padding: 1em 2em;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
line-height: 1.75em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-control a {
|
|
||||||
padding: 0 .25em;
|
|
||||||
font-size: 1.75em;
|
|
||||||
color: var(--bg-gray);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-control a[href] {
|
|
||||||
color: var(--bg-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-control span {
|
|
||||||
padding: 0 .5em;
|
|
||||||
vertical-align: text-bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
#samples-table.max {
|
#samples-table.max {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -383,97 +140,43 @@ table.samples {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th {
|
||||||
padding: 0.4rem .2rem;
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
line-height: 2em;
|
td, th {
|
||||||
|
padding: .2rem;
|
||||||
|
|
||||||
|
border-right: 1px solid lightgray;
|
||||||
|
border-bottom: 1px solid lightgray;
|
||||||
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
|
||||||
border-bottom: 1px solid var(--bg-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
td {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
border-bottom: 1px solid var(--bg-gray-op);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:last-of-type > td {
|
td.bg-green, th.bg-green {
|
||||||
border-bottom: none;
|
background: green;
|
||||||
}
|
|
||||||
|
|
||||||
td > small {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
td.patient-id {
|
|
||||||
width: 32em;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
td.bg-blue, th.bg-blue,
|
|
||||||
td.bg-green, th.bg-green,
|
|
||||||
td.bg-yellow, th.bg-yellow,
|
|
||||||
td.bg-red, th.bg-red,
|
|
||||||
td.bg-gray, th.bg-gray
|
|
||||||
{
|
|
||||||
width: 8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
td.bg-blue > small, th.bg-blue > small {
|
|
||||||
background: var(--bg-blue);
|
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 0.4em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td.bg-green > small, th.bg-green > small {
|
td.bg-yellow, th.bg-yellow {
|
||||||
background: var(--bg-green);
|
background: darkorange;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 0.4em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td.bg-yellow > small, th.bg-yellow > small {
|
td.bg-red, th.bg-red {
|
||||||
background: var(--bg-yellow);
|
background: red;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 0.4em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td.bg-red > small, th.bg-red > small {
|
td.bg-gray, th.bg-gray {
|
||||||
background: var(--bg-red);
|
background: slategray;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
td.bg-gray > small, th.bg-gray > small {
|
|
||||||
background: var(--bg-gray);
|
|
||||||
color: white;
|
|
||||||
border-radius: 0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-path {
|
|
||||||
background: var(--bg-gray-op);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-from {
|
|
||||||
background: var(--bg-red-op);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-to {
|
|
||||||
background: var(--bg-green-op);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-path, .bg-from, .bg-to {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td.bg-shaded, th.bg-shaded {
|
td.bg-shaded, th.bg-shaded {
|
||||||
@ -493,6 +196,7 @@ td.clipboard.clipped {
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|
||||||
line-height: 1.2rem;
|
line-height: 1.2rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
border: 0 solid transparent;
|
border: 0 solid transparent;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@ -504,38 +208,38 @@ td.clipboard.clipped {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:active,
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
filter: drop-shadow(0px 1px 1px var(--bg-gray)) var(--dark);
|
filter: drop-shadow(1px 2px 2px gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:active {
|
.btn:active {
|
||||||
|
filter: drop-shadow(1px 1px 2px gray);
|
||||||
translate: 0 1px;
|
translate: 0 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.btn-red {
|
.btn.btn-red {
|
||||||
background: var(--bg-red);
|
background: red;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn.btn-red:hover, .btn.btn-red:active {
|
||||||
|
background: darkred !important;
|
||||||
|
}
|
||||||
|
|
||||||
.btn.btn-blue {
|
.btn.btn-blue {
|
||||||
background: var(--bg-blue);
|
background: slategray;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn.btn-blue:hover, .btn.btn-blue:active {
|
||||||
|
background: darkslategray !important;
|
||||||
|
}
|
||||||
|
|
||||||
.btn.btn-delete:before {
|
.btn.btn-delete:before {
|
||||||
content: '\1F5D1';
|
content: '\1F5D1';
|
||||||
padding: .2rem;
|
padding: .2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled,
|
|
||||||
.btn:disabled {
|
|
||||||
background: slategray !important;
|
|
||||||
color: lightgray;
|
|
||||||
filter: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.inline {
|
input.inline {
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@ -571,140 +275,19 @@ input.inline:focus-visible {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.charts {
|
.chart {
|
||||||
display: grid;
|
padding: 1rem;
|
||||||
grid-gap: 1em;
|
margin: .2rem;
|
||||||
grid-template:
|
|
||||||
"a b" 28em
|
|
||||||
"c c" 28em / 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts > .grid-left {
|
border: 1px solid lightgray;
|
||||||
grid-area: a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts > .grid-right {
|
|
||||||
grid-area: b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts > .grid-full {
|
|
||||||
grid-area: c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-display {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 10em 16em 10em;
|
|
||||||
place-items: center;
|
|
||||||
width: fit-content;
|
|
||||||
margin: 1em auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-display > * {
|
|
||||||
text-align: center;
|
|
||||||
margin: auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-display .connection {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
background: repeating-linear-gradient(to left, white, white 2px, transparent 2px, transparent 8px, white 8px) var(--bg-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-display .connection.available {
|
|
||||||
background: var(--bg-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification {
|
|
||||||
margin: 1em;
|
|
||||||
padding: .5em;
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification.info {
|
width: calc(100% - 2.4rem - 4px);
|
||||||
color: var(--bg-blue);
|
height: 320px;
|
||||||
}
|
|
||||||
|
|
||||||
.notification.success {
|
|
||||||
color: var(--bg-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification.notice {
|
|
||||||
color: var(--bg-yellow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification.error {
|
|
||||||
color: var(--bg-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
padding: 1em;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px 3px 0 0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover,
|
|
||||||
.tab.active {
|
|
||||||
background: var(--bg-gray);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabcontent {
|
|
||||||
border: 2px solid var(--bg-gray);
|
|
||||||
border-radius: 0 .5em .5em .5em;
|
|
||||||
display: none;
|
|
||||||
padding: 1em;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabcontent.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.reload {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
height: 1.2em;
|
|
||||||
width: 1.2em;
|
|
||||||
background: var(--bg-red);
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: .6em;
|
|
||||||
align-content: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-token {
|
|
||||||
padding: 1em;
|
|
||||||
background: var(--bg-green-op);
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-token > pre {
|
|
||||||
margin: 0;
|
|
||||||
border: 1px solid var(--bg-green);
|
|
||||||
padding: .5em;
|
|
||||||
width: max-content;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-token {
|
.chart-50pc {
|
||||||
padding: 1em;
|
width: calc(50% - 2.4rem - 4px);
|
||||||
background: var(--bg-red-op);
|
|
||||||
}
|
|
||||||
|
|
||||||
.issue-message {
|
|
||||||
font-family: monospace;
|
|
||||||
font-weight: bolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
.issue-path {
|
|
||||||
font-family: monospace;
|
|
||||||
line-height: 1rem;
|
|
||||||
}
|
}
|
@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
||||||
<svg width="24" height="24" version="1.1" viewBox="0 0 6.35 6.35" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="matrix(1.2 0 0 1.2 -108.01 -85.977)">
|
|
||||||
<rect x="90.01" y="71.647" width="5.2917" height="5.2917" rx=".96212" fill="#b3b3b3"/>
|
|
||||||
<g transform="matrix(1.6667 0 0 1.6667 -60.888 -47.952)" fill="#fff">
|
|
||||||
<circle cx="92.126" cy="72.802" r=".70556"/>
|
|
||||||
<path d="m91.068 74.598a1.0583 1.0583 0 0 1 1.0583-1.0583 1.0583 1.0583 0 0 1 1.0583 1.0583h-1.0583z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 588 B |
@ -1,125 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de" xmlns:th="http://www.thymeleaf.org">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>ETL-Prozessor</title>
|
|
||||||
<link rel="stylesheet" th:href="@{/style.css}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div th:replace="~{fragments.html :: nav}"></div>
|
|
||||||
<main>
|
|
||||||
<h1>Konfiguration</h1>
|
|
||||||
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab active" onclick="selectTab(this, 'common');">Allgemeine Informationen</button>
|
|
||||||
<button class="tab" onclick="selectTab(this, 'security');">Sicherheit</button>
|
|
||||||
<button class="tab" onclick="selectTab(this, 'transformation');">Transformationen</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="common" class="tabcontent active">
|
|
||||||
<section>
|
|
||||||
<h2>🔧 Allgemeine Konfiguration</h2>
|
|
||||||
<table class="config-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Wert</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Pseudonym erzeugt über</td>
|
|
||||||
<td>[[ ${pseudonymGenerator} ]]</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>MTBFile-Sender</td>
|
|
||||||
<td>[[ ${mtbFileSender} ]]</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td th:if="${mtbFileSender.startsWith('Rest')}">REST-Endpunkt</td>
|
|
||||||
<td th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker und Topics</td>
|
|
||||||
<td>[[ ${mtbFileEndpoint} ]]</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section hx-ext="sse" th:sse-connect="@{/configs/events}">
|
|
||||||
<div th:insert="~{configs/gPasConnectionAvailable.html}" th:hx-get="@{/configs?gPasConnectionAvailable}" hx-trigger="sse:gpas-connection-check">
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section hx-ext="sse" th:sse-connect="@{/configs/events}">
|
|
||||||
<div th:insert="~{configs/outputConnectionAvailable.html}" th:hx-get="@{/configs?outputConnectionAvailable}" hx-trigger="sse:output-connection-check">
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="security" class="tabcontent">
|
|
||||||
<section th:insert="~{configs/tokens.html}">
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section th:insert="~{configs/userroles.html}">
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="transformation" class="tabcontent">
|
|
||||||
<section>
|
|
||||||
<h2><span th:if="${not transformations.isEmpty()}">✅</span><span th:if="${transformations.isEmpty()}">⛔</span> Transformationen</h2>
|
|
||||||
|
|
||||||
<h3>Syntax</h3>
|
|
||||||
Hier einige Beispiele zum Syntax des JSON-Path
|
|
||||||
<ul>
|
|
||||||
<li style="padding: 0.6rem 0;"><span class="bg-path">diagnoses[*].icdO3T.version</span>: Ersetze die ICD-O3T-Version in allen Diagnosen, z.B. zur Version der deutschen Übersetzung</li>
|
|
||||||
<li style="padding: 0.6rem 0;"><span class="bg-path">patient.gender</span>: Ersetze das Geschlecht des Patienten, z.B. in das von bwHC verlangte Format</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Konfigurierte Transformationen</h3>
|
|
||||||
<th:block th:if="${transformations.isEmpty()}">
|
|
||||||
<p>
|
|
||||||
Keine konfigurierten Transformationen.
|
|
||||||
</p>
|
|
||||||
</th:block>
|
|
||||||
<th:block th:if="${not transformations.isEmpty()}">
|
|
||||||
<p>
|
|
||||||
Hier sehen Sie eine Übersicht der konfigurierten Transformationen.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<table class="config-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>JSON-Path</th>
|
|
||||||
<th>Transformation von ⇒ nach</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="transformation : ${transformations}">
|
|
||||||
<td>
|
|
||||||
<span class="bg-path" title="Ersetze Wert(e) an dieser Stelle im MTB-File">[[ ${transformation.path} ]]</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="bg-from" title="Ersetze immer dann, wenn dieser Wert enthalten ist">[[ ${transformation.existingValue} ]]</span>
|
|
||||||
<strong>⇒</strong>
|
|
||||||
<span class="bg-to" title="Ersetze durch diesen Wert">[[ ${transformation.newValue} ]]</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</th:block>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script th:src="@{/scripts.js}"></script>
|
|
||||||
<script th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>
|
|
||||||
<script th:src="@{/webjars/htmx.org/dist/ext/sse.js}"></script>
|
|
||||||
<script>
|
|
||||||
function selectTab(self, elem) {
|
|
||||||
Array.from(document.getElementsByClassName('tab')).forEach(e => e.className = 'tab');
|
|
||||||
self.className = 'tab active';
|
|
||||||
|
|
||||||
Array.from(document.getElementsByClassName('tabcontent')).forEach(e => e.className = 'tabcontent');
|
|
||||||
document.getElementById(elem).className = 'tabcontent active';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,24 +0,0 @@
|
|||||||
<th:block th:if="${gPasConnectionAvailable == null}">
|
|
||||||
<h2><span>🟦</span> gPAS nicht konfiguriert - Patienten-IDs werden intern anonymisiert</h2>
|
|
||||||
</th:block>
|
|
||||||
<th:block th:if="${gPasConnectionAvailable != null}">
|
|
||||||
<h2><span th:if="${gPasConnectionAvailable.available}">✅</span><span th:if="${not(gPasConnectionAvailable.available)}">⚡</span> Verbindung zu gPAS</h2>
|
|
||||||
<div>
|
|
||||||
Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}"></time>
|
|
||||||
|
|
|
||||||
Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}"></time>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Die Verbindung ist aktuell</span>
|
|
||||||
<strong th:if="${gPasConnectionAvailable.available}" style="color: green">verfügbar.</strong>
|
|
||||||
<strong th:if="${not(gPasConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong>
|
|
||||||
</div>
|
|
||||||
<div class="connection-display border">
|
|
||||||
<img th:src="@{/server.png}" alt="ETL-Processor" />
|
|
||||||
<span class="connection" th:classappend="${gPasConnectionAvailable.available ? 'available' : ''}"></span>
|
|
||||||
<img th:src="@{/server.png}" alt="gPAS" />
|
|
||||||
<span>ETL-Processor</span>
|
|
||||||
<span></span>
|
|
||||||
<span>gPAS</span>
|
|
||||||
</div>
|
|
||||||
</th:block>
|
|
@ -1,27 +0,0 @@
|
|||||||
<th:block th:if="${outputConnectionAvailable == null}">
|
|
||||||
<h2><span>🟦</span> Keine Ausgabenkonfiguration</h2>
|
|
||||||
</th:block>
|
|
||||||
<th:block th:if="${outputConnectionAvailable != null}">
|
|
||||||
<h2><span th:if="${outputConnectionAvailable.available}">✅</span><span th:if="${not(outputConnectionAvailable.available)}">⚡</span> MTB-File Verbindung</h2>
|
|
||||||
<div>
|
|
||||||
Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(outputConnectionAvailable.timestamp)}"></time>
|
|
||||||
|
|
|
||||||
Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(outputConnectionAvailable.lastChange)}"></time>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Verbindung über <code>[[ ${mtbFileSender} ]]</code>. Die Verbindung ist aktuell
|
|
||||||
<strong th:if="${outputConnectionAvailable.available}" style="color: green">verfügbar.</strong>
|
|
||||||
<strong th:if="${not(outputConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong>
|
|
||||||
</div>
|
|
||||||
<div class="connection-display border">
|
|
||||||
<img th:src="@{/server.png}" alt="ETL-Processor" />
|
|
||||||
<span class="connection" th:classappend="${outputConnectionAvailable.available ? 'available' : ''}"></span>
|
|
||||||
<img th:if="${mtbFileSender.startsWith('Rest')}" th:src="@{/server.png}" alt="bwHC-Backend" />
|
|
||||||
<img th:if="${mtbFileSender.startsWith('Kafka')}" th:src="@{/kafka.png}" alt="Kafka-Broker" />
|
|
||||||
<span>ETL-Processor</span>
|
|
||||||
<span></span>
|
|
||||||
<span th:if="${mtbFileSender.startsWith('RestBwhc')}">bwHC-Backend</span>
|
|
||||||
<span th:if="${mtbFileSender.startsWith('RestDip')}">DNPM:DIP-Backend</span>
|
|
||||||
<span th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker</span>
|
|
||||||
</div>
|
|
||||||
</th:block>
|
|
@ -1,40 +0,0 @@
|
|||||||
<div th:if="${not tokensEnabled}">
|
|
||||||
<h2><span>⛔</span> Tokens</h2>
|
|
||||||
<p>Die Verwendung von Tokens ist nicht aktiviert.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tokens" th:if="${tokensEnabled}">
|
|
||||||
<h2><span>✅</span> Tokens</h2>
|
|
||||||
<div class="border">
|
|
||||||
<div th:if="${tokens.isEmpty()}">Noch keine Tokens vorhanden.</div>
|
|
||||||
<table th:if="${not tokens.isEmpty()}" class="config-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Erstellt</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="token : ${tokens}">
|
|
||||||
<td>[[ ${token.name} ]]</td>
|
|
||||||
<td>
|
|
||||||
<time th:datetime="${token.createdAt}">[[ ${token.createdAt} ]]</time>
|
|
||||||
<button class="btn btn-red" th:hx-delete="@{/configs/tokens/{id}(id=${token.id})}" hx-target="#tokens">Löschen</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div th:if="${newTokenValue != null and success}" class="new-token">
|
|
||||||
Verwendung über HTTP-Basic. Bitte notieren, wird nicht erneut angezeigt: <pre>[[ ${newTokenValue} ]]</pre>
|
|
||||||
</div>
|
|
||||||
<div th:if="${success != null and not success}" class="no-token">
|
|
||||||
Das Token konnte nicht erzeugt werden. Versuchen Sie einen anderen Namen.
|
|
||||||
</div>
|
|
||||||
<div class="token-form">
|
|
||||||
<form th:hx-post="@{/configs/tokens}" hx-target="#tokens">
|
|
||||||
<input placeholder="Token-Name" name="name" required />
|
|
||||||
<button class="btn">Token Erstellen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,37 +0,0 @@
|
|||||||
<div th:if="${not userRolesEnabled}">
|
|
||||||
<h2><span>⛔</span> Benutzerberechtigungen</h2>
|
|
||||||
<p>Die Verwendung von rollenbasierten Benutzerberechtigungen ist nicht aktiviert.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="userroles" th:if="${userRolesEnabled}">
|
|
||||||
<h2><span>✅</span> Benutzerberechtigungen</h2>
|
|
||||||
<div class="border">
|
|
||||||
<div th:if="${userRoles.isEmpty()}">Noch keine Benutzerberechtigungen vorhanden.</div>
|
|
||||||
<table th:if="${not userRoles.isEmpty()}" class="config-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Benutzername</th>
|
|
||||||
<th>Rolle</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="userRole : ${userRoles}">
|
|
||||||
<td>[[ ${userRole.username} ]]</td>
|
|
||||||
<td>
|
|
||||||
<div class="userrole-form">
|
|
||||||
<form th:hx-put="@{/configs/userroles/{id}(id=${userRole.id})}" hx-target="#userroles">
|
|
||||||
<select name="role" th:disabled="${#authorization.authentication.getName() == userRole.username}">
|
|
||||||
<option th:selected="${userRole.role.value == 'guest'}" value="GUEST">Gast</option>
|
|
||||||
<option th:selected="${userRole.role.value == 'user'}" value="USER">Benutzer</option>
|
|
||||||
<option th:selected="${userRole.role.value == 'admin'}" value="ADMIN">Administrator</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-blue" th:disabled="${#authorization.authentication.getName() == userRole.username}">Übernehmen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-red" th:hx-delete="@{/configs/userroles/{id}(id=${userRole.id})}" hx-target="#userroles" th:disabled="${#authorization.authentication.getName() == userRole.username}">Löschen</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org">
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="stylesheet" th:href="@{/style.css}" />
|
<link rel="stylesheet" th:href="@{/style.css}" />
|
||||||
@ -7,33 +7,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<div th:fragment="nav">
|
<div th:fragment="nav">
|
||||||
<nav>
|
<nav>
|
||||||
<span>
|
|
||||||
<a class="nav-home" th:href="@{/}">
|
|
||||||
<img th:src="@{/icon.svg}" alt="Icon" />
|
|
||||||
<span>ETL-Processor</span>
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><a th:href="@{/}">Übersicht</a></li>
|
<li><a th:href="@{/}">Übersicht</a></li>
|
||||||
<li><a th:href="@{/statistics}">Statistiken</a></li>
|
<li><a th:href="@{/statistics}">Statistiken</a></li>
|
||||||
<li sec:authorize="hasRole('ADMIN')">
|
|
||||||
<a th:href="@{/configs}">Konfiguration</a>
|
|
||||||
</li>
|
|
||||||
<li class="login" sec:authorize="not isAuthenticated()">
|
|
||||||
<a class="btn btn-blue" th:href="@{/login}">Login</a>
|
|
||||||
</li>
|
|
||||||
<li class="login" sec:authorize="isAuthenticated()">
|
|
||||||
<span>
|
|
||||||
<div class="user-icon">
|
|
||||||
<img th:src="@{/user.svg}" alt="User-Image">
|
|
||||||
<span sec:authorize="hasRole('ADMIN')" class="user-role admin">Admin</span>
|
|
||||||
<span sec:authorize="hasRole('USER')" class="user-role user">User</span>
|
|
||||||
<span sec:authorize="hasRole('GUEST')" class="user-role guest">Guest</span>
|
|
||||||
</div>
|
|
||||||
<span class="user-name" sec:authentication="name">?</span>
|
|
||||||
</span>
|
|
||||||
<a class="btn btn-red" th:href="@{/logout}">Abmelden</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org">
|
<html lang="de" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>ETL-Prozessor</title>
|
<title>ETL-Prozessor</title>
|
||||||
@ -9,95 +9,37 @@
|
|||||||
<div th:replace="~{fragments.html :: nav}"></div>
|
<div th:replace="~{fragments.html :: nav}"></div>
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
<h1>Alle Anfragen<a id="reload-notify" class="reload" title="Neue Anfragen" th:href="@{/}">⟳</a></h1>
|
<h1>Letzte Anfragen</h1>
|
||||||
|
|
||||||
<div>
|
<table>
|
||||||
<h2 th:if="${patientPseudonym != null}">
|
<thead>
|
||||||
Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientPseudonym}">***</span>
|
<tr>
|
||||||
<a class="btn btn-blue" th:if="${patientPseudonym != null}" th:href="@{/}">Alle anzeigen</a>
|
<th>Status</th>
|
||||||
</h2>
|
<th>Typ</th>
|
||||||
</div>
|
<th>ID</th>
|
||||||
|
<th>Datum</th>
|
||||||
<div class="border" th:if="${requests.totalElements == 0}">
|
<th>Patienten-ID</th>
|
||||||
<div class="notification info">Noch keine Anfragen eingegangen</div>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
|
<tbody>
|
||||||
<div class="border" th:if="${requests.totalElements > 0}">
|
<tr th:each="request : ${requests}">
|
||||||
<div th:if="${patientPseudonym == null}" class="page-control">
|
<td th:if="${request.status.value.contains('success')}" class="bg-green"><small>[[ ${request.status} ]]</small></td>
|
||||||
<a id="first-page-link" th:href="@{/(page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a>
|
<td th:if="${request.status.value.contains('warning')}" class="bg-yellow"><small>[[ ${request.status} ]]</small></td>
|
||||||
<a id="prev-page-link" th:href="@{/(page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a>
|
<td th:if="${request.status.value.contains('error')}" class="bg-red"><small>[[ ${request.status} ]]</small></td>
|
||||||
<span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
|
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
||||||
<a id="next-page-link" th:href="@{/(page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">→</a><a th:if="${requests.isLast()}">→</a>
|
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
||||||
<a id="last-page-link" th:href="@{/(page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">⇥</a><a th:if="${requests.isLast()}">⇥</a>
|
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
|
||||||
</div>
|
<td th:if="not ${request.report}">[[ ${request.uuid} ]]</td>
|
||||||
<div th:if="${patientPseudonym != null}" class="page-control">
|
<td th:if="${request.report}">
|
||||||
<a id="first-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a>
|
<a th:href="@{/report/{id}(id=${request.uuid})}">[[ ${request.uuid} ]]</a>
|
||||||
<a id="prev-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a>
|
</td>
|
||||||
<span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
|
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
|
||||||
<a id="next-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">→</a><a th:if="${requests.isLast()}">→</a>
|
<td>[[ ${request.patientId} ]]</td>
|
||||||
<a id="last-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">⇥</a><a th:if="${requests.isLast()}">⇥</a>
|
</tr>
|
||||||
</div>
|
</tbody>
|
||||||
<table class="paged">
|
</table>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Datum</th>
|
|
||||||
<th>Patienten-ID</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="request : ${requests}">
|
|
||||||
<td th:if="${request.status.value.contains('success')}" class="bg-green"><small>[[ ${request.status} ]]</small></td>
|
|
||||||
<td th:if="${request.status.value.contains('warning')}" class="bg-yellow"><small>[[ ${request.status} ]]</small></td>
|
|
||||||
<td th:if="${request.status.value.contains('error')}" class="bg-red"><small>[[ ${request.status} ]]</small></td>
|
|
||||||
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
|
||||||
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
|
||||||
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
|
|
||||||
<td th:if="not ${request.report}">[[ ${request.uuid} ]]</td>
|
|
||||||
<td th:if="${request.report}">
|
|
||||||
<a th:href="@{/report/{id}(id=${request.uuid})}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">[[ ${request.uuid} ]]</a>
|
|
||||||
<th:block sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">[[ ${request.uuid} ]]</th:block>
|
|
||||||
</td>
|
|
||||||
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
|
|
||||||
<td class="patient-id" th:if="${patientPseudonym != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
|
|
||||||
[[ ${request.patientPseudonym} ]]
|
|
||||||
</td>
|
|
||||||
<td class="patient-id" th:if="${patientPseudonym == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
|
|
||||||
<a th:href="@{/patient/{pid}(pid=${request.patientPseudonym})}">[[ ${request.patientPseudonym} ]]</a>
|
|
||||||
</td>
|
|
||||||
<td class="patient-id" sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">***</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
<script th:src="@{/scripts.js}"></script>
|
<script th:src="@{/scripts.js}"></script>
|
||||||
<script>
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
let keyBindings = {
|
|
||||||
'w': 'first-page-link',
|
|
||||||
'a': 'prev-page-link',
|
|
||||||
'd': 'next-page-link',
|
|
||||||
's': 'last-page-link'
|
|
||||||
};
|
|
||||||
window.onkeydown = (event) => {
|
|
||||||
for (const [key, elemId] of Object.entries(keyBindings)) {
|
|
||||||
if (event.key === key && document.getElementById(elemId)) {
|
|
||||||
document.getElementById(elemId).style.background = 'yellow';
|
|
||||||
document.getElementById(elemId).click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventSource = new EventSource('statistics/events');
|
|
||||||
eventSource.addEventListener('newrequest', event => {
|
|
||||||
console.log(event);
|
|
||||||
document.getElementById('reload-notify').style.display = 'inline-flex';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -1,27 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de" xmlns:th="http://www.thymeleaf.org">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>ETL-Prozessor</title>
|
|
||||||
<link rel="stylesheet" th:href="@{/style.css}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div th:replace="~{fragments.html :: nav}"></div>
|
|
||||||
<main>
|
|
||||||
<div class="login-form">
|
|
||||||
<img th:src="@{/user.svg}" alt="user-logo" />
|
|
||||||
<h2 class="centered">Anmelden</h2>
|
|
||||||
<div class="centered notification error" th:if="${param.error}">Anmeldung nicht erfolgreich</div>
|
|
||||||
<div class="centered notification notice" th:if="${param.expired}">Sitzung abgelaufen oder von einem Administrator beendet.</div>
|
|
||||||
<div class="centered notification success" th:if="${param.logout}">Sie haben sich abgemeldet</div>
|
|
||||||
<form method="post" th:action="@{/login}">
|
|
||||||
<input type="text" id="username" name="username" class="form-control" placeholder="Username" required="" autofocus="" />
|
|
||||||
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required="" />
|
|
||||||
<button class="btn" type="submit">Anmelden</button>
|
|
||||||
<hr th:if="${not oidcLogins.isEmpty()}" />
|
|
||||||
<a th:each="oidcLogin : ${oidcLogins}" class="btn" th:href="@{/oauth2/authorization/{provider}(provider=${oidcLogin.component1()})}">OIDC Login - [[ ${oidcLogin.component2()} ]]</a>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org">
|
<html lang="de" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>ETL-Prozessor</title>
|
<title>ETL-Prozessor</title>
|
||||||
@ -15,7 +15,6 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Typ</th>
|
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Datum</th>
|
<th>Datum</th>
|
||||||
<th>Patienten-ID</th>
|
<th>Patienten-ID</th>
|
||||||
@ -28,39 +27,27 @@
|
|||||||
<td th:if="${request.status.value == 'error'}" class="bg-red"><small>[[ ${request.status} ]]</small></td>
|
<td th:if="${request.status.value == 'error'}" class="bg-red"><small>[[ ${request.status} ]]</small></td>
|
||||||
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
||||||
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
||||||
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
|
|
||||||
<td>[[ ${request.uuid} ]]</td>
|
<td>[[ ${request.uuid} ]]</td>
|
||||||
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
|
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
|
||||||
<td class="patient-id" sec:authorize="authenticated">[[ ${request.patientPseudonym} ]]</td>
|
<td>[[ ${request.patientId} ]]</td>
|
||||||
<td class="patient-id" sec:authorize="not authenticated">***</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2 th:text="${request.report.description}"></h2>
|
<h2 th:text="${request.report.description}"></h2>
|
||||||
|
|
||||||
<p th:if="${issues.isEmpty()}">
|
<table th:if="not ${issues.isEmpty()}">
|
||||||
Keine weiteren Angaben.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<table th:if="${not issues.isEmpty()}">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Schweregrad</th>
|
<th>Schweregrad</th>
|
||||||
<th>Beschreibung und Pfad</th>
|
<th>Beschreibung</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="issue : ${issues}">
|
<tr th:each="issue : ${issues}">
|
||||||
<td th:if="${issue.severity.value == 'info'}" class="bg-blue"><small>[[ ${issue.severity} ]]</small></td>
|
|
||||||
<td th:if="${issue.severity.value == 'warning'}" class="bg-yellow"><small>[[ ${issue.severity} ]]</small></td>
|
<td th:if="${issue.severity.value == 'warning'}" class="bg-yellow"><small>[[ ${issue.severity} ]]</small></td>
|
||||||
<td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
|
<td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
|
||||||
<td th:if="${issue.severity.value == 'fatal'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
|
<td>[[ ${issue.message} ]]</td>
|
||||||
<td>
|
|
||||||
<div class="issue-message">[[ ${issue.message} ]]</div>
|
|
||||||
<div class="issue-path" th:if="${issue.path.isPresent()}">[[ ${issue.path.get()} ]]</div>
|
|
||||||
<div class="issue-path" th:if="${issue.path.isEmpty()}"><i>Keine Angabe</i></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -13,32 +13,28 @@
|
|||||||
Hier sehen Sie eine Übersicht über eingegangene Anfragen.
|
Hier sehen Sie eine Übersicht über eingegangene Anfragen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section>
|
<h2>MTB-File-Anfragen</h2>
|
||||||
<h2>MTB-File-Anfragen</h2>
|
<p>
|
||||||
<p>
|
Anfragen zur Aktualisierung von Patientendaten durch Übermittlung eines MTB-Files.
|
||||||
Anfragen zur Aktualisierung von Patientendaten durch Übermittlung eines MTB-Files.
|
</p>
|
||||||
</p>
|
<div>
|
||||||
<div class="charts">
|
<div id="piechart1" class="chart chart-50pc"></div>
|
||||||
<div id="piechart1" class="chart grid-left"></div>
|
<div id="piechart2" class="chart chart-50pc"></div>
|
||||||
<div id="piechart2" class="chart grid-right"></div>
|
</div>
|
||||||
<div id="barchart" class="chart grid-full"></div>
|
<div id="barchart" class="chart"></div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<h2>Löschanfragen</h2>
|
||||||
<h2>Löschanfragen</h2>
|
<p>
|
||||||
<p>
|
Anfragen zur Löschung von Patientendaten, wenn kein Consent vorliegt.
|
||||||
Anfragen zur Löschung von Patientendaten, wenn kein Consent vorliegt.
|
</p>
|
||||||
</p>
|
<div>
|
||||||
<div class="charts">
|
<div id="piechartdel1" class="chart chart-50pc"></div>
|
||||||
<div id="piechartdel1" class="chart grid-left"></div>
|
<div id="piechartdel2" class="chart chart-50pc"></div>
|
||||||
<div id="piechartdel2" class="chart grid-right"></div>
|
</div>
|
||||||
<div id="barchartdel" class="chart grid-full"></div>
|
<div id="barchartdel" class="chart"></div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
<script th:src="@{/webjars/echarts/dist/echarts.min.js}"></script>
|
<script th:src="@{/echarts.min.js}"></script>
|
||||||
<script th:src="@{/scripts.js}"></script>
|
<script th:src="@{/scripts.js}"></script>
|
||||||
<script>
|
<script>
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* 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
|
|
@ -1,184 +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.input
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import de.ukw.ccc.bwhc.dto.Consent
|
|
||||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
|
||||||
import de.ukw.ccc.bwhc.dto.Patient
|
|
||||||
import dev.dnpm.etl.processor.CustomMediaType
|
|
||||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
|
||||||
import org.apache.kafka.clients.consumer.ConsumerRecord
|
|
||||||
import org.apache.kafka.common.header.internals.RecordHeader
|
|
||||||
import org.apache.kafka.common.header.internals.RecordHeaders
|
|
||||||
import org.apache.kafka.common.record.TimestampType
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.Mock
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.mockito.kotlin.any
|
|
||||||
import org.mockito.kotlin.anyValueClass
|
|
||||||
import org.mockito.kotlin.times
|
|
||||||
import org.mockito.kotlin.verify
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension::class)
|
|
||||||
class KafkaInputListenerTest {
|
|
||||||
|
|
||||||
private lateinit var requestProcessor: RequestProcessor
|
|
||||||
private lateinit var objectMapper: ObjectMapper
|
|
||||||
private lateinit var kafkaInputListener: KafkaInputListener
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Mock requestProcessor: RequestProcessor
|
|
||||||
) {
|
|
||||||
this.requestProcessor = requestProcessor
|
|
||||||
this.objectMapper = ObjectMapper()
|
|
||||||
|
|
||||||
this.kafkaInputListener = KafkaInputListener(requestProcessor, objectMapper)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessMtbFileRequest() {
|
|
||||||
val mtbFile = MtbFile.builder()
|
|
||||||
.withPatient(Patient.builder().withId("DUMMY_12345678").build())
|
|
||||||
.withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
kafkaInputListener.onMessage(
|
|
||||||
ConsumerRecord(
|
|
||||||
"testtopic",
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
"",
|
|
||||||
this.objectMapper.writeValueAsString(mtbFile)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessDeleteRequest() {
|
|
||||||
val mtbFile = MtbFile.builder()
|
|
||||||
.withPatient(Patient.builder().withId("DUMMY_12345678").build())
|
|
||||||
.withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
kafkaInputListener.onMessage(
|
|
||||||
ConsumerRecord(
|
|
||||||
"testtopic",
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
"",
|
|
||||||
this.objectMapper.writeValueAsString(mtbFile)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessMtbFileRequestWithExistingRequestId() {
|
|
||||||
val mtbFile = MtbFile.builder()
|
|
||||||
.withPatient(Patient.builder().withId("DUMMY_12345678").build())
|
|
||||||
.withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray())))
|
|
||||||
kafkaInputListener.onMessage(
|
|
||||||
ConsumerRecord(
|
|
||||||
"testtopic",
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
-1L,
|
|
||||||
TimestampType.NO_TIMESTAMP_TYPE,
|
|
||||||
-1,
|
|
||||||
-1,
|
|
||||||
"",
|
|
||||||
this.objectMapper.writeValueAsString(mtbFile),
|
|
||||||
headers,
|
|
||||||
Optional.empty()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>(), anyValueClass())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessDeleteRequestWithExistingRequestId() {
|
|
||||||
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())))
|
|
||||||
kafkaInputListener.onMessage(
|
|
||||||
ConsumerRecord(
|
|
||||||
"testtopic",
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
-1L,
|
|
||||||
TimestampType.NO_TIMESTAMP_TYPE,
|
|
||||||
-1,
|
|
||||||
-1,
|
|
||||||
"",
|
|
||||||
this.objectMapper.writeValueAsString(mtbFile),
|
|
||||||
headers,
|
|
||||||
Optional.empty()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
verify(requestProcessor, times(1)).processDeletion(anyValueClass(), anyValueClass())
|
|
||||||
}
|
|
||||||
|
|
||||||
@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())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,220 +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.input
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import de.ukw.ccc.bwhc.dto.*
|
|
||||||
import dev.dnpm.etl.processor.CustomMediaType
|
|
||||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
|
||||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.Mock
|
|
||||||
import org.mockito.Mockito.times
|
|
||||||
import org.mockito.Mockito.verify
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.mockito.kotlin.any
|
|
||||||
import org.mockito.kotlin.anyValueClass
|
|
||||||
import org.springframework.core.io.ClassPathResource
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
|
||||||
import org.springframework.test.web.servlet.delete
|
|
||||||
import org.springframework.test.web.servlet.post
|
|
||||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension::class)
|
|
||||||
class MtbFileRestControllerTest {
|
|
||||||
|
|
||||||
private val objectMapper = ObjectMapper()
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class BwhcRequests {
|
|
||||||
|
|
||||||
private lateinit var mockMvc: MockMvc
|
|
||||||
|
|
||||||
private lateinit var requestProcessor: RequestProcessor
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Mock requestProcessor: RequestProcessor
|
|
||||||
) {
|
|
||||||
this.requestProcessor = requestProcessor
|
|
||||||
val controller = MtbFileRestController(requestProcessor)
|
|
||||||
this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessPostRequest() {
|
|
||||||
mockMvc.post("/mtbfile") {
|
|
||||||
content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE))
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
}.andExpect {
|
|
||||||
status {
|
|
||||||
isAccepted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessPostRequestWithRejectedConsent() {
|
|
||||||
mockMvc.post("/mtbfile") {
|
|
||||||
content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED))
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
}.andExpect {
|
|
||||||
status {
|
|
||||||
isAccepted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessDeleteRequest() {
|
|
||||||
mockMvc.delete("/mtbfile/TEST_12345678").andExpect {
|
|
||||||
status {
|
|
||||||
isAccepted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class BwhcRequestsWithAlias {
|
|
||||||
|
|
||||||
private lateinit var mockMvc: MockMvc
|
|
||||||
|
|
||||||
private lateinit var requestProcessor: RequestProcessor
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Mock requestProcessor: RequestProcessor
|
|
||||||
) {
|
|
||||||
this.requestProcessor = requestProcessor
|
|
||||||
val controller = MtbFileRestController(requestProcessor)
|
|
||||||
this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessPostRequest() {
|
|
||||||
mockMvc.post("/mtb") {
|
|
||||||
content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE))
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
}.andExpect {
|
|
||||||
status {
|
|
||||||
isAccepted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessPostRequestWithRejectedConsent() {
|
|
||||||
mockMvc.post("/mtb") {
|
|
||||||
content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED))
|
|
||||||
contentType = MediaType.APPLICATION_JSON
|
|
||||||
}.andExpect {
|
|
||||||
status {
|
|
||||||
isAccepted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldProcessDeleteRequest() {
|
|
||||||
mockMvc.delete("/mtb/TEST_12345678").andExpect {
|
|
||||||
status {
|
|
||||||
isAccepted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 {
|
|
||||||
fun bwhcMtbFileContent(consentStatus: Consent.Status) = MtbFile.builder()
|
|
||||||
.withPatient(
|
|
||||||
Patient.builder()
|
|
||||||
.withId("TEST_12345678")
|
|
||||||
.withBirthDate("2000-08-08")
|
|
||||||
.withGender(Patient.Gender.MALE)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withConsent(
|
|
||||||
Consent.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withStatus(consentStatus)
|
|
||||||
.withPatient("TEST_12345678")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withEpisode(
|
|
||||||
Episode.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withPatient("TEST_12345678")
|
|
||||||
.withPeriod(PeriodStart("2023-08-08"))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,260 +21,105 @@ 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.config.KafkaTargetProperties
|
||||||
import dev.dnpm.etl.processor.CustomMediaType
|
|
||||||
import dev.dnpm.etl.processor.PatientPseudonym
|
|
||||||
import dev.dnpm.etl.processor.RequestId
|
|
||||||
import dev.dnpm.etl.processor.config.KafkaProperties
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
import dev.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.support.RetryTemplateBuilder
|
|
||||||
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(
|
||||||
@BeforeEach
|
@Mock kafkaTemplate: KafkaTemplate<String, String>
|
||||||
fun setup(
|
) {
|
||||||
@Mock kafkaTemplate: KafkaTemplate<String, String>
|
val kafkaTargetProperties = KafkaTargetProperties("testtopic")
|
||||||
) {
|
this.objectMapper = ObjectMapper()
|
||||||
val kafkaProperties = KafkaProperties("testtopic")
|
this.kafkaTemplate = kafkaTemplate
|
||||||
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, kafkaTargetProperties, 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("TestID", 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("TestID", "PID"))
|
||||||
|
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("TestID", 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\", \"eid\": \"1\"}")
|
||||||
|
assertThat(captor.secondValue).isNotNull
|
||||||
|
assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData("TestID", Consent.Status.ACTIVE)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldSendDeleteRequestWithCorrectKeyAndBody() {
|
||||||
|
doAnswer {
|
||||||
|
completedFuture(SendResult<String, String>(null, null))
|
||||||
|
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
|
||||||
|
|
||||||
|
kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
|
||||||
|
|
||||||
|
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("TestID", Consent.Status.REJECTED)))
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val TEST_REQUEST_ID = RequestId("TestId")
|
fun mtbFile(consentStatus: Consent.Status): MtbFile {
|
||||||
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
|
|
||||||
|
|
||||||
fun bwhcV1MtbFile(consentStatus: Consent.Status): MtbFile {
|
|
||||||
return if (consentStatus == Consent.Status.ACTIVE) {
|
return if (consentStatus == Consent.Status.ACTIVE) {
|
||||||
MtbFile.builder()
|
MtbFile.builder()
|
||||||
.withPatient(
|
.withPatient(
|
||||||
@ -309,31 +154,8 @@ class KafkaMtbFileSenderTest {
|
|||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dnpmV2MtbFile(): Mtb = Mtb.builder()
|
fun kafkaRecordData(requestId: String, consentStatus: Consent.Status): KafkaMtbFileSender.Data {
|
||||||
.withPatient(
|
return KafkaMtbFileSender.Data(requestId, mtbFile(consentStatus))
|
||||||
dev.pcvolkmer.mv64e.mtb.Patient.builder()
|
|
||||||
.withId("PID")
|
|
||||||
.withBirthDate("2000-08-08")
|
|
||||||
.withGender(CodingGender.builder().withCode(CodingGender.Code.MALE).build())
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withEpisodesOfCare(
|
|
||||||
listOf(
|
|
||||||
MTBEpisodeOfCare.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withPatient(Reference("PID"))
|
|
||||||
.withPeriod(PeriodDate.builder().withStart("2023-08-08").build())
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
fun bwhcV1kafkaRecordData(requestId: RequestId, consentStatus: Consent.Status): MtbRequest {
|
|
||||||
return BwhcV1MtbFileRequest(requestId, bwhcV1MtbFile(consentStatus))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dnmpV2kafkaRecordData(requestId: RequestId): MtbRequest {
|
|
||||||
return DnpmV2MtbFileRequest(requestId, dnpmV2MtbFile())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TestData(val requestStatus: RequestStatus, val exception: Throwable? = null)
|
data class TestData(val requestStatus: RequestStatus, val exception: Throwable? = null)
|
||||||
|
@ -1,313 +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 com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
|
||||||
import de.ukw.ccc.bwhc.dto.*
|
|
||||||
import dev.dnpm.etl.processor.PatientPseudonym
|
|
||||||
import dev.dnpm.etl.processor.RequestId
|
|
||||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest
|
|
||||||
import org.junit.jupiter.params.provider.MethodSource
|
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.HttpMethod
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.retry.policy.SimpleRetryPolicy
|
|
||||||
import org.springframework.retry.support.RetryTemplateBuilder
|
|
||||||
import org.springframework.test.web.client.ExpectedCount
|
|
||||||
import org.springframework.test.web.client.MockRestServiceServer
|
|
||||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.*
|
|
||||||
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
|
|
||||||
class RestBwhcMtbFileSenderTest {
|
|
||||||
|
|
||||||
private lateinit var mockRestServiceServer: MockRestServiceServer
|
|
||||||
|
|
||||||
private lateinit var restMtbFileSender: RestMtbFileSender
|
|
||||||
|
|
||||||
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
val restTemplate = RestTemplate()
|
|
||||||
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null)
|
|
||||||
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
|
|
||||||
|
|
||||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
|
||||||
|
|
||||||
this.restMtbFileSender =
|
|
||||||
RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource("deleteRequestWithResponseSource")
|
|
||||||
fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
|
|
||||||
this.mockRestServiceServer
|
|
||||||
.expect(method(HttpMethod.DELETE))
|
|
||||||
.andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}"))
|
|
||||||
.andRespond {
|
|
||||||
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
|
|
||||||
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
|
|
||||||
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource("mtbFileRequestWithResponseSource")
|
|
||||||
fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) {
|
|
||||||
this.mockRestServiceServer
|
|
||||||
.expect(method(HttpMethod.POST))
|
|
||||||
.andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile"))
|
|
||||||
.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, mtbFile))
|
|
||||||
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
|
|
||||||
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource("mtbFileRequestWithResponseSource")
|
|
||||||
fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) {
|
|
||||||
val restTemplate = RestTemplate()
|
|
||||||
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null)
|
|
||||||
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
|
|
||||||
|
|
||||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
|
||||||
this.restMtbFileSender =
|
|
||||||
RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
|
||||||
|
|
||||||
val expectedCount = when (requestWithResponse.httpStatus) {
|
|
||||||
// OK - No Retry
|
|
||||||
HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1)
|
|
||||||
// Request failed - Retry max 3 times
|
|
||||||
else -> ExpectedCount.max(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mockRestServiceServer
|
|
||||||
.expect(expectedCount, method(HttpMethod.POST))
|
|
||||||
.andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile"))
|
|
||||||
.andRespond {
|
|
||||||
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile))
|
|
||||||
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
|
|
||||||
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource("deleteRequestWithResponseSource")
|
|
||||||
fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) {
|
|
||||||
val restTemplate = RestTemplate()
|
|
||||||
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null)
|
|
||||||
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
|
|
||||||
|
|
||||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
|
||||||
this.restMtbFileSender =
|
|
||||||
RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
|
||||||
|
|
||||||
val expectedCount = when (requestWithResponse.httpStatus) {
|
|
||||||
// OK - No Retry
|
|
||||||
HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1)
|
|
||||||
// Request failed - Retry max 3 times
|
|
||||||
else -> ExpectedCount.max(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mockRestServiceServer
|
|
||||||
.expect(expectedCount, method(HttpMethod.DELETE))
|
|
||||||
.andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}"))
|
|
||||||
.andRespond {
|
|
||||||
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
|
|
||||||
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
|
|
||||||
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
data class RequestWithResponse(
|
|
||||||
val httpStatus: HttpStatus,
|
|
||||||
val body: String,
|
|
||||||
val response: MtbFileSender.Response
|
|
||||||
)
|
|
||||||
|
|
||||||
val TEST_REQUEST_ID = RequestId("TestId")
|
|
||||||
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
|
|
||||||
|
|
||||||
val mtbFile: MtbFile = MtbFile.builder()
|
|
||||||
.withPatient(
|
|
||||||
Patient.builder()
|
|
||||||
.withId("PID")
|
|
||||||
.withBirthDate("2000-08-08")
|
|
||||||
.withGender(Patient.Gender.MALE)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withConsent(
|
|
||||||
Consent.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withStatus(Consent.Status.ACTIVE)
|
|
||||||
.withPatient("PID")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withEpisode(
|
|
||||||
Episode.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withPatient("PID")
|
|
||||||
.withPeriod(PeriodStart("2023-08-08"))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synthetic http responses with related request status
|
|
||||||
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun mtbFileRequestWithResponseSource(): Set<RequestWithResponse> {
|
|
||||||
return setOf(
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.OK,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.INFO),
|
|
||||||
MtbFileSender.Response(
|
|
||||||
RequestStatus.SUCCESS,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.INFO)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.CREATED,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.WARNING),
|
|
||||||
MtbFileSender.Response(
|
|
||||||
RequestStatus.WARNING,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.WARNING)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.ERROR),
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR))
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.FATAL),
|
|
||||||
MtbFileSender.Response(
|
|
||||||
RequestStatus.ERROR,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.FATAL)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
// Some more errors not mentioned in documentation
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.NOT_FOUND,
|
|
||||||
ERROR_RESPONSE_BODY,
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
ERROR_RESPONSE_BODY,
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synthetic http responses with related request status
|
|
||||||
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun deleteRequestWithResponseSource(): Set<RequestWithResponse> {
|
|
||||||
return setOf(
|
|
||||||
RequestWithResponse(HttpStatus.OK, "", MtbFileSender.Response(RequestStatus.SUCCESS)),
|
|
||||||
// Some more errors not mentioned in documentation
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.NOT_FOUND,
|
|
||||||
"what????",
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
"what????",
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun responseBodyWithMaxSeverity(severity: ReportService.Severity): String {
|
|
||||||
return when (severity) {
|
|
||||||
ReportService.Severity.INFO -> """
|
|
||||||
{
|
|
||||||
"patient": "PID",
|
|
||||||
"issues": [
|
|
||||||
{ "severity": "info", "message": "Info Message" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
ReportService.Severity.WARNING -> """
|
|
||||||
{
|
|
||||||
"patient": "PID",
|
|
||||||
"issues": [
|
|
||||||
{ "severity": "info", "message": "Info Message" },
|
|
||||||
{ "severity": "warning", "message": "Warning Message" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
ReportService.Severity.ERROR -> """
|
|
||||||
{
|
|
||||||
"patient": "PID",
|
|
||||||
"issues": [
|
|
||||||
{ "severity": "info", "message": "Info Message" },
|
|
||||||
{ "severity": "warning", "message": "Warning Message" },
|
|
||||||
{ "severity": "error", "message": "Error Message" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
ReportService.Severity.FATAL -> """
|
|
||||||
{
|
|
||||||
"patient": "PID",
|
|
||||||
"issues": [
|
|
||||||
{ "severity": "info", "message": "Info Message" },
|
|
||||||
{ "severity": "warning", "message": "Warning Message" },
|
|
||||||
{ "severity": "error", "message": "Error Message" },
|
|
||||||
{ "severity": "fatal", "message": "Fatal Message" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@ -1,405 +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 com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
|
||||||
import de.ukw.ccc.bwhc.dto.*
|
|
||||||
import de.ukw.ccc.bwhc.dto.Patient
|
|
||||||
import dev.dnpm.etl.processor.CustomMediaType
|
|
||||||
import dev.dnpm.etl.processor.PatientPseudonym
|
|
||||||
import dev.dnpm.etl.processor.RequestId
|
|
||||||
import dev.dnpm.etl.processor.config.AppConfigProperties
|
|
||||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
|
||||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
|
||||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
|
||||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
|
||||||
import dev.pcvolkmer.mv64e.mtb.*
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest
|
|
||||||
import org.junit.jupiter.params.provider.MethodSource
|
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.HttpMethod
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.retry.backoff.NoBackOffPolicy
|
|
||||||
import org.springframework.retry.policy.SimpleRetryPolicy
|
|
||||||
import org.springframework.retry.support.RetryTemplateBuilder
|
|
||||||
import org.springframework.test.web.client.ExpectedCount
|
|
||||||
import org.springframework.test.web.client.MockRestServiceServer
|
|
||||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.*
|
|
||||||
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
|
|
||||||
class RestDipMtbFileSenderTest {
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class BwhcV1ContentRequest {
|
|
||||||
|
|
||||||
private lateinit var mockRestServiceServer: MockRestServiceServer
|
|
||||||
|
|
||||||
private lateinit var restMtbFileSender: RestMtbFileSender
|
|
||||||
|
|
||||||
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
val restTemplate = RestTemplate()
|
|
||||||
val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
|
|
||||||
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
|
|
||||||
|
|
||||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
|
||||||
|
|
||||||
this.restMtbFileSender =
|
|
||||||
RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource")
|
|
||||||
fun 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class DnpmV2ContentRequest {
|
|
||||||
|
|
||||||
private lateinit var mockRestServiceServer: MockRestServiceServer
|
|
||||||
|
|
||||||
private lateinit var restMtbFileSender: RestMtbFileSender
|
|
||||||
|
|
||||||
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
val restTemplate = RestTemplate()
|
|
||||||
val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
|
|
||||||
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
|
|
||||||
|
|
||||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
|
||||||
|
|
||||||
this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource")
|
|
||||||
fun shouldReturnExpectedResponseForDnpmV2MtbFilePost(requestWithResponse: RequestWithResponse) {
|
|
||||||
this.mockRestServiceServer
|
|
||||||
.expect(method(HttpMethod.POST))
|
|
||||||
.andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record"))
|
|
||||||
.andExpect(header(HttpHeaders.CONTENT_TYPE, CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE))
|
|
||||||
.andRespond {
|
|
||||||
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = restMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile))
|
|
||||||
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
|
|
||||||
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class DeleteRequest {
|
|
||||||
|
|
||||||
private lateinit var mockRestServiceServer: MockRestServiceServer
|
|
||||||
|
|
||||||
private lateinit var restMtbFileSender: RestMtbFileSender
|
|
||||||
|
|
||||||
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
val restTemplate = RestTemplate()
|
|
||||||
val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
|
|
||||||
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
|
|
||||||
|
|
||||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
|
||||||
|
|
||||||
this.restMtbFileSender =
|
|
||||||
RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#deleteRequestWithResponseSource")
|
|
||||||
fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
|
|
||||||
this.mockRestServiceServer
|
|
||||||
.expect(method(HttpMethod.DELETE))
|
|
||||||
.andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}"))
|
|
||||||
.andRespond {
|
|
||||||
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
|
|
||||||
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
|
|
||||||
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#deleteRequestWithResponseSource")
|
|
||||||
fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) {
|
|
||||||
val restTemplate = RestTemplate()
|
|
||||||
val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
|
|
||||||
val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000"))
|
|
||||||
retryTemplate.setBackOffPolicy(NoBackOffPolicy())
|
|
||||||
|
|
||||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
|
||||||
this.restMtbFileSender =
|
|
||||||
RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
|
||||||
|
|
||||||
val expectedCount = when (requestWithResponse.httpStatus) {
|
|
||||||
// OK - No Retry
|
|
||||||
HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max(
|
|
||||||
1
|
|
||||||
)
|
|
||||||
// Request failed - Retry max 3 times
|
|
||||||
else -> ExpectedCount.max(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mockRestServiceServer
|
|
||||||
.expect(expectedCount, method(HttpMethod.DELETE))
|
|
||||||
.andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}"))
|
|
||||||
.andRespond {
|
|
||||||
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
|
|
||||||
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
|
|
||||||
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
data class RequestWithResponse(
|
|
||||||
val httpStatus: HttpStatus,
|
|
||||||
val body: String,
|
|
||||||
val response: MtbFileSender.Response
|
|
||||||
)
|
|
||||||
|
|
||||||
val TEST_REQUEST_ID = RequestId("TestId")
|
|
||||||
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
|
|
||||||
|
|
||||||
val bwhcV1mtbFile: MtbFile = MtbFile.builder()
|
|
||||||
.withPatient(
|
|
||||||
Patient.builder()
|
|
||||||
.withId("PID")
|
|
||||||
.withBirthDate("2000-08-08")
|
|
||||||
.withGender(Patient.Gender.MALE)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withConsent(
|
|
||||||
Consent.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withStatus(Consent.Status.ACTIVE)
|
|
||||||
.withPatient("PID")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withEpisode(
|
|
||||||
Episode.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withPatient("PID")
|
|
||||||
.withPeriod(PeriodStart("2023-08-08"))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val dnpmV2MtbFile: Mtb = Mtb.builder()
|
|
||||||
.withPatient(
|
|
||||||
dev.pcvolkmer.mv64e.mtb.Patient.builder()
|
|
||||||
.withId("PID")
|
|
||||||
.withBirthDate("2000-08-08")
|
|
||||||
.withGender(CodingGender.builder().withCode(CodingGender.Code.MALE).build())
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withEpisodesOfCare(
|
|
||||||
listOf(
|
|
||||||
MTBEpisodeOfCare.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withPatient(Reference("PID"))
|
|
||||||
.withPeriod(PeriodDate.builder().withStart("2023-08-08").build())
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synthetic http responses with related request status
|
|
||||||
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun mtbFileRequestWithResponseSource(): Set<RequestWithResponse> {
|
|
||||||
return setOf(
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.OK,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.INFO),
|
|
||||||
MtbFileSender.Response(
|
|
||||||
RequestStatus.SUCCESS,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.INFO)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.CREATED,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.WARNING),
|
|
||||||
MtbFileSender.Response(RequestStatus.WARNING, responseBodyWithMaxSeverity(ReportService.Severity.WARNING))
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.ERROR),
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR))
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
|
||||||
responseBodyWithMaxSeverity(ReportService.Severity.ERROR),
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR))
|
|
||||||
),
|
|
||||||
// Some more errors not mentioned in documentation
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.NOT_FOUND,
|
|
||||||
ERROR_RESPONSE_BODY,
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
ERROR_RESPONSE_BODY,
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synthetic http responses with related request status
|
|
||||||
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun deleteRequestWithResponseSource(): Set<RequestWithResponse> {
|
|
||||||
return setOf(
|
|
||||||
RequestWithResponse(HttpStatus.OK, "", MtbFileSender.Response(RequestStatus.SUCCESS)),
|
|
||||||
// Some more errors not mentioned in documentation
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.NOT_FOUND,
|
|
||||||
"what????",
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
|
||||||
),
|
|
||||||
RequestWithResponse(
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
"what????",
|
|
||||||
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun responseBodyWithMaxSeverity(severity: ReportService.Severity): String {
|
|
||||||
return when (severity) {
|
|
||||||
ReportService.Severity.INFO -> """
|
|
||||||
{
|
|
||||||
"patient": "PID",
|
|
||||||
"issues": [
|
|
||||||
{ "severity": "info", "message": "Info Message" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
ReportService.Severity.WARNING -> """
|
|
||||||
{
|
|
||||||
"patient": "PID",
|
|
||||||
"issues": [
|
|
||||||
{ "severity": "info", "message": "Info Message" },
|
|
||||||
{ "severity": "warning", "message": "Warning Message" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
ReportService.Severity.ERROR -> """
|
|
||||||
{
|
|
||||||
"patient": "PID",
|
|
||||||
"issues": [
|
|
||||||
{ "severity": "info", "message": "Info Message" },
|
|
||||||
{ "severity": "warning", "message": "Warning Message" },
|
|
||||||
{ "severity": "error", "message": "Error Message" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
ReportService.Severity.FATAL -> """
|
|
||||||
{
|
|
||||||
"patient": "PID",
|
|
||||||
"issues": [
|
|
||||||
{ "severity": "info", "message": "Info Message" },
|
|
||||||
{ "severity": "warning", "message": "Warning Message" },
|
|
||||||
{ "severity": "error", "message": "Error Message" },
|
|
||||||
{ "severity": "fatal", "message": "Fatal Message" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of ETL-Processor
|
||||||
|
*
|
||||||
|
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.dnpm.etl.processor.output
|
||||||
|
|
||||||
|
import de.ukw.ccc.bwhc.dto.*
|
||||||
|
import dev.dnpm.etl.processor.config.RestTargetProperties
|
||||||
|
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.test.web.client.MockRestServiceServer
|
||||||
|
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
|
||||||
|
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
|
||||||
|
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
|
||||||
|
class RestMtbFileSenderTest {
|
||||||
|
|
||||||
|
private lateinit var mockRestServiceServer: MockRestServiceServer
|
||||||
|
|
||||||
|
private lateinit var restMtbFileSender: RestMtbFileSender
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
val restTemplate = RestTemplate()
|
||||||
|
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile")
|
||||||
|
|
||||||
|
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
||||||
|
|
||||||
|
this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("deleteRequestWithResponseSource")
|
||||||
|
fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
|
||||||
|
this.mockRestServiceServer.expect {
|
||||||
|
method(HttpMethod.DELETE)
|
||||||
|
requestTo("/mtbfile")
|
||||||
|
}.andRespond {
|
||||||
|
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = restMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
|
||||||
|
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
|
||||||
|
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("mtbFileRequestWithResponseSource")
|
||||||
|
fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) {
|
||||||
|
this.mockRestServiceServer.expect {
|
||||||
|
method(HttpMethod.POST)
|
||||||
|
requestTo("/mtbfile")
|
||||||
|
}.andRespond {
|
||||||
|
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile))
|
||||||
|
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
|
||||||
|
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
data class RequestWithResponse(
|
||||||
|
val httpStatus: HttpStatus,
|
||||||
|
val body: String,
|
||||||
|
val response: MtbFileSender.Response
|
||||||
|
)
|
||||||
|
|
||||||
|
private val warningBody = """
|
||||||
|
{
|
||||||
|
"patient_id": "PID",
|
||||||
|
"issues": [
|
||||||
|
{ "severity": "warning", "message": "Something is not right" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val errorBody = """
|
||||||
|
{
|
||||||
|
"patient_id": "PID",
|
||||||
|
"issues": [
|
||||||
|
{ "severity": "error", "message": "Something is very bad" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val mtbFile: MtbFile = MtbFile.builder()
|
||||||
|
.withPatient(
|
||||||
|
Patient.builder()
|
||||||
|
.withId("PID")
|
||||||
|
.withBirthDate("2000-08-08")
|
||||||
|
.withGender(Patient.Gender.MALE)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.withConsent(
|
||||||
|
Consent.builder()
|
||||||
|
.withId("1")
|
||||||
|
.withStatus(Consent.Status.ACTIVE)
|
||||||
|
.withPatient("PID")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.withEpisode(
|
||||||
|
Episode.builder()
|
||||||
|
.withId("1")
|
||||||
|
.withPatient("PID")
|
||||||
|
.withPeriod(PeriodStart("2023-08-08"))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthetic http responses with related request status
|
||||||
|
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun mtbFileRequestWithResponseSource(): Set<RequestWithResponse> {
|
||||||
|
return setOf(
|
||||||
|
RequestWithResponse(HttpStatus.OK, "{}", MtbFileSender.Response(RequestStatus.SUCCESS, "{}")),
|
||||||
|
RequestWithResponse(
|
||||||
|
HttpStatus.CREATED,
|
||||||
|
warningBody,
|
||||||
|
MtbFileSender.Response(RequestStatus.WARNING, warningBody)
|
||||||
|
),
|
||||||
|
RequestWithResponse(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"??",
|
||||||
|
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
||||||
|
),
|
||||||
|
RequestWithResponse(
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
errorBody,
|
||||||
|
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
||||||
|
),
|
||||||
|
// Some more errors not mentioned in documentation
|
||||||
|
RequestWithResponse(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"what????",
|
||||||
|
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
||||||
|
),
|
||||||
|
RequestWithResponse(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"what????",
|
||||||
|
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthetic http responses with related request status
|
||||||
|
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun deleteRequestWithResponseSource(): Set<RequestWithResponse> {
|
||||||
|
return setOf(
|
||||||
|
RequestWithResponse(HttpStatus.OK, "", MtbFileSender.Response(RequestStatus.SUCCESS)),
|
||||||
|
// Some more errors not mentioned in documentation
|
||||||
|
RequestWithResponse(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"what????",
|
||||||
|
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
||||||
|
),
|
||||||
|
RequestWithResponse(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"what????",
|
||||||
|
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -1,281 +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.pseudonym
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import de.ukw.ccc.bwhc.dto.*
|
|
||||||
import dev.pcvolkmer.mv64e.mtb.MTBEpisodeOfCare
|
|
||||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
|
||||||
import dev.pcvolkmer.mv64e.mtb.PeriodDate
|
|
||||||
import dev.pcvolkmer.mv64e.mtb.Reference
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.assertThrows
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.Mock
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.mockito.kotlin.anyValueClass
|
|
||||||
import org.mockito.kotlin.doAnswer
|
|
||||||
import org.mockito.kotlin.whenever
|
|
||||||
import org.springframework.core.io.ClassPathResource
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension::class)
|
|
||||||
class ExtensionsTest {
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class UsingBwhcDatamodel {
|
|
||||||
|
|
||||||
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
|
|
||||||
inner class UsingDnpmV2Datamodel {
|
|
||||||
|
|
||||||
val FAKE_MTB_FILE_PATH = "mv64e-mtb-fake-patient.json"
|
|
||||||
val CLEAN_PATIENT_ID = "63f8fd7b-8127-4f3c-8843-aa9199e21c29"
|
|
||||||
|
|
||||||
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.builder()
|
|
||||||
.withPatient(
|
|
||||||
dev.pcvolkmer.mv64e.mtb.Patient.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withBirthDate("2000-08-08")
|
|
||||||
.withGender(null)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.withEpisodesOfCare(
|
|
||||||
listOf(
|
|
||||||
MTBEpisodeOfCare.builder()
|
|
||||||
.withId("1")
|
|
||||||
.withPatient(Reference("1"))
|
|
||||||
.withPeriod(PeriodDate.builder().withStart("2023-08-08").build())
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.withClaims(null)
|
|
||||||
.withDiagnoses(null)
|
|
||||||
.withCarePlans(null)
|
|
||||||
.withClaimResponses(null)
|
|
||||||
.withHistologyReports(null)
|
|
||||||
.withNgsReports(null)
|
|
||||||
.withResponses(null)
|
|
||||||
.withSpecimens(null)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
mtbFile.pseudonymizeWith(pseudonymizeService)
|
|
||||||
mtbFile.anonymizeContentWith(pseudonymizeService)
|
|
||||||
|
|
||||||
assertThat(mtbFile.episodesOfCare).hasSize(1)
|
|
||||||
assertThat(mtbFile.episodesOfCare.map { it.id }).isNotNull
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -70,13 +70,6 @@ class PseudonymizeServiceTest {
|
|||||||
assertThat(mtbFile.patient.id).isEqualTo("123")
|
assertThat(mtbFile.patient.id).isEqualTo("123")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sanitizeFileName(@Mock generator: GpasPseudonymGenerator) {
|
|
||||||
val result= GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>")
|
|
||||||
|
|
||||||
assertThat(result).isEqualTo("l___a_bs_1_2_3_")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldUsePseudonymPrefixForBuiltin(@Mock generator: AnonymizingGenerator) {
|
fun shouldUsePseudonymPrefixForBuiltin(@Mock generator: AnonymizingGenerator) {
|
||||||
doAnswer {
|
doAnswer {
|
||||||
|
@ -1,153 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* 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.security
|
|
||||||
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.ArgumentMatchers.anyLong
|
|
||||||
import org.mockito.ArgumentMatchers.anyString
|
|
||||||
import org.mockito.Mock
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.mockito.kotlin.*
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
|
||||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
|
||||||
import java.util.*
|
|
||||||
import java.util.function.Consumer
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension::class)
|
|
||||||
class TokenServiceTest {
|
|
||||||
|
|
||||||
private lateinit var userDetailsManager: InMemoryUserDetailsManager
|
|
||||||
private lateinit var passwordEncoder: PasswordEncoder
|
|
||||||
private lateinit var tokenRepository: TokenRepository
|
|
||||||
|
|
||||||
private lateinit var tokenService: TokenService
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Mock userDetailsManager: InMemoryUserDetailsManager,
|
|
||||||
@Mock passwordEncoder: PasswordEncoder,
|
|
||||||
@Mock tokenRepository: TokenRepository
|
|
||||||
) {
|
|
||||||
this.userDetailsManager = userDetailsManager
|
|
||||||
this.passwordEncoder = passwordEncoder
|
|
||||||
this.tokenRepository = tokenRepository
|
|
||||||
|
|
||||||
this.tokenService = TokenService(userDetailsManager, passwordEncoder, tokenRepository)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldEncodePasswordForNewToken() {
|
|
||||||
doAnswer { "{test}verysecret" }.whenever(passwordEncoder).encode(anyString())
|
|
||||||
|
|
||||||
val actual = this.tokenService.addToken("Test Token")
|
|
||||||
|
|
||||||
assertThat(actual).satisfies(
|
|
||||||
Consumer { assertThat(it.isSuccess).isTrue() },
|
|
||||||
Consumer { assertThat(it.getOrNull()).matches("testtoken:[A-Za-z0-9]{48}$") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldContainAlphanumTokenUserPart() {
|
|
||||||
doAnswer { "{test}verysecret" }.whenever(passwordEncoder).encode(anyString())
|
|
||||||
|
|
||||||
val actual = this.tokenService.addToken("Test Token")
|
|
||||||
|
|
||||||
assertThat(actual).satisfies(
|
|
||||||
Consumer { assertThat(it.isSuccess).isTrue() },
|
|
||||||
Consumer { assertThat(it.getOrDefault("")).startsWith("testtoken:") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldNotAllowSameTokenUserPartTwice() {
|
|
||||||
doReturn(true).whenever(userDetailsManager).userExists(anyString())
|
|
||||||
|
|
||||||
val actual = this.tokenService.addToken("Test Token")
|
|
||||||
|
|
||||||
assertThat(actual).satisfies(Consumer { assertThat(it.isFailure).isTrue() })
|
|
||||||
verify(tokenRepository, never()).save(any())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldSaveNewToken() {
|
|
||||||
doAnswer { "{test}verysecret" }.whenever(passwordEncoder).encode(anyString())
|
|
||||||
|
|
||||||
val actual = this.tokenService.addToken("Test Token")
|
|
||||||
|
|
||||||
val captor = argumentCaptor<Token>()
|
|
||||||
verify(tokenRepository, times(1)).save(captor.capture())
|
|
||||||
|
|
||||||
assertThat(actual).satisfies(Consumer { assertThat(it.isSuccess).isTrue() })
|
|
||||||
assertThat(captor.firstValue).satisfies(
|
|
||||||
Consumer { assertThat(it.name).isEqualTo("Test Token") },
|
|
||||||
Consumer { assertThat(it.username).isEqualTo("testtoken") },
|
|
||||||
Consumer { assertThat(it.password).isEqualTo("{test}verysecret") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldDeleteExistingToken() {
|
|
||||||
doAnswer {
|
|
||||||
val id = it.arguments[0] as Long
|
|
||||||
Optional.of(Token(id, "Test Token", "testtoken", "{test}hsdajfgadskjhfgsdkfjg"))
|
|
||||||
}.whenever(tokenRepository).findById(anyLong())
|
|
||||||
|
|
||||||
this.tokenService.deleteToken(42)
|
|
||||||
|
|
||||||
val stringCaptor = argumentCaptor<String>()
|
|
||||||
verify(userDetailsManager, times(1)).deleteUser(stringCaptor.capture())
|
|
||||||
assertThat(stringCaptor.firstValue).isEqualTo("testtoken")
|
|
||||||
|
|
||||||
val tokenCaptor = argumentCaptor<Token>()
|
|
||||||
verify(tokenRepository, times(1)).delete(tokenCaptor.capture())
|
|
||||||
assertThat(tokenCaptor.firstValue.id).isEqualTo(42)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldReturnAllTokensFromRepository() {
|
|
||||||
val expected = listOf(
|
|
||||||
Token(1, "Test Token 1", "testtoken1", "{test}hsdajfgadskjhfgsdkfjg"),
|
|
||||||
Token(2, "Test Token 2", "testtoken2", "{test}asdasdasdasdasdasdasd")
|
|
||||||
)
|
|
||||||
|
|
||||||
doReturn(expected).whenever(tokenRepository).findAll()
|
|
||||||
|
|
||||||
assertThat(tokenService.findAll()).isEqualTo(expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldAddAllTokensFromRepositoryToUserDataManager() {
|
|
||||||
val expected = listOf(
|
|
||||||
Token(1, "Test Token 1", "testtoken1", "{test}hsdajfgadskjhfgsdkfjg"),
|
|
||||||
Token(2, "Test Token 2", "testtoken2", "{test}asdasdasdasdasdasdasd")
|
|
||||||
)
|
|
||||||
|
|
||||||
doReturn(expected).whenever(tokenRepository).findAll()
|
|
||||||
|
|
||||||
tokenService.setup()
|
|
||||||
|
|
||||||
verify(userDetailsManager, times(expected.size)).createUser(any())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,202 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of ETL-Processor
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* 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.security
|
|
||||||
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.Mock
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension
|
|
||||||
import org.mockito.kotlin.*
|
|
||||||
import org.springframework.security.core.session.SessionInformation
|
|
||||||
import org.springframework.security.core.session.SessionRegistry
|
|
||||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken
|
|
||||||
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser
|
|
||||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension::class)
|
|
||||||
class UserRoleServiceTest {
|
|
||||||
|
|
||||||
private lateinit var userRoleRepository: UserRoleRepository
|
|
||||||
private lateinit var sessionRegistry: SessionRegistry
|
|
||||||
|
|
||||||
private lateinit var userRoleService: UserRoleService
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup(
|
|
||||||
@Mock userRoleRepository: UserRoleRepository,
|
|
||||||
@Mock sessionRegistry: SessionRegistry
|
|
||||||
) {
|
|
||||||
this.userRoleRepository = userRoleRepository
|
|
||||||
this.sessionRegistry = sessionRegistry
|
|
||||||
|
|
||||||
this.userRoleService = UserRoleService(userRoleRepository, sessionRegistry)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldDelegateFindAllToRepository() {
|
|
||||||
userRoleService.findAll()
|
|
||||||
|
|
||||||
verify(userRoleRepository, times(1)).findAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class WithExistingUserRole {
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
doAnswer { invocation ->
|
|
||||||
Optional.of(
|
|
||||||
UserRole(invocation.getArgument(0), "patrick.tester", Role.USER)
|
|
||||||
)
|
|
||||||
}.whenever(userRoleRepository).findById(any<Long>())
|
|
||||||
|
|
||||||
doAnswer { _ ->
|
|
||||||
listOf(
|
|
||||||
dummyPrincipal()
|
|
||||||
)
|
|
||||||
}.whenever(sessionRegistry).allPrincipals
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldUpdateUserRole() {
|
|
||||||
userRoleService.updateUserRole(1, Role.ADMIN)
|
|
||||||
|
|
||||||
val userRoleCaptor = argumentCaptor<UserRole>()
|
|
||||||
verify(userRoleRepository, times(1)).save(userRoleCaptor.capture())
|
|
||||||
|
|
||||||
assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L)
|
|
||||||
assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.ADMIN)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldExpireSessionOnUpdate() {
|
|
||||||
val dummySessions = dummySessions()
|
|
||||||
whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn(
|
|
||||||
dummySessions
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(dummySessions.filter { it.isExpired }).hasSize(0)
|
|
||||||
|
|
||||||
userRoleService.updateUserRole(1, Role.ADMIN)
|
|
||||||
|
|
||||||
verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>())
|
|
||||||
|
|
||||||
assertThat(dummySessions.filter { it.isExpired }).hasSize(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldDeleteUserRole() {
|
|
||||||
userRoleService.deleteUserRole(1)
|
|
||||||
|
|
||||||
val userRoleCaptor = argumentCaptor<UserRole>()
|
|
||||||
verify(userRoleRepository, times(1)).delete(userRoleCaptor.capture())
|
|
||||||
|
|
||||||
assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L)
|
|
||||||
assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.USER)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldExpireSessionOnDelete() {
|
|
||||||
val dummySessions = dummySessions()
|
|
||||||
whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn(
|
|
||||||
dummySessions
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(dummySessions.filter { it.isExpired }).hasSize(0)
|
|
||||||
|
|
||||||
userRoleService.deleteUserRole(1)
|
|
||||||
|
|
||||||
verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>())
|
|
||||||
|
|
||||||
assertThat(dummySessions.filter { it.isExpired }).hasSize(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class WithoutExistingUserRole {
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setup() {
|
|
||||||
doAnswer { _ ->
|
|
||||||
Optional.empty<UserRole>()
|
|
||||||
}.whenever(userRoleRepository).findById(any<Long>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldNotUpdateUserRole() {
|
|
||||||
userRoleService.updateUserRole(1, Role.ADMIN)
|
|
||||||
|
|
||||||
verify(userRoleRepository, never()).save(any<UserRole>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldNotExpireSessionOnUpdate() {
|
|
||||||
userRoleService.updateUserRole(1, Role.ADMIN)
|
|
||||||
|
|
||||||
verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldNotDeleteUserRole() {
|
|
||||||
userRoleService.deleteUserRole(1)
|
|
||||||
|
|
||||||
verify(userRoleRepository, never()).delete(any<UserRole>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldNotExpireSessionOnDelete() {
|
|
||||||
userRoleService.deleteUserRole(1)
|
|
||||||
|
|
||||||
verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private fun dummyPrincipal() = DefaultOidcUser(
|
|
||||||
listOf(),
|
|
||||||
OidcIdToken(
|
|
||||||
"anytokenvalue",
|
|
||||||
Instant.now(),
|
|
||||||
Instant.now().plusSeconds(10),
|
|
||||||
mapOf("sub" to "testsub", "preferred_username" to "patrick.tester")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun dummySessions() = listOf(
|
|
||||||
SessionInformation(
|
|
||||||
dummyPrincipal(),
|
|
||||||
"SESSIONID1",
|
|
||||||
Date.from(Instant.now()),
|
|
||||||
),
|
|
||||||
SessionInformation(
|
|
||||||
dummyPrincipal(),
|
|
||||||
"SESSIONID2",
|
|
||||||
Date.from(Instant.now()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|