Compare commits
294 Commits
v0.3.1
...
v0.11.0-rc
Author | SHA1 | Date | |
---|---|---|---|
b7aa187293 | |||
8402462c3b | |||
d3e6aa5821 | |||
eed0972018 | |||
|
3b66f42eb2 | ||
c40fd7f816 | |||
1759729931 | |||
|
7f80224eac | ||
3eb1c79cec | |||
|
be513f305a | ||
2e88157893 | |||
bf898e5c25 | |||
e5693736d8 | |||
|
dfc9de78ce | ||
|
199511e567 | ||
1319be8b3f | |||
1a5737189c | |||
7543785116 | |||
858189aa59 | |||
17f4dc3512 | |||
1dd601e8db | |||
b748603c06 | |||
b939b2bf57 | |||
c6b37fda69 | |||
8e3de6a220 | |||
c5c553f817 | |||
7d97365aea | |||
48b1e62e22 | |||
66cc818755 | |||
9d4786fae3 | |||
b78dc3519b | |||
46015c5b66 | |||
a4d0b73d2b | |||
9307fc0dad | |||
586d388e57 | |||
7ae34719fd | |||
033750eb10 | |||
befeef3153 | |||
98b971d7db | |||
56a63b276e | |||
c0ea5fcd51 | |||
d4fd54f51f | |||
d49671f0d4 | |||
84868dc22c | |||
4ad6c4bd0a | |||
9bdd8ba375 | |||
f027339425 | |||
3c5639708f | |||
639159c677 | |||
38261d6d2c | |||
47ebe46974 | |||
f347653be8 | |||
775a7df1ce | |||
f66b737f11 | |||
3a19212a78 | |||
280fbd445e | |||
91e2cf5ef1 | |||
262c54f2e5 | |||
|
b25e580113 | ||
ff27b7157d | |||
1e652a7856 | |||
74ff9f08a4 | |||
23cc2f365a | |||
53b4cf1a95 | |||
5ce13e962b | |||
3257493b6a | |||
2036077c06 | |||
8ce3aed870 | |||
998989d319 | |||
e95fa2fb12 | |||
1bcc8c13de | |||
2fc3299543 | |||
5575867632 | |||
46ba565c29 | |||
|
6cdbd35e64 | ||
|
d258d9081b | ||
eb49ba611b | |||
efa736f232 | |||
4a7030e85b | |||
464c8b8c1d | |||
3f1bb4f4e2 | |||
370ea87095 | |||
c8f6e6efc8 | |||
c949ec07e5 | |||
87658bfa58 | |||
99efd6c98a | |||
e42d11f125 | |||
6e0ec6b95a | |||
0ff56416dd | |||
3a2f6a2bb6 | |||
3eb9e68786 | |||
59403d1dba | |||
9f5ac664af | |||
5867ed9dd3 | |||
4d6d1879e6 | |||
2a34c0efc9 | |||
0ee00de5aa | |||
baeebdb9b8 | |||
8b194e7212 | |||
070100eba0 | |||
ce1489d9a1 | |||
ca1e73a0b5 | |||
041bf459ef | |||
c922e27758 | |||
4d5c0ce1fb | |||
bb0bbf5a28 | |||
1b4585d601 | |||
dad3ea80ee | |||
01446bdece | |||
43660a4dcb | |||
5320466b6c | |||
263cb02416 | |||
0b37fd7091 | |||
bdee969409 | |||
4c39920afd | |||
5e836c48b0 | |||
fb5a3c062c | |||
8fc0609aa4 | |||
a846a8765a | |||
8645becd82 | |||
011511d5ef | |||
e9839c2731 | |||
86bee9e2cf | |||
f419acb924 | |||
52171e8ebe | |||
a2124ba83d | |||
a046203339 | |||
b40d41ce8c | |||
57de96771c | |||
3bc148f7ea | |||
8e6b1ec799 | |||
8e5f5c73ec | |||
d4f984b138 | |||
24ebbf3b50 | |||
9c6bd64a7e | |||
6567aa803c | |||
e874350712 | |||
94d7b4c4f0 | |||
107429fda7 | |||
26b2f65e67 | |||
e863269a42 | |||
4ab95ef11f | |||
2244ef1b86 | |||
c3ddb387e2 | |||
ae5d8341cc | |||
40b2558943 | |||
9a6a0c6138 | |||
5985327219 | |||
06f9e8ace9 | |||
365a651918 | |||
5fcc24f915 | |||
3bd7239812 | |||
1672ad8640 | |||
710aeb1f18 | |||
06d11790b6 | |||
959f6889d4 | |||
0f5a68660d | |||
b809a2da02 | |||
effffcfc1a | |||
7b3151d227 | |||
26b415f336 | |||
bda3c30a74 | |||
8779600330 | |||
159fb46009 | |||
eabbbfbb68 | |||
4db38ef2f0 | |||
ed6d21e920 | |||
550bee5ad3 | |||
8313420de5 | |||
1651f446fe | |||
bd7dccbd87 | |||
8ae958b8c4 | |||
0f144568e3 | |||
08540e3bd7 | |||
43af1aa103 | |||
056a087065 | |||
a730ce2a53 | |||
12eb1feea6 | |||
af714f7b64 | |||
f47b0b7de4 | |||
d8ba6b67cb | |||
40b89dd4f1 | |||
e3aeee61de | |||
07e59f9b02 | |||
f751d64220 | |||
299bd56d63 | |||
a0c4d1863f | |||
fc1901211d | |||
bed91439db | |||
a8e008000e | |||
a9c771aa99 | |||
256d9d4ff0 | |||
41b87835ca | |||
3654962294 | |||
9382da7101 | |||
67ab0ef2be | |||
69d796dab4 | |||
4bfe7dc698 | |||
0aec5e4479 | |||
b1a83510a6 | |||
6806c4fd69 | |||
b2016df852 | |||
b332f3c5ff | |||
9eb65788e1 | |||
9392bcadc9 | |||
a008641192 | |||
5928d52237 | |||
1eb40b40c9 | |||
feb9f2430c | |||
200c5338ea | |||
5c15ad4518 | |||
0b6decf88d | |||
cfdf41d550 | |||
45c65d53ce | |||
4568f491f5 | |||
952ad8c0cf | |||
3e45bf8494 | |||
46ddaf10f7 | |||
408b121f26 | |||
61e5273158 | |||
50b8f7bbd4 | |||
25f286f73b | |||
50a6d66718 | |||
f5c80f6d81 | |||
7659939d3c | |||
f58d4a76cf | |||
c2dd450579 | |||
a1b62ad754 | |||
59d8744c84 | |||
d2a6ec17ea | |||
550403cc9f | |||
d3a4500568 | |||
2e4fee97a8 | |||
5355eee05c | |||
3e22000541 | |||
8c319197d0 | |||
a31d2b4bcc | |||
67d5fb4c67 | |||
329be65d1a | |||
91fe3d1c23 | |||
f4b86ce2ea | |||
19d0daa442 | |||
cc9811d11d | |||
8ce5b06823 | |||
3cc34fb30b | |||
17e04a3f89 | |||
f71a775e12 | |||
45c83e943b | |||
6dcbfde62e | |||
4cdc419478 | |||
90b529adb4 | |||
a3bc60986b | |||
f5df0b5d22 | |||
972ac745e9 | |||
358373cf70 | |||
27a62321fa | |||
30cf0fd22e | |||
531a8589db | |||
fa89a64ddd | |||
45ad5e8827 | |||
c4eb4d0fe2 | |||
4bc69a353c | |||
9d30f750f7 | |||
a1a252d5a9 | |||
568942bfe5 | |||
15f0432553 | |||
113bf2dd2e | |||
7ac151202a | |||
5d9d47c2df | |||
585468314c | |||
441bff3783 | |||
21959c1698 | |||
8a11e6e85b | |||
5579ad1453 | |||
c2026bdd07 | |||
de6faecb02 | |||
3be8bc53ff | |||
fad2f33fd6 | |||
d88e2973da | |||
af767e4ea6 | |||
f98c970348 | |||
75872a149f | |||
e24ba430a5 | |||
08914a6f86 | |||
104f50afcb | |||
0083e75940 | |||
c892ff2461 | |||
4a9cffbaa5 | |||
8a6f9a6e02 | |||
91f17f6af5 | |||
8d4497bf2c | |||
4ab20a5f16 | |||
167587a473 | |||
e5d80f89b0 |
20
.github/workflows/deploy.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: "Run build and deploy"
|
||||
name: 'Run build and deploy'
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -8,20 +8,20 @@ jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2.4.2
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -30,6 +30,6 @@ jobs:
|
||||
- name: Execute image build and push
|
||||
run: |
|
||||
./gradlew bootBuildImage
|
||||
docker tag ghcr.io/ccc-mf/etl-processor ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }}
|
||||
docker push ghcr.io/ccc-mf/etl-processor
|
||||
docker push ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }}
|
||||
docker tag ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
docker push ghcr.io/${{ github.repository }}
|
||||
docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
16
.github/workflows/test.yml
vendored
@@ -11,14 +11,14 @@ jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2.4.2
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Execute tests
|
||||
run: ./gradlew test
|
||||
@@ -26,14 +26,14 @@ jobs:
|
||||
integrationTests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2.4.2
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Execute integration tests
|
||||
run: ./gradlew integrationTest
|
4
.gitignore
vendored
@@ -5,6 +5,8 @@ build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
bindings/ca-certificates/*.pem
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
@@ -37,3 +39,5 @@ out/
|
||||
.vscode/
|
||||
/dev/gpas*
|
||||
/deploy/.env
|
||||
/dev/gICS*
|
||||
/dev/gPAS*
|
||||
|
479
README.md
@@ -1,118 +1,366 @@
|
||||
# ETL-Processor for bwHC data [](https://github.com/CCC-MF/etl-processor/actions/workflows/test.yml)
|
||||
# ETL-Processor für das MV gem. §64e und DNPM:DIP
|
||||
[](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml)
|
||||
|
||||
Diese Anwendung versendet ein bwHC-MTB-File an das bwHC-Backend und pseudonymisiert die Patienten-ID.
|
||||
Diese Anwendung pseudonymisiert/anonymisiert Daten im DNPM-Datenmodell 2.1 für das Modellvorhaben
|
||||
Genomsequenzierung nach §64e unter Beachtung des Consents und sendet sie an DNPM:DIP.
|
||||
|
||||
### Einordnung innerhalb einer DNPM-ETL-Strecke
|
||||
## Einordnung innerhalb einer DNPM-ETL-Strecke
|
||||
|
||||
Diese Anwendung erlaubt das Entgegennehmen HTTP/REST-Anfragen aus dem Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)**.
|
||||
Diese Anwendung erlaubt das Entgegennehmen von HTTP/REST-Anfragen aus dem Onkostar-Plugin
|
||||
**[mv64e-onkostar-plugin-export](https://github.com/pcvolkmer/mv64e-onkostar-plugin-export)**.
|
||||
|
||||
Der Inhalt einer Anfrage, wenn ein bwHC-MTBFile, wird pseudonymisiert und auf Duplikate geprüft.
|
||||
Der Inhalt einer Anfrage, wenn ein MTB-File, wird pseudonymisiert und auf Duplikate geprüft.
|
||||
Duplikate werden verworfen, Änderungen werden weitergeleitet.
|
||||
|
||||
Löschanfragen werden immer als Löschanfrage an das bwHC-backend weitergeleitet.
|
||||
Löschanfragen werden immer als Löschanfrage an DNPM:DIP weitergeleitet.
|
||||
|
||||
Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt.
|
||||
|
||||

|
||||
|
||||
#### HTTP/REST-Konfiguration
|
||||
### 🔥 Wichtige Änderungen in Version 0.11
|
||||
|
||||
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an das bwHC-Backend gesendet.
|
||||
Ab Version 0.11 wird ausschließlich [DNPM:DIP](https://github.com/dnpm-dip) unterstützt.
|
||||
|
||||
#### Konfiguration für Apache Kafka
|
||||
Zudem wurde der Name des Pakets in **mv64e-etl-processor** geändert.
|
||||
|
||||
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.
|
||||
## Funktionsweise
|
||||
|
||||
### Duplikaterkennung
|
||||
|
||||
Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über den
|
||||
Konfigurationsparameter
|
||||
`APP_DUPLICATION_DETECTION=false` deaktiviert werden.
|
||||
|
||||
### Modelvorhaben genomDE §64e
|
||||
|
||||
#### Vorgangsummern
|
||||
Zusätzlich zur Patienten Identifier Pseudonymisierung müssen Vorgangsummern generiert werden, die
|
||||
jede Übertragung eindeutig identifizieren aber gleichzeitig dem Patienten zugeordnet werden können.
|
||||
Dies lässt sich durch weitere Pseudonyme abbilden, allerdings werden pro Originalwert mehrere
|
||||
Pseudonyme benötigt.
|
||||
Zu diesem Zweck muss in gPas eine **Multi-Pseudonym-Domäne** konfiguriert werden (siehe auch
|
||||
*APP_PSEUDONYMIZE_GPAS_CCDN*).
|
||||
|
||||
**WICHTIG:** Deaktivierte Pseudonymisierung ist nur für Tests nutzbar. Vorgangsummern sind zufällig
|
||||
und werden anschließend verworfen.
|
||||
|
||||
#### Test Betriebsbereitschaft
|
||||
Um die voll Betriebsbereitschaft herzustellen, muss eine erfolgreiche Übertragung mit dem
|
||||
Submission-Typ *Test* erfolgt sein. Über die Umgebungsvariable wird dieser Übertragungsmodus
|
||||
aktiviert. Alle Datensätze mit erteilter Teilnahme am Modelvorhaben werden mit der Test-Submission-Kennung
|
||||
übertragen, unabhängig vom ursprünglichen Wert.
|
||||
|
||||
`APP_GENOM_DE_TEST_SUBMISSION` -> `true` | `false` (falls fehlt, wird `false` angenommen)
|
||||
|
||||
### Datenübermittlung über HTTP/REST
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
## Pseudonymisierung der Patienten-ID
|
||||
## Konfiguration
|
||||
|
||||
### Pseudonymisierung der Patienten-ID
|
||||
|
||||
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.
|
||||
|
||||
* `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Prefix - `UNKNOWN`, wenn nicht gesetzt
|
||||
* `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Präfix - `UNKNOWN`, wenn nicht gesetzt
|
||||
* `APP_PSEUDONYMIZE_GENERATOR`: `BUILDIN` oder `GPAS` - `BUILDIN`, wenn nicht gesetzt
|
||||
|
||||
### Eingebaute Pseudonymisierung
|
||||
**Hinweis**
|
||||
|
||||
Wurde keine oder die Verwendung der eingebauten Pseudonymisierung konfiguriert, so wird für die Patienten-ID der
|
||||
entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des konfigurierten Prefixes
|
||||
als Patienten-Pseudonym verwendet.
|
||||
Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID.
|
||||
Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den
|
||||
aktuellen Kontext nicht
|
||||
vergleichbare IDs bereitzustellen.
|
||||
|
||||
### Pseudonymisierung mit gPAS
|
||||
#### 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.
|
||||
|
||||
#### Pseudonymisierung mit gPAS
|
||||
|
||||
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_TARGET`: gPas Domänenname
|
||||
Ab Version 2025.1 (Multi-Pseudonym Support)
|
||||
|
||||
* `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz REST API (e.g. http://127.0.0.1:9990/ttp-fhir/fhir/gpas)
|
||||
* `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername
|
||||
* `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort
|
||||
* `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`: Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss.
|
||||
* `APP_PSEUDONYMIZE_GPAS_PID_DOMAIN`: gPas Domänenname für Patienten ID
|
||||
* `APP_PSEUDONYMIZE_GPAS_GENOM_DE_TAN_DOMAIN`: gPas Multi-Pseudonym-Domäne für genomDE Vorgangsnummern (
|
||||
Clinical data node)
|
||||
|
||||
## Transformation von Werten
|
||||
### (Externe) Consent-Services
|
||||
|
||||
In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht,
|
||||
der vom bwHC-Backend akzeptiert wird.
|
||||
Consent-Services können konfiguriert werden.
|
||||
|
||||
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.
|
||||
* `APP_CONSENT_SERVICE`: Zu verwendender (externer) Consent-Service:
|
||||
* `NONE`: Verwende Consent-Angaben im MTB-File v1 und ändere diese nicht. Für MTB-File v2 wird
|
||||
die Prüfung übersprungen.
|
||||
* `GICS`: Verwende gICS der Greiswalder Tools (siehe unten).
|
||||
|
||||
Hier ein Beispiel für die erste (Index 0 - weitere dann mit 1,2,...) Transformationsregel:
|
||||
#### Einwilligung gICS
|
||||
|
||||
* `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.
|
||||
Ab gIcs Version 2.13.0 kann im ETL-Processor
|
||||
per [REST-Schnittstelle](https://simplifier.net/guide/ttp-fhir-gateway-ig/ImplementationGuide-markdown-Einwilligungsmanagement-Operations-isConsented?version=current)
|
||||
der Einwilligungsstatus abgefragt werden.
|
||||
Vor der MTB-Übertragung kann der zum Sendezeitpunkt verfügbarer Einwilligungsstatus über Endpunkt
|
||||
*isConsented* (MTB-File v1) und *currentPolicyStatesForPerson* (MTB-File v2) abgefragt werden.
|
||||
|
||||
Falls Anbindung an gICS aktiviert wurde, wird der Einwilligungsstatus der MTB Datei ignoriert.
|
||||
Stattdessen werden vorhandene Einwilligungen abgefragt und in die MTB Datei eingebettet.
|
||||
|
||||
Es werden zwei Einwilligungsdomänen unterstützt, eine für Broad Consent und als zweites GenomDE
|
||||
Modelvorhaben §64e.
|
||||
|
||||
##### Hinweise
|
||||
|
||||
1. Die aktuelle Impl. nimmt an, dass die hinterlegten Domänen der Einwilligungen ausschließlich für
|
||||
die genannten Art von Einwilligungen genutzt werden. Es finde keine weitere Filterung statt. Wir
|
||||
fragen pro Domäne die Schnittstelle `CurrentPolicyStatesForPerson` - siehe
|
||||
auch [IG TTP-FHIR Gateway
|
||||
](https://www.ths-greifswald.de/wp-content/uploads/tools/fhirgw/ig/2024-3-0/ImplementationGuide-markdown-Einwilligungsmanagement-Operations-currentPolicyStatesForPerson.html)
|
||||
ab.
|
||||
2. Die Einwilligung wird für den Patienten-Identifier der MTB abgerufen und anschließend durch das
|
||||
DNPM Pseudonym ersetzt.
|
||||
3. Abfragen von Einwilligungen über gesonderte Pseudonyme anstatt des MTB-Identifiers fehlt in der
|
||||
ersten Implementierung.
|
||||
4. Bei Verarbeitung von MTB Version 1.x Inhalten ist eine positive Einwilligung für die
|
||||
Weiterverarbeitung notwendig. Das Fehlen einer Einwilligung löst die Löschung des Patienten im
|
||||
Brückenkopf aus.
|
||||
|
||||
##### Konfiguration
|
||||
|
||||
* `APP_CONSENT_SERVICE`: Muss Wert `GICS` gesetzt sein um die Abfragen zu aktivieren. Der Wert
|
||||
`NONE` deaktiviert die Abfrage in gICS.
|
||||
* `APP_CONSENT_GICS_URI`: URI der gICS-Instanz (z.B. `http://localhost:8090/ttp-fhir/fhir/gics`)
|
||||
* `APP_CONSENT_GICS_USERNAME`: gIcs Basic-Auth Benutzername
|
||||
* `APP_CONSENT_GICS_PASSWORD`: gIcs Basic-Auth Passwort
|
||||
* `APP_CONSENT_GICS_PERSONIDENTIFIERSYSTEM`: Derzeit wird nur die PID unterstützt. wenn leer wird
|
||||
`https://ths-greifswald.de/fhir/gics/identifiers/Patienten-ID` angenommen
|
||||
* `APP_CONSENT_GICS_BROADCONSENTDOMAINNAME`: Domäne in der gIcs Broad Consent Einwilligungen
|
||||
verwaltet. Falls Wert leer, wird `MII` angenommen.
|
||||
* `APP_CONSENT_GICS_GNOMDECONSENTDOMAINNAME`: Domäne in der gIcs GenomDE Modelvorhaben §64e
|
||||
Einwilligungen verwaltet. Falls Wert leer, wird `GenomDE_MV` angenommen.
|
||||
* `APP_CONSENT_GICS_POLICYCODE`: Die entscheidende Objekt-ID der zu prüfenden Einwilligung-Regel.
|
||||
Falls leer wird `2.16.840.1.113883.3.1937.777.24.5.3.6` angenommen.
|
||||
* `APP_CONSENT_GICS_POLICYSYSTEM`: Das System der Einwilligung-Regel der Objekt-IDs. Falls leer wird
|
||||
`urn:oid:2.16.840.1.113883.3.1937.777.24.5.3` angenommen.
|
||||
|
||||
### Anmeldung mit einem Passwort
|
||||
|
||||
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
|
||||
### Mögliche Endpunkte zur Datenübermittlung
|
||||
|
||||
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.
|
||||
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 das bwHC-Backend gesendet wird:
|
||||
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein MTB-File an DNPM:DIP gesendet wird:
|
||||
|
||||
* `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api`
|
||||
* `APP_REST_URI`: URI der zu benutzenden API der Backend-Instanz. Zum Beispiel `http://localhost:9000/api`
|
||||
* `APP_REST_USERNAME`: Basic-Auth-Benutzername für den REST-Endpunkt
|
||||
* `APP_REST_PASSWORD`: Basic-Auth-Passwort für den REST-Endpunkt
|
||||
|
||||
### 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 MTB-File an ein Kafka-Topic
|
||||
übermittelt wird:
|
||||
|
||||
* `APP_KAFKA_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen
|
||||
* `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_OUTPUT_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_GROUP_ID`: Kafka GroupID des Consumers. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_
|
||||
group".
|
||||
* `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste
|
||||
|
||||
Wird keine Rückantwort über Apache Kafka empfangen und es gibt keine weitere Möglichkeit den Status festzustellen, verbleibt der Status auf `UNKNOWN`.
|
||||
Wird keine Rückantwort über Apache Kafka empfangen und es gibt keine weitere Möglichkeit den Status
|
||||
festzustellen, verbleibt der Status auf `UNKNOWN`.
|
||||
|
||||
Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden.
|
||||
|
||||
Lässt sich keine Verbindung zu dem bwHC-Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es
|
||||
Lässt sich keine Verbindung zu dem Backend aufbauen, wird eine Rückantwort mit Status-Code `900`
|
||||
erwartet, welchen es
|
||||
für HTTP nicht gibt.
|
||||
|
||||
#### Retention Time
|
||||
Wird die Umgebungsvariable `APP_KAFKA_INPUT_TOPIC` gesetzt, kann eine Nachricht auch über dieses
|
||||
Kafka-Topic an den ETL-Prozessor übermittelt werden.
|
||||
|
||||
##### Retention Time
|
||||
|
||||
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.
|
||||
Es sind innerhalb dieses Zeitraums auch alte Informationen weiterhin enthalten, wenn der Consent später abgelehnt wurde.
|
||||
Es sind innerhalb dieses Zeitraums auch alte Informationen weiterhin enthalten, wenn der Consent
|
||||
später abgelehnt wurde.
|
||||
|
||||
Durch eine entsprechende Konfiguration des Topics kann dies verhindert werden.
|
||||
|
||||
Beispiel - auszuführen innerhalb des Kafka-Containers: Löschen alter Records nach einem Tag
|
||||
|
||||
```
|
||||
kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config retention.ms=86400000
|
||||
```
|
||||
|
||||
#### 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.
|
||||
|
||||
|
||||
* `retention.ms`: Möglichst kurze Zeit in der alte Records noch erhalten bleiben, z.B. 10 Sekunden 10000
|
||||
* `cleanup.policy`: Löschen alter Records und Beibehalten des letzten Records zu einem Key [delete,compact]
|
||||
* `retention.ms`: Möglichst kurze Zeit in der alte Records noch erhalten bleiben, z.B. 10 Sekunden
|
||||
10000
|
||||
* `cleanup.policy`: Löschen alter Records und Beibehalten des letzten Records zu einem
|
||||
Key [delete,compact]
|
||||
|
||||
Beispiele für ein Topic `test`, hier bitte an die verwendeten Topics anpassen.
|
||||
|
||||
@@ -121,19 +369,39 @@ 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]
|
||||
```
|
||||
|
||||
Da als Key eines Records die (pseudonymisierte) Patienten-ID und die (anonymisierte) Erkrankungs-ID verwendet wird,
|
||||
stehen mit obiger Konfiguration der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden
|
||||
Key zur Verfügung.
|
||||
Da als Key eines Records die (pseudonymisierte) Patienten-ID verwendet wird, stehen mit obiger
|
||||
Konfiguration
|
||||
der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden Key zur
|
||||
Verfügung.
|
||||
|
||||
Da der Key sowohl für die Records in Richtung bwHC-Backend für die Rückantwort identisch aufgebaut ist, lassen sich so
|
||||
auch im Falle eines Consent-Widerspruchs die enthaltenen Daten als auch die Offenlegung durch Verifikationsdaten in der
|
||||
Da der Key sowohl für die Records in Richtung DNPM:DIP, als auch für die Rückantwort identisch
|
||||
aufgebaut ist, lassen sich so
|
||||
auch im Falle eines Consent-Widerspruchs die enthaltenen Daten als auch die Offenlegung durch
|
||||
Verifikationsdaten in der
|
||||
Antwort effektiv verhindern, da diese nach 10 Sekunden gelöscht werden.
|
||||
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.
|
||||
|
||||
Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für andere Auswertungen
|
||||
verwenden möchten.
|
||||
|
||||
### Antworten und Statusauswertung
|
||||
|
||||
Seit Version 0.10 wird die Issue-Liste der Antwort verwendet und die darion enthaltene höchste
|
||||
Severity-Stufe als Ergebnis verwendet.
|
||||
|
||||
| Höchste Severity | Status |
|
||||
|------------------|-----------|
|
||||
| `info` | `SUCCESS` |
|
||||
| `warning` | `WARNING` |
|
||||
| `error`, `fatal` | `ERROR` |
|
||||
|
||||
## Docker-Images
|
||||
|
||||
Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/CCC-MF/etl-processor/pkgs/container/etl-processor
|
||||
Diese Anwendung ist auch als Docker-Image
|
||||
verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-processor
|
||||
|
||||
### Images lokal bauen
|
||||
|
||||
@@ -141,28 +409,116 @@ Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/CCC-MF/
|
||||
./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
|
||||
*Ausführen als Docker Conatiner:*
|
||||
|
||||
*Ausführen als Docker Container:*
|
||||
|
||||
```bash
|
||||
cd ./deploy
|
||||
cp env-sample.env .env
|
||||
```
|
||||
|
||||
Wenn gewünscht, Änderungen in der `.env` vornehmen.
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
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.
|
||||
Diese kann zur Nutzung der Datenbanken **MariaDB** als auch **PostgreSQL** angepasst werden.
|
||||
|
||||
Zur Nutzung von Apache Kafka muss dazu ein Eintrag im hosts-File vorgenommen werden und der Hostname `kafka` auf die lokale
|
||||
IP-Adresse verweisen. Ohne diese Einstellung ist eine Nutzung von Apache Kafka außerhalb der Docker-Umgebung nicht möglich.
|
||||
Zur Nutzung von Apache Kafka muss dazu ein Eintrag im hosts-File vorgenommen werden und der Hostname
|
||||
`kafka` auf die lokale
|
||||
IP-Adresse verweisen. Ohne diese Einstellung ist eine Nutzung von Apache Kafka außerhalb der
|
||||
Docker-Umgebung nicht möglich.
|
||||
|
||||
Beim Start der Anwendung mit dem Profil `dev` wird die in `dev-compose.yml` definierte Umgebung beim Start der
|
||||
Beim Start der Anwendung mit dem Profil `dev` wird die in `dev-compose.yml` definierte Umgebung beim
|
||||
Start der
|
||||
Anwendung mit gestartet:
|
||||
|
||||
```
|
||||
@@ -173,3 +529,6 @@ Die Datei `application-dev.yml` enthält hierzu die Konfiguration für das Profi
|
||||
|
||||
Beim Ausführen der Integrationstests wird eine Testdatenbank in einem Docker-Container gestartet.
|
||||
Siehe hier auch die Klasse `AbstractTestcontainerTest` unter `src/integrationTest`.
|
||||
|
||||
Ein einfaches Entwickler-Setup inklusive DNPM:DIP ist mit Hilfe
|
||||
von https://github.com/pcvolkmer/dnpmdip-devenv realisierbar.
|
||||
|
5
bindings/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 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
bindings/ca-certificates/type
Normal file
@@ -0,0 +1 @@
|
||||
ca-certificates
|
100
build.gradle.kts
@@ -1,27 +1,35 @@
|
||||
import org.gradle.api.tasks.testing.logging.TestLogEvent
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
|
||||
|
||||
plugins {
|
||||
war
|
||||
id("org.springframework.boot") version "3.2.1"
|
||||
id("io.spring.dependency-management") version "1.1.4"
|
||||
kotlin("jvm") version "1.9.21"
|
||||
kotlin("plugin.spring") version "1.9.21"
|
||||
id("org.springframework.boot") version "3.5.3"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
kotlin("jvm") version "1.9.25"
|
||||
kotlin("plugin.spring") version "1.9.25"
|
||||
jacoco
|
||||
}
|
||||
|
||||
group = "de.ukw.ccc"
|
||||
version = "0.3.1"
|
||||
group = "dev.dnpm"
|
||||
version = "0.11.0-SNAPSHOT"
|
||||
|
||||
var versions = mapOf(
|
||||
"bwhc-dto-java" to "0.2.0",
|
||||
"hapi-fhir" to "6.10.2",
|
||||
"httpclient5" to "5.2.1",
|
||||
"mockito-kotlin" to "5.1.0"
|
||||
"mtb-dto" to "0.1.0-SNAPSHOT",
|
||||
"hapi-fhir" to "7.6.1",
|
||||
"mockito-kotlin" to "5.4.0",
|
||||
"archunit" to "1.3.0",
|
||||
// Webjars
|
||||
"webjars-locator" to "0.52",
|
||||
"echarts" to "5.4.3",
|
||||
"htmx.org" to "1.9.12"
|
||||
)
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
@@ -40,9 +48,18 @@ configurations {
|
||||
compileOnly {
|
||||
extendsFrom(configurations.annotationProcessor.get())
|
||||
}
|
||||
|
||||
all {
|
||||
resolutionStrategy {
|
||||
cacheChangingModulesFor(5, "minutes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url = uri("https://git.dnpm.dev/api/packages/public-snapshots/maven")
|
||||
}
|
||||
maven {
|
||||
url = uri("https://git.dnpm.dev/api/packages/public/maven")
|
||||
}
|
||||
@@ -54,33 +71,52 @@ dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
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("org.springframework.kafka:spring-kafka")
|
||||
implementation("org.flywaydb:flyway-database-postgresql")
|
||||
implementation("org.flywaydb:flyway-mysql")
|
||||
implementation("commons-codec:commons-codec")
|
||||
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
|
||||
implementation("de.ukw.ccc:bwhc-dto-java:${versions["bwhc-dto-java"]}")
|
||||
implementation("dev.pcvolkmer.mv64e:mtb-dto:${versions["mtb-dto"]}") { isChanging = true }
|
||||
implementation("ca.uhn.hapi.fhir:hapi-fhir-base:${versions["hapi-fhir"]}")
|
||||
implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}")
|
||||
implementation("org.apache.httpcomponents.client5:httpclient5:${versions["httpclient5"]}")
|
||||
implementation("org.apache.httpcomponents.client5: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"]}")
|
||||
// Fix for CVE-2025-48924
|
||||
implementation("org.apache.commons:commons-lang3:3.18.0")
|
||||
|
||||
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
|
||||
|
||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||
|
||||
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
testImplementation("io.projectreactor:reactor-test")
|
||||
testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}")
|
||||
|
||||
integrationTestImplementation("org.testcontainers:junit-jupiter")
|
||||
integrationTestImplementation("org.testcontainers:postgresql")
|
||||
integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}")
|
||||
integrationTestImplementation("org.htmlunit:htmlunit")
|
||||
integrationTestImplementation("org.springframework:spring-webflux")
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile> {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-Xjsr305=strict"
|
||||
jvmTarget = "17"
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xjsr305=strict")
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +127,9 @@ tasks.withType<Test> {
|
||||
}
|
||||
}
|
||||
|
||||
task<Test>("integrationTest") {
|
||||
tasks.register<Test>("integrationTest") {
|
||||
description = "Runs integration tests"
|
||||
group = "verification"
|
||||
|
||||
testClassesDirs = sourceSets["integrationTest"].output.classesDirs
|
||||
classpath = sourceSets["integrationTest"].runtimeClasspath
|
||||
@@ -100,12 +137,35 @@ task<Test>("integrationTest") {
|
||||
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") {
|
||||
imageName.set("ghcr.io/ccc-mf/etl-processor")
|
||||
imageName.set("ghcr.io/pcvolkmer/mv64e-etl-processor")
|
||||
|
||||
// Binding for CA Certs
|
||||
bindings.set(listOf(
|
||||
"$rootDir/bindings/ca-certificates/:/platform/bindings/ca-certificates"
|
||||
))
|
||||
|
||||
environment.set(environment.get() + mapOf(
|
||||
"BP_OCI_SOURCE" to "https://github.com/CCC-MF/etl-processor",
|
||||
// Enable this line to embed CA Certs into image on build time
|
||||
//"BP_EMBED_CERTS" to "true",
|
||||
"BP_OCI_SOURCE" to "https://github.com/pcvolkmer/mv64e-etl-processor",
|
||||
"BP_OCI_LICENSES" to "AGPLv3",
|
||||
"BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files"
|
||||
"BP_OCI_DESCRIPTION" to "ETL Processor for MV § 64e and DNPM:DIP"
|
||||
))
|
||||
}
|
||||
|
@@ -18,6 +18,11 @@ services:
|
||||
APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID}
|
||||
APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC}
|
||||
APP_REST_URI: ${DNPM_BWHC_REST_URI}
|
||||
APP_REST_USERNAME: ${DNPM_BWHC_REST_USERNAME}
|
||||
APP_REST_PASSWORD: ${DNPM_BWHC_REST_PASSWORD}
|
||||
APP_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_PASSWORD: ${DNPM_MARIADB_USER_PW}
|
||||
SPRING_DATASOURCE_USERNAME: ${DNPM_MARIADB_DB}
|
||||
|
@@ -2,6 +2,10 @@
|
||||
DNPM_MONITORING_HTTP_PORT=8088
|
||||
DNPM_LOG_LEVEL=INFO
|
||||
|
||||
# ADMIN USER CREDENTIALS
|
||||
DNPM_ADMIN_USER=admin
|
||||
DNPM_ADMIN_PASSWORD=
|
||||
|
||||
# GPAS or BUILDIN
|
||||
DNPM_PSEUDONYMIZE_GENERATOR=BUILDIN
|
||||
DNPM_APP_PSEUDONYMIZE_PREFIX=ANONYM
|
||||
@@ -24,6 +28,9 @@ DNPM_DATASOURCE_URL=jdbc:mariadb://dnpm-monitor-db:3306/$DNPM_MARIADB_DB
|
||||
## TARGET SYSTEMS CONFIG
|
||||
# in case of direct access to bwhc enter endpoint url here
|
||||
DNPM_BWHC_REST_URI=
|
||||
DNPM_BWHC_REST_USERNAME=
|
||||
DNPM_BWHC_REST_PASSWORD=
|
||||
DNPM_BWHC_REST_IS_BWHC=false
|
||||
|
||||
# produce mtb files to this topic - values 'false' disabling kafka processing
|
||||
DNPM_KAFKA_TOPIC=false
|
||||
|
126
dev-compose.yml
@@ -1,24 +1,125 @@
|
||||
services:
|
||||
kafka:
|
||||
image: bitnami/kafka
|
||||
image: apache/kafka
|
||||
hostname: kafka
|
||||
ports:
|
||||
- "9092:9092"
|
||||
- "9094:9094"
|
||||
environment:
|
||||
ALLOW_PLAINTEXT_LISTENER: "yes"
|
||||
KAFKA_CFG_NODE_ID: "0"
|
||||
KAFKA_CFG_PROCESS_ROLES: "controller,broker"
|
||||
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
|
||||
KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
|
||||
KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT,PLAINTEXT:PLAINTEXT
|
||||
KAFKA_CFG_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
||||
KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: true
|
||||
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
|
||||
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
||||
KAFKA_NODE_ID: "0"
|
||||
KAFKA_KRAFT_CLUSTER_ID: "mv64e-etl-processor-dev"
|
||||
KAFKA_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
|
||||
KAFKA_PROCESS_ROLES: "controller,broker"
|
||||
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
||||
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1"
|
||||
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1"
|
||||
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1"
|
||||
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: "0"
|
||||
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
|
||||
# Without SSL
|
||||
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
|
||||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT,PLAINTEXT:PLAINTEXT
|
||||
# Using SSL
|
||||
# KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
|
||||
# KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
|
||||
# KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,EXTERNAL:SSL,PLAINTEXT:PLAINTEXT
|
||||
# KAFKA_SSL_KEYSTORE_TYPE: "PEM"
|
||||
# KAFKA_SSL_KEYSTORE_CERTIFICATE_CHAIN: -----BEGIN CERTIFICATE-----
|
||||
# MIIDCzCCAfOgAwIBAgIUaXNh4PahaKeLUaab2rUPSVESx28wDQYJKoZIhvcNAQEL
|
||||
# BQAwFTETMBEGA1UEAwwKRXhhbXBsZSBDQTAeFw0yNTA4MjExODEyMTFaFw0zNTA4
|
||||
# MTkxODEyMTFaMBUxEzARBgNVBAMMCkV4YW1wbGUgQ0EwggEiMA0GCSqGSIb3DQEB
|
||||
# AQUAA4IBDwAwggEKAoIBAQCsqalqVOLFglVbX9oSHU91ebyL1kPyb/2N90UGQIcD
|
||||
# UAjzKxxysId1Vdvtbbwgli6UgfPwlzFP2Wlw51h496yL4QU/9tNV956UJ1RoS/fG
|
||||
# qBAEHctqavfMI27UQmIzw4pGMkGzEQxRMc6a9pdabBhbMMTJsjtmOv2YMYHj1HHK
|
||||
# Dr7wTBTt2l0eRyCR0kZ8XGIMWhYowPa4EMpC7+4e5Nf/7LSJZWLLy9jXPpazsjkJ
|
||||
# jEmDNlFfx2tZiq0Wz2Xj1pZSDLbcuIX4IHcLfMvagibfrCMX/h6+WuW42sWPRuBW
|
||||
# wB6cHGlXs+K/gBBWxtD7sOTacO5hbHFsfaJOhSEIGoIpAgMBAAGjUzBRMB0GA1Ud
|
||||
# DgQWBBT2S/C2++ECY+CSuN5KKql0umfbTDAfBgNVHSMEGDAWgBT2S/C2++ECY+CS
|
||||
# uN5KKql0umfbTDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBj
|
||||
# H4DdwqrOHg7sVsqiwDsZfTharpUDCYeG5XhrJQlnA9eKwyofTb929W/fjOwBdDtg
|
||||
# 9THT/omR0lA8/UyHtezMT6nMsCn4HG2mXvx6ghgvA3jrFTEY7R80dHkboLMTV3u4
|
||||
# RYgC9S3BJPcbJYpM0cXzkp2T0F4FxWZlfqefuedHuX3zcCxpgVD56qQb2a131TX7
|
||||
# O3UDJfVg8a65IFtehndqILgLVrf7w6+pbmDAlCg5RKrt2USEYyZXYdyTryJbdtn4
|
||||
# BCLp0avYtSYVUGwgH0oUCpkjQRwMg1003TTz8SNnmE7mAXHYljyYejnjL8vBHfch
|
||||
# 8tTDVXQn08BT9H3jZTnF
|
||||
# -----END CERTIFICATE-----
|
||||
# -----BEGIN CERTIFICATE-----
|
||||
# MIIC+TCCAeGgAwIBAgIUUoCwz8GS6xQ3mmI7RUUYSNPIOi4wDQYJKoZIhvcNAQEL
|
||||
# BQAwFTETMBEGA1UEAwwKRXhhbXBsZSBDQTAeFw0yNTA4MjExODE0NDhaFw0zNTA4
|
||||
# MTkxODE0NDhaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEB
|
||||
# BQADggEPADCCAQoCggEBAL9PW99MhhBwdEmTHyZgfnhfTrxZPrNU6z1UdV8b82Lk
|
||||
# 3p75o8eCKa9iOd7DDQlo75hQBhhX0+Xc3mucrstx5p8TYFMbypif8ojWh3LM++P8
|
||||
# tz3ezQZlq86ycyKpm8dqlA03b227tFDfiYTev2eD2HN40BU7yDAYhhqd/QW8+MV2
|
||||
# jkcRGv5cE21GZxWmPUpkVN+bNoBC8H90WmkST90LfeYF+wZnlsAJZH6AQzR1GnGD
|
||||
# ICE5evMhC78hvRnpgeA310SyxssZEigkePL5lTZOBPY2IuzBqL05agyVTiVq4Xd6
|
||||
# y3xOqXoxxOhZu06yd3nymorqeTgbF1fW8wQF0u3KsFECAwEAAaNCMEAwHQYDVR0O
|
||||
# BBYEFHk9jMWRAAt2YsBSxUcOQVoWayoHMB8GA1UdIwQYMBaAFPZL8Lb74QJj4JK4
|
||||
# 3koqqXS6Z9tMMA0GCSqGSIb3DQEBCwUAA4IBAQBqabAA9INONDaLHqs9i9YQHm/g
|
||||
# AnB7xRl/RFbERKKCTSMZUYM8oEaaH0W2ENoPMc/7xOB/R8a7Rm62PTr6syxwhZrY
|
||||
# 5NtGKJOD+rh90/5l83tulf93KqOJtGkiv6NBDvCNrITcA+UKRk/z4GcFi2YjWAl4
|
||||
# wvY44lzTasMKSpjUQ5N0VNANcW3nVuEgPQ8rrr0NOK/5j4guPjsXDsixa47gqblA
|
||||
# 5xGfBKeVmEXdPbzawZfP4hPIw7DpX2m8Y0erswF1ZxkIV73V3TDsFSLcqSKSzZr6
|
||||
# mtj8COlV9Us7zqaJbV5eOl7GN1T9orZJwZmX1Z46gCkkSLYDP/dqtl2j9JgN
|
||||
# -----END CERTIFICATE-----
|
||||
# KAFKA_SSL_KEYSTORE_KEY: -----BEGIN PRIVATE KEY-----
|
||||
# MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/T1vfTIYQcHRJ
|
||||
# kx8mYH54X068WT6zVOs9VHVfG/Ni5N6e+aPHgimvYjneww0JaO+YUAYYV9Pl3N5r
|
||||
# nK7LceafE2BTG8qYn/KI1odyzPvj/Lc93s0GZavOsnMiqZvHapQNN29tu7RQ34mE
|
||||
# 3r9ng9hzeNAVO8gwGIYanf0FvPjFdo5HERr+XBNtRmcVpj1KZFTfmzaAQvB/dFpp
|
||||
# Ek/dC33mBfsGZ5bACWR+gEM0dRpxgyAhOXrzIQu/Ib0Z6YHgN9dEssbLGRIoJHjy
|
||||
# +ZU2TgT2NiLswai9OWoMlU4lauF3est8Tql6McToWbtOsnd58pqK6nk4GxdX1vME
|
||||
# BdLtyrBRAgMBAAECggEAC1wXfPlqxoQe65WAVoOJTvV90+JKvlRPCZu/wm+C8r7b
|
||||
# Vz5Ekt6wQflHrWoQlpv0CivKSNzCONZ2IJazrGHti0mXwSeXzptEyApRDaiNVnrV
|
||||
# mKdnrjcQThw7iPXgSaWS9/vwMmhgayLy5ABkBi4GhsjINlKP7wctw1vZP+N6NCNd
|
||||
# Ql3taStvDKmG0SfJHF6/2o/XBpof3IJEL7ghbzyTTbWWaO34J1mJ8A+AmjGhj9GE
|
||||
# Dp3XuOrO9W7MVd1nfZDtGBS8qf80AwROyodZZRma9vZuWJZ5aQFi2CnUEtU1T+Uv
|
||||
# tW+F6tg2FOMr8M0Fb79wGIDwSF8u/QcTvwhEzZAfiQKBgQDioOofnE1oB1DOMnqZ
|
||||
# SOFjs+vsirvS6G3lo27+HkE3TgvCHR4sk1305AiXtjmPu8iaUCo9qn18MtduY2RS
|
||||
# CcKMOG/FxhmDyP5I29DhJRhvERIpJd0kcSDQOgtaoVPC1XzIlyTqte6nGX9kAnA/
|
||||
# x/OOXrZ0hjhMNDcZzf2NasPYJQKBgQDYGqTobkVBk+eekNWklnTh41/649rUIgTu
|
||||
# JStArtY2hgaEInYcGa2e7cEj7nIHA0iGy3EJ2yvwoUIyxtoXVcGohu2IrzlhS33T
|
||||
# R4jA7nE2/yHZrEMEJovuSU0eMw7rgvEtL79Q0RToYnTY1EU6X/BBoFfiiEeNMHKz
|
||||
# zjDOOQ6ZvQKBgGCWChIc0FSkwYiPtPZ9PCn89XCjk/cIPkYfiF9fT5Ydeh9pv4Fp
|
||||
# 8SI8yXi3HgMnGhDCV65eagqztGMEky3voO2X4/MbQaaL0+wDWxuJbsdvNBk7XOt6
|
||||
# F20HP+2JUiR4Ti1DVWV+0k5/LG7YJzTXp/KmZQZ2aan4mv8xbn2F4h/NAoGAI4ou
|
||||
# OLN53FEQtHkpSYoc6tFUBZTXdi+qE+g09sxKGmlsROrN9c0bSpnbO6eJRTH7CYAH
|
||||
# tRFAZrB+jI87ar8FvEuEYQhALYoWxVpsWR5drCfFT2EPHG2icavIbQEEoSLFuyKx
|
||||
# Gf9oqtcWVFqEkBcbEg/mpDC5Y7TmCEAOsrubdRkCgYEAl7B+EzIdG0rabGoti09q
|
||||
# QXfyiTjR7nQYkhpLxMCeNlCpQ8Y15XSa8bm1UIGYqj/ZBpeBNhrj64IHoub5Vd43
|
||||
# tzbb8yNgoLUd16TU1VvyccCMGQVPIF8RkDsAtEawV2eoXbHAstN99xbC8jsLNZRQ
|
||||
# fcfgTiQaXRJmlVx6jfbfZd4=
|
||||
# -----END PRIVATE KEY-----
|
||||
# #KAFKA_SSL_KEYSTORE_CREDENTIALS: example
|
||||
# KAFKA_SSL_KEY_CREDENTIALS: example
|
||||
# KAFKA_SSL_TRUSTSTORE_TYPE: "PEM"
|
||||
# KAFKA_SSL_TRUSTSTORE_CERTIFICATES: -----BEGIN CERTIFICATE-----
|
||||
# MIIDCzCCAfOgAwIBAgIUaXNh4PahaKeLUaab2rUPSVESx28wDQYJKoZIhvcNAQEL
|
||||
# BQAwFTETMBEGA1UEAwwKRXhhbXBsZSBDQTAeFw0yNTA4MjExODEyMTFaFw0zNTA4
|
||||
# MTkxODEyMTFaMBUxEzARBgNVBAMMCkV4YW1wbGUgQ0EwggEiMA0GCSqGSIb3DQEB
|
||||
# AQUAA4IBDwAwggEKAoIBAQCsqalqVOLFglVbX9oSHU91ebyL1kPyb/2N90UGQIcD
|
||||
# UAjzKxxysId1Vdvtbbwgli6UgfPwlzFP2Wlw51h496yL4QU/9tNV956UJ1RoS/fG
|
||||
# qBAEHctqavfMI27UQmIzw4pGMkGzEQxRMc6a9pdabBhbMMTJsjtmOv2YMYHj1HHK
|
||||
# Dr7wTBTt2l0eRyCR0kZ8XGIMWhYowPa4EMpC7+4e5Nf/7LSJZWLLy9jXPpazsjkJ
|
||||
# jEmDNlFfx2tZiq0Wz2Xj1pZSDLbcuIX4IHcLfMvagibfrCMX/h6+WuW42sWPRuBW
|
||||
# wB6cHGlXs+K/gBBWxtD7sOTacO5hbHFsfaJOhSEIGoIpAgMBAAGjUzBRMB0GA1Ud
|
||||
# DgQWBBT2S/C2++ECY+CSuN5KKql0umfbTDAfBgNVHSMEGDAWgBT2S/C2++ECY+CS
|
||||
# uN5KKql0umfbTDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBj
|
||||
# H4DdwqrOHg7sVsqiwDsZfTharpUDCYeG5XhrJQlnA9eKwyofTb929W/fjOwBdDtg
|
||||
# 9THT/omR0lA8/UyHtezMT6nMsCn4HG2mXvx6ghgvA3jrFTEY7R80dHkboLMTV3u4
|
||||
# RYgC9S3BJPcbJYpM0cXzkp2T0F4FxWZlfqefuedHuX3zcCxpgVD56qQb2a131TX7
|
||||
# O3UDJfVg8a65IFtehndqILgLVrf7w6+pbmDAlCg5RKrt2USEYyZXYdyTryJbdtn4
|
||||
# BCLp0avYtSYVUGwgH0oUCpkjQRwMg1003TTz8SNnmE7mAXHYljyYejnjL8vBHfch
|
||||
# 8tTDVXQn08BT9H3jZTnF
|
||||
# -----END CERTIFICATE-----
|
||||
# KAFKA_SSL_CLIENT_AUTH: none
|
||||
###
|
||||
|
||||
## Use AKHQ as Kafka web frontend
|
||||
akhq:
|
||||
image: tchiotludo/akhq:0.21.0
|
||||
image: tchiotludo/akhq:0.25.0
|
||||
environment:
|
||||
AKHQ_CONFIGURATION: |
|
||||
akhq:
|
||||
@@ -32,6 +133,8 @@ services:
|
||||
ports:
|
||||
- "8084:8080"
|
||||
|
||||
|
||||
## For use with MariaDB
|
||||
mariadb:
|
||||
image: mariadb:10
|
||||
ports:
|
||||
@@ -42,6 +145,7 @@ services:
|
||||
MARIADB_PASSWORD: dev
|
||||
MARIADB_ROOT_PASSWORD: dev
|
||||
|
||||
## For use with Postgres
|
||||
# postgres:
|
||||
# image: postgres:alpine
|
||||
# ports:
|
||||
|
@@ -2,31 +2,55 @@ version: '3.7'
|
||||
|
||||
services:
|
||||
|
||||
zoo1:
|
||||
image: zookeeper:3.8.0
|
||||
hostname: zoo1
|
||||
zoo:
|
||||
image: zookeeper:3.9.2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2181:2181"
|
||||
environment:
|
||||
ZOO_MY_ID: 1
|
||||
ZOO_PORT: 2181
|
||||
ZOO_SERVERS: server.1=zoo1:2888:3888;2181
|
||||
ZOO_SERVERS: server.1=zoo:2888:3888;2181
|
||||
|
||||
kafka1:
|
||||
image: confluentinc/cp-kafka:7.2.1
|
||||
hostname: kafka1
|
||||
kafka:
|
||||
image: confluentinc/cp-kafka:7.6.1
|
||||
ports:
|
||||
- "9092:9092"
|
||||
environment:
|
||||
KAFKA_ADVERTISED_LISTENERS: LISTENER_DOCKER_INTERNAL://kafka1:19092,LISTENER_DOCKER_EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT
|
||||
KAFKA_ADVERTISED_LISTENERS: LISTENER_DOCKER_INTERNAL://kafka:19092,LISTENER_DOCKER_EXTERNAL://172.17.0.1:9093,LISTENER_EXTERNAL://127.0.0.1:9092
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT,LISTENER_EXTERNAL:PLAINTEXT
|
||||
KAFKA_INTER_BROKER_LISTENER_NAME: LISTENER_DOCKER_INTERNAL
|
||||
KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181"
|
||||
KAFKA_ZOOKEEPER_CONNECT: zoo:2181
|
||||
KAFKA_BROKER_ID: 1
|
||||
KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO"
|
||||
KAFKA_LOG4J_LOGGERS: kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO
|
||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||
KAFKA_MESSAGE_MAX_BYTES: 5242880
|
||||
KAFKA_REPLICA_FETCH_MAX_BYTES: 5242880
|
||||
KAFKA_COMPRESSION_TYPE: gzip
|
||||
depends_on:
|
||||
- zoo1
|
||||
- zoo
|
||||
healthcheck:
|
||||
test: kafka-topics --bootstrap-server kafka:9092 --list
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
akhq:
|
||||
image: tchiotludo/akhq:0.25.0
|
||||
environment:
|
||||
AKHQ_CONFIGURATION: |
|
||||
akhq:
|
||||
ui-options:
|
||||
topic.show-all-consumer-groups: true
|
||||
topic-data.sort: NEWEST
|
||||
connections:
|
||||
docker-kafka-server:
|
||||
properties:
|
||||
bootstrap.servers: "kafka:19092"
|
||||
ports:
|
||||
- "9000:8080"
|
||||
depends_on:
|
||||
- kafka
|
||||
|
||||
kafka-rest-proxy:
|
||||
image: confluentinc/cp-kafka-rest:7.2.1
|
||||
@@ -40,8 +64,8 @@ services:
|
||||
KAFKA_REST_HOST_NAME: kafka-rest-proxy
|
||||
KAFKA_REST_BOOTSTRAP_SERVERS: PLAINTEXT://kafka1:19092
|
||||
depends_on:
|
||||
- zoo1
|
||||
- kafka1
|
||||
- zoo
|
||||
- kafka
|
||||
|
||||
kafka-connect:
|
||||
image: confluentinc/cp-kafka-connect:7.2.1
|
||||
@@ -67,24 +91,6 @@ services:
|
||||
#volumes:
|
||||
# - ./connectors:/etc/kafka-connect/jars/
|
||||
depends_on:
|
||||
- zoo1
|
||||
- kafka1
|
||||
- zoo
|
||||
- kafka
|
||||
- kafka-rest-proxy
|
||||
|
||||
akhq:
|
||||
image: tchiotludo/akhq:0.21.0
|
||||
environment:
|
||||
AKHQ_CONFIGURATION: |
|
||||
akhq:
|
||||
connections:
|
||||
docker-kafka-server:
|
||||
properties:
|
||||
bootstrap.servers: "kafka1:19092"
|
||||
connect:
|
||||
- name: "kafka-connect"
|
||||
url: "http://kafka-connect:8083"
|
||||
ports:
|
||||
- "8084:8080"
|
||||
depends_on:
|
||||
- kafka1
|
||||
- kafka-connect
|
||||
|
28
docs/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
### 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: 75 KiB After Width: | Height: | Size: 115 KiB |
BIN
docs/login.png
Normal file
After Width: | Height: | Size: 9.2 KiB |
BIN
docs/tokens.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
docs/userroles.png
Normal file
After Width: | Height: | Size: 16 KiB |
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
@@ -1 +1 @@
|
||||
rootProject.name = "etl-processor"
|
||||
rootProject.name = "mv64e-etl-processor"
|
||||
|
@@ -20,10 +20,11 @@
|
||||
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.DnpmV2MtbFileRequest
|
||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||
import dev.pcvolkmer.mv64e.mtb.*
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
@@ -33,10 +34,10 @@ import org.mockito.kotlin.*
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.post
|
||||
@@ -45,7 +46,14 @@ import org.testcontainers.junit.jupiter.Testcontainers
|
||||
@Testcontainers
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
@MockBean(MtbFileSender::class)
|
||||
@MockitoBean(types = [MtbFileSender::class])
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.rest.uri=http://example.com",
|
||||
"app.pseudonymize.generator=buildin",
|
||||
"app.consent.service=none"
|
||||
]
|
||||
)
|
||||
class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
|
||||
|
||||
@Test
|
||||
@@ -59,14 +67,16 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.transformations[0].path=diagnoses[*].icd10.version",
|
||||
"app.pseudonymize.generator=buildin",
|
||||
"app.consent.service=none",
|
||||
"app.transformations[0].path=diagnoses[*].code.version",
|
||||
"app.transformations[0].from=2013",
|
||||
"app.transformations[0].to=2014",
|
||||
]
|
||||
)
|
||||
inner class TransformationTest {
|
||||
|
||||
@MockBean
|
||||
@MockitoBean
|
||||
private lateinit var mtbFileSender: MtbFileSender
|
||||
|
||||
@Autowired
|
||||
@@ -84,36 +94,33 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
|
||||
fun mtbFileIsTransformed() {
|
||||
doAnswer {
|
||||
MtbFileSender.Response(RequestStatus.SUCCESS)
|
||||
}.whenever(mtbFileSender).send(any<MtbFileSender.MtbFileRequest>())
|
||||
}.whenever(mtbFileSender).send(any<DnpmV2MtbFileRequest>())
|
||||
|
||||
val mtbFile = MtbFile.builder()
|
||||
.withPatient(
|
||||
val mtbFile = Mtb.builder()
|
||||
.patient(
|
||||
Patient.builder()
|
||||
.withId("TEST_12345678")
|
||||
.withBirthDate("2000-08-08")
|
||||
.withGender(Patient.Gender.MALE)
|
||||
.id("TEST_12345678")
|
||||
.build()
|
||||
)
|
||||
.withConsent(
|
||||
Consent.builder()
|
||||
.withId("1")
|
||||
.withStatus(Consent.Status.ACTIVE)
|
||||
.withPatient("TEST_12345678")
|
||||
.metadata(
|
||||
MvhMetadata
|
||||
.builder()
|
||||
.modelProjectConsent(
|
||||
ModelProjectConsent
|
||||
.builder()
|
||||
.provisions(
|
||||
listOf(Provision.builder().type(ConsentProvision.PERMIT).purpose(ModelProjectConsentPurpose.SEQUENCING).build())
|
||||
).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.withEpisode(
|
||||
Episode.builder()
|
||||
.withId("1")
|
||||
.withPatient("TEST_12345678")
|
||||
.withPeriod(PeriodStart("2023-08-08"))
|
||||
.build()
|
||||
)
|
||||
.withDiagnoses(
|
||||
.diagnoses(
|
||||
listOf(
|
||||
Diagnosis.builder()
|
||||
.withId("1234")
|
||||
.withIcd10(Icd10.builder().withCode("F79.9").withVersion("2013").build())
|
||||
.build()
|
||||
MtbDiagnosis.builder()
|
||||
.id("1234")
|
||||
.patient(Reference.builder().id("TEST_12345678").build())
|
||||
.code(Coding.builder().code("F79.9").version("2013").build())
|
||||
.build(),
|
||||
)
|
||||
)
|
||||
.build()
|
||||
@@ -127,10 +134,10 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
|
||||
}
|
||||
}
|
||||
|
||||
val captor = argumentCaptor<MtbFileSender.MtbFileRequest>()
|
||||
val captor = argumentCaptor<DnpmV2MtbFileRequest>()
|
||||
verify(mtbFileSender).send(captor.capture())
|
||||
assertThat(captor.firstValue.mtbFile.diagnoses).hasSize(1).allMatch { diagnosis ->
|
||||
diagnosis.icd10.version == "2014"
|
||||
assertThat(captor.firstValue.content.diagnoses).hasSize(1).allMatch { diagnosis ->
|
||||
diagnosis.code.version == "2014"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,73 @@
|
||||
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
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -20,9 +20,18 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.consent.ConsentEvaluator
|
||||
import dev.dnpm.etl.processor.consent.GicsConsentService
|
||||
import dev.dnpm.etl.processor.consent.MtbFileConsentService
|
||||
import dev.dnpm.etl.processor.input.KafkaInputListener
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.output.KafkaMtbFileSender
|
||||
import dev.dnpm.etl.processor.output.RestMtbFileSender
|
||||
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
|
||||
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
|
||||
import dev.dnpm.etl.processor.security.TokenRepository
|
||||
import dev.dnpm.etl.processor.security.TokenService
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -30,14 +39,31 @@ import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.beans.factory.NoSuchBeanDefinitionException
|
||||
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
|
||||
@SpringBootTest
|
||||
@ContextConfiguration(classes = [AppConfiguration::class, KafkaAutoConfiguration::class, AppKafkaConfiguration::class, AppRestConfiguration::class])
|
||||
@MockBean(ObjectMapper::class)
|
||||
@ContextConfiguration(
|
||||
classes = [
|
||||
AppConfiguration::class,
|
||||
AppSecurityConfiguration::class,
|
||||
KafkaAutoConfiguration::class,
|
||||
AppKafkaConfiguration::class,
|
||||
AppRestConfiguration::class,
|
||||
ConsentEvaluator::class
|
||||
]
|
||||
)
|
||||
@MockitoBean(types = [ObjectMapper::class])
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=BUILDIN",
|
||||
]
|
||||
)
|
||||
class AppConfigurationTest {
|
||||
|
||||
@Nested
|
||||
@@ -60,12 +86,12 @@ class AppConfigurationTest {
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.kafka.servers=localhost:9092",
|
||||
"app.kafka.topic=test",
|
||||
"app.kafka.response-topic=test-response",
|
||||
"app.kafka.output-topic=test",
|
||||
"app.kafka.output-response-topic=test-response",
|
||||
"app.kafka.group-id=test"
|
||||
]
|
||||
)
|
||||
@MockBean(RequestRepository::class)
|
||||
@MockitoBean(types = [RequestRepository::class])
|
||||
inner class AppConfigurationKafkaTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
@@ -81,8 +107,8 @@ class AppConfigurationTest {
|
||||
properties = [
|
||||
"app.rest.uri=http://localhost:9000",
|
||||
"app.kafka.servers=localhost:9092",
|
||||
"app.kafka.topic=test",
|
||||
"app.kafka.response-topic=test-response",
|
||||
"app.kafka.output-topic=test",
|
||||
"app.kafka.output-response-topic=test-response",
|
||||
"app.kafka.group-id=test"
|
||||
]
|
||||
)
|
||||
@@ -96,6 +122,44 @@ 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 = [
|
||||
@@ -116,4 +180,130 @@ class AppConfigurationTest {
|
||||
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.consent.service=GICS",
|
||||
"app.consent.gics.uri=http://localhost:9000",
|
||||
]
|
||||
)
|
||||
inner class AppConfigurationConsentGicsTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
fun shouldUseConfiguredGenerator() {
|
||||
assertThat(context.getBean(GicsConsentService::class.java)).isNotNull
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class AppConfigurationConsentBuildinTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
fun shouldUseConfiguredGenerator() {
|
||||
assertThat(context.getBean(MtbFileConsentService::class.java)).isNotNull
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
30
src/integrationTest/kotlin/dev/dnpm/etl/processor/helpers.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* 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 dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
||||
import dev.dnpm.etl.processor.consent.ConsentEvaluation
|
||||
import dev.dnpm.etl.processor.consent.ConsentEvaluator
|
||||
import dev.dnpm.etl.processor.consent.MtbFileConsentService
|
||||
import dev.dnpm.etl.processor.consent.TtpConsentStatus
|
||||
import dev.dnpm.etl.processor.security.TokenRepository
|
||||
import dev.dnpm.etl.processor.security.UserRoleRepository
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import dev.pcvolkmer.mv64e.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.junit.jupiter.MockitoExtension
|
||||
import org.mockito.kotlin.*
|
||||
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
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
@WebMvcTest(controllers = [MtbFileRestController::class])
|
||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
||||
@ContextConfiguration(
|
||||
classes = [
|
||||
MtbFileRestController::class,
|
||||
AppSecurityConfiguration::class,
|
||||
MtbFileConsentService::class
|
||||
]
|
||||
)
|
||||
@MockitoBean(types = [TokenRepository::class, RequestProcessor::class, ConsentEvaluator::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 {
|
||||
|
||||
lateinit var mockMvc: MockMvc
|
||||
lateinit var requestProcessor: RequestProcessor
|
||||
lateinit var consentEvaluator: ConsentEvaluator
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Autowired mockMvc: MockMvc,
|
||||
@Autowired requestProcessor: RequestProcessor,
|
||||
@Autowired consentEvaluator: ConsentEvaluator
|
||||
) {
|
||||
this.mockMvc = mockMvc
|
||||
this.requestProcessor = requestProcessor
|
||||
this.consentEvaluator = consentEvaluator
|
||||
|
||||
doAnswer {
|
||||
ConsentEvaluation(TtpConsentStatus.BROAD_CONSENT_GIVEN, true)
|
||||
}.whenever(consentEvaluator).check(any())
|
||||
}
|
||||
|
||||
@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<Mtb>())
|
||||
}
|
||||
|
||||
@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<Mtb>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldDenyPermissionToSendMtbFile() {
|
||||
mockMvc.post("/mtbfile") {
|
||||
with(anonymous())
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
||||
}.andExpect {
|
||||
status { isUnauthorized() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, never()).processMtbFile(any<Mtb>())
|
||||
}
|
||||
|
||||
@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<Mtb>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldGrantPermissionToDeletePatientData() {
|
||||
mockMvc.delete("/mtbfile/12345678") {
|
||||
with(user("onkostarserver").roles("MTBFILE"))
|
||||
}.andExpect {
|
||||
status { isAccepted() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, times(1)).processDeletion(anyValueClass(), eq(TtpConsentStatus.UNKNOWN_CHECK_FILE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldDenyPermissionToDeletePatientData() {
|
||||
mockMvc.delete("/mtbfile/12345678") {
|
||||
with(anonymous())
|
||||
}.andExpect {
|
||||
status { isUnauthorized() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, never()).processDeletion(anyValueClass(), any())
|
||||
}
|
||||
|
||||
@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<Mtb>())
|
||||
}
|
||||
|
||||
@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<Mtb>())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val mtbFile = Mtb.builder()
|
||||
.patient(
|
||||
Patient.builder()
|
||||
.id("PID")
|
||||
.build()
|
||||
)
|
||||
.episodesOfCare(
|
||||
listOf(
|
||||
MtbEpisodeOfCare.builder()
|
||||
.id("1")
|
||||
.patient(Reference.builder().id("PID").build())
|
||||
.period(PeriodDate.builder().start(Date.from(Instant.parse("2023-08-08T02:00:00.00Z"))).build())
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.AppFhirConfig
|
||||
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
|
||||
private var appFhirConfig: AppFhirConfig = AppFhirConfig()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
|
||||
val gPasConfigProperties = GPasConfigProperties(
|
||||
"https://localhost:9990/ttp-fhir/fhir/gpas",
|
||||
"test", "test2",
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
this.restTemplate = RestTemplate()
|
||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
||||
this.generator =
|
||||
GpasPseudonymGenerator(gPasConfigProperties, retryTemplate, restTemplate, appFhirConfig)
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
import dev.dnpm.etl.processor.AbstractTestcontainerTest
|
||||
import dev.dnpm.etl.processor.*
|
||||
import dev.dnpm.etl.processor.monitoring.Request
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
@@ -31,18 +31,24 @@ import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
@Testcontainers
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
@MockBean(MtbFileSender::class)
|
||||
@MockitoBean(types = [MtbFileSender::class])
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=buildin",
|
||||
"app.rest.uri=http://example.com"
|
||||
]
|
||||
)
|
||||
class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
|
||||
private lateinit var requestRepository: RequestRepository
|
||||
@@ -59,7 +65,7 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
|
||||
@Test
|
||||
fun shouldResultInEmptyRequestList() {
|
||||
val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
|
||||
val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
|
||||
|
||||
assertThat(actual).isEmpty()
|
||||
}
|
||||
@@ -69,33 +75,33 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
this.requestRepository.saveAll(
|
||||
listOf(
|
||||
Request(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
patientId = "TEST_12345678901",
|
||||
pid = "P1",
|
||||
fingerprint = "0123456789abcdef1",
|
||||
type = RequestType.MTB_FILE,
|
||||
status = RequestStatus.SUCCESS,
|
||||
processedAt = Instant.parse("2023-07-07T02:00:00Z")
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678901"),
|
||||
PatientId("P1"),
|
||||
Fingerprint("0123456789abcdef1"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.SUCCESS,
|
||||
Instant.parse("2023-07-07T02:00:00Z")
|
||||
),
|
||||
// Should be ignored - wrong patient ID -->
|
||||
Request(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
patientId = "TEST_12345678902",
|
||||
pid = "P2",
|
||||
fingerprint = "0123456789abcdef2",
|
||||
type = RequestType.MTB_FILE,
|
||||
status = RequestStatus.WARNING,
|
||||
processedAt = Instant.parse("2023-08-08T00:00:00Z")
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678902"),
|
||||
PatientId("P2"),
|
||||
Fingerprint("0123456789abcdef2"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.WARNING,
|
||||
Instant.parse("2023-08-08T00:00:00Z")
|
||||
),
|
||||
// <--
|
||||
Request(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
patientId = "TEST_12345678901",
|
||||
pid = "P2",
|
||||
fingerprint = "0123456789abcdee1",
|
||||
type = RequestType.DELETE,
|
||||
status = RequestStatus.SUCCESS,
|
||||
processedAt = Instant.parse("2023-08-08T02:00:00Z")
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678901"),
|
||||
PatientId("P2"),
|
||||
Fingerprint("0123456789abcdee1"),
|
||||
RequestType.DELETE,
|
||||
RequestStatus.SUCCESS,
|
||||
Instant.parse("2023-08-08T02:00:00Z")
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -105,18 +111,18 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
fun shouldResultInSortedRequestList() {
|
||||
setupTestData()
|
||||
|
||||
val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
|
||||
val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
|
||||
|
||||
assertThat(actual).hasSize(2)
|
||||
assertThat(actual[0].fingerprint).isEqualTo("0123456789abcdee1")
|
||||
assertThat(actual[1].fingerprint).isEqualTo("0123456789abcdef1")
|
||||
assertThat(actual[0].fingerprint).isEqualTo(Fingerprint("0123456789abcdee1"))
|
||||
assertThat(actual[1].fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldReturnDeleteRequestAsLastRequest() {
|
||||
setupTestData()
|
||||
|
||||
val actual = requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901")
|
||||
val actual = requestService.isLastRequestWithKnownStatusDeletion(TEST_PATIENT_PSEUDONYM)
|
||||
|
||||
assertThat(actual).isTrue()
|
||||
}
|
||||
@@ -125,10 +131,14 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
fun shouldReturnLastMtbFileRequest() {
|
||||
setupTestData()
|
||||
|
||||
val actual = requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901")
|
||||
val actual = requestService.lastMtbFileRequestForPatientPseudonym(TEST_PATIENT_PSEUDONYM)
|
||||
|
||||
assertThat(actual).isNotNull
|
||||
assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef1")
|
||||
assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("TEST_12345678901")
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* 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.GIcsConnectionCheckService
|
||||
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,
|
||||
GIcsConnectionCheckService::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 testShouldRequestGPasSSE() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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")
|
||||
}
|
||||
}
|
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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") }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
public enum ConsentDomain {
|
||||
/**
|
||||
* MII Broad consent
|
||||
*/
|
||||
BROAD_CONSENT,
|
||||
|
||||
/**
|
||||
* GenomDe Modellvorhaben §64e
|
||||
*/
|
||||
MODELLVORHABEN_64E
|
||||
}
|
@@ -0,0 +1,307 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext;
|
||||
import ca.uhn.fhir.parser.DataFormatException;
|
||||
import dev.dnpm.etl.processor.config.AppFhirConfig;
|
||||
import dev.dnpm.etl.processor.config.GIcsConfigProperties;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.hl7.fhir.r4.model.*;
|
||||
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.retry.TerminatedRetryException;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Service to request Consent from remote gICS installation
|
||||
*
|
||||
* @since 0.11
|
||||
*/
|
||||
public class GicsConsentService implements IConsentService {
|
||||
|
||||
private final Logger log = LoggerFactory.getLogger(GicsConsentService.class);
|
||||
|
||||
public static final String IS_CONSENTED_ENDPOINT = "/$isConsented";
|
||||
public static final String IS_POLICY_STATES_FOR_PERSON_ENDPOINT = "/$currentPolicyStatesForPerson";
|
||||
|
||||
private final RetryTemplate retryTemplate;
|
||||
private final RestTemplate restTemplate;
|
||||
private final FhirContext fhirContext;
|
||||
private final GIcsConfigProperties gIcsConfigProperties;
|
||||
|
||||
public GicsConsentService(
|
||||
GIcsConfigProperties gIcsConfigProperties,
|
||||
RetryTemplate retryTemplate,
|
||||
RestTemplate restTemplate,
|
||||
AppFhirConfig appFhirConfig
|
||||
) {
|
||||
this.retryTemplate = retryTemplate;
|
||||
this.restTemplate = restTemplate;
|
||||
this.fhirContext = appFhirConfig.fhirContext();
|
||||
this.gIcsConfigProperties = gIcsConfigProperties;
|
||||
log.info("GicsConsentService initialized...");
|
||||
}
|
||||
|
||||
protected Parameters getFhirRequestParameters(
|
||||
String personIdentifierValue
|
||||
) {
|
||||
var result = new Parameters();
|
||||
result.addParameter(
|
||||
new ParametersParameterComponent()
|
||||
.setName("personIdentifier")
|
||||
.setValue(
|
||||
new Identifier()
|
||||
.setValue(personIdentifierValue)
|
||||
.setSystem(this.gIcsConfigProperties.getPersonIdentifierSystem())
|
||||
)
|
||||
);
|
||||
result.addParameter(
|
||||
new ParametersParameterComponent()
|
||||
.setName("domain")
|
||||
.setValue(
|
||||
new StringType()
|
||||
.setValue(this.gIcsConfigProperties.getBroadConsentDomainName())
|
||||
)
|
||||
);
|
||||
result.addParameter(
|
||||
new ParametersParameterComponent()
|
||||
.setName("policy")
|
||||
.setValue(
|
||||
new Coding()
|
||||
.setCode(this.gIcsConfigProperties.getBroadConsentPolicyCode())
|
||||
.setSystem(this.gIcsConfigProperties.getBroadConsentPolicySystem())
|
||||
)
|
||||
);
|
||||
|
||||
/*
|
||||
* is mandatory parameter, but we ignore it via additional configuration parameter
|
||||
* 'ignoreVersionNumber'.
|
||||
*/
|
||||
result.addParameter(
|
||||
new ParametersParameterComponent()
|
||||
.setName("version")
|
||||
.setValue(new StringType().setValue("1.1")
|
||||
)
|
||||
);
|
||||
|
||||
/* add config parameter with:
|
||||
* ignoreVersionNumber -> true ->> Reason is we cannot know which policy version each patient
|
||||
* has possibly signed or not, therefore we are happy with any version found.
|
||||
* unknownStateIsConsideredAsDecline -> true
|
||||
*/
|
||||
var config = new ParametersParameterComponent()
|
||||
.setName("config")
|
||||
.addPart(
|
||||
new ParametersParameterComponent()
|
||||
.setName("ignoreVersionNumber")
|
||||
.setValue(new BooleanType().setValue(true))
|
||||
)
|
||||
.addPart(
|
||||
new ParametersParameterComponent()
|
||||
.setName("unknownStateIsConsideredAsDecline")
|
||||
.setValue(new BooleanType().setValue(false))
|
||||
);
|
||||
|
||||
result.addParameter(config);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private URI endpointUri(String endpoint) {
|
||||
assert this.gIcsConfigProperties.getUri() != null;
|
||||
return UriComponentsBuilder.fromUriString(this.gIcsConfigProperties.getUri()).path(endpoint).build().toUri();
|
||||
}
|
||||
|
||||
private HttpHeaders headersWithHttpBasicAuth() {
|
||||
assert this.gIcsConfigProperties.getUri() != null;
|
||||
|
||||
var headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_XML);
|
||||
|
||||
if (
|
||||
StringUtils.isBlank(this.gIcsConfigProperties.getUsername())
|
||||
|| StringUtils.isBlank(this.gIcsConfigProperties.getPassword())
|
||||
) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
headers.setBasicAuth(this.gIcsConfigProperties.getUsername(), this.gIcsConfigProperties.getPassword());
|
||||
return headers;
|
||||
}
|
||||
|
||||
protected String callGicsApi(Parameters parameter, String endpoint) {
|
||||
var parameterAsXml = fhirContext.newXmlParser().encodeResourceToString(parameter);
|
||||
HttpEntity<String> requestEntity = new HttpEntity<>(parameterAsXml, this.headersWithHttpBasicAuth());
|
||||
try {
|
||||
var responseEntity = retryTemplate.execute(
|
||||
ctx -> restTemplate.exchange(endpointUri(endpoint), HttpMethod.POST, requestEntity, String.class)
|
||||
);
|
||||
|
||||
if (responseEntity.getStatusCode().is2xxSuccessful()) {
|
||||
return responseEntity.getBody();
|
||||
} else {
|
||||
var msg = String.format(
|
||||
"Trusted party system reached but request failed! code: '%s' response: '%s'",
|
||||
responseEntity.getStatusCode(), responseEntity.getBody());
|
||||
log.error(msg);
|
||||
return null;
|
||||
}
|
||||
} catch (RestClientException e) {
|
||||
var msg = String.format("Get consents status request failed reason: '%s",
|
||||
e.getMessage());
|
||||
log.error(msg);
|
||||
return null;
|
||||
|
||||
} catch (TerminatedRetryException terminatedRetryException) {
|
||||
var msg = String.format(
|
||||
"Get consents status process has been terminated. termination reason: '%s",
|
||||
terminatedRetryException.getMessage());
|
||||
log.error(msg);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) {
|
||||
var consentStatusResponse = callGicsApi(
|
||||
getFhirRequestParameters(personIdentifierValue),
|
||||
GicsConsentService.IS_CONSENTED_ENDPOINT
|
||||
);
|
||||
return evaluateConsentResponse(consentStatusResponse);
|
||||
}
|
||||
|
||||
protected Bundle currentConsentForPersonAndTemplate(
|
||||
String personIdentifierValue,
|
||||
ConsentDomain consentDomain,
|
||||
Date requestDate
|
||||
) {
|
||||
|
||||
var requestParameter = buildRequestParameterCurrentPolicyStatesForPerson(
|
||||
personIdentifierValue,
|
||||
requestDate,
|
||||
consentDomain
|
||||
);
|
||||
|
||||
var consentDataSerialized = callGicsApi(requestParameter,
|
||||
GicsConsentService.IS_POLICY_STATES_FOR_PERSON_ENDPOINT);
|
||||
|
||||
if (consentDataSerialized == null) {
|
||||
// error occurred - should not process further!
|
||||
throw new IllegalStateException(
|
||||
"consent data request failed - stopping processing! - try again or fix other problems first.");
|
||||
}
|
||||
var iBaseResource = fhirContext.newJsonParser()
|
||||
.parseResource(consentDataSerialized);
|
||||
if (iBaseResource instanceof OperationOutcome) {
|
||||
// log error - very likely a configuration error
|
||||
String errorMessage =
|
||||
"Consent request failed! Check outcome:\n " + consentDataSerialized;
|
||||
log.error(errorMessage);
|
||||
throw new IllegalStateException(errorMessage);
|
||||
} else if (iBaseResource instanceof Bundle bundle) {
|
||||
return bundle;
|
||||
} else {
|
||||
String errorMessage = "Consent request failed! Unexpected response received! -> "
|
||||
+ consentDataSerialized;
|
||||
log.error(errorMessage);
|
||||
throw new IllegalStateException(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private String getConsentDomainName(ConsentDomain targetConsentDomain) {
|
||||
return switch (targetConsentDomain) {
|
||||
case BROAD_CONSENT -> gIcsConfigProperties.getBroadConsentDomainName();
|
||||
case MODELLVORHABEN_64E -> gIcsConfigProperties.getGenomDeConsentDomainName();
|
||||
};
|
||||
}
|
||||
|
||||
protected Parameters buildRequestParameterCurrentPolicyStatesForPerson(
|
||||
String personIdentifierValue,
|
||||
Date requestDate,
|
||||
ConsentDomain consentDomain
|
||||
) {
|
||||
var requestParameter = new Parameters();
|
||||
requestParameter.addParameter(
|
||||
new ParametersParameterComponent()
|
||||
.setName("personIdentifier")
|
||||
.setValue(
|
||||
new Identifier()
|
||||
.setValue(personIdentifierValue)
|
||||
.setSystem(this.gIcsConfigProperties.getPersonIdentifierSystem())
|
||||
)
|
||||
);
|
||||
|
||||
requestParameter.addParameter(
|
||||
new ParametersParameterComponent()
|
||||
.setName("domain")
|
||||
.setValue(new StringType().setValue(getConsentDomainName(consentDomain)))
|
||||
);
|
||||
|
||||
Parameters nestedConfigParameters = new Parameters();
|
||||
nestedConfigParameters
|
||||
.addParameter(
|
||||
new ParametersParameterComponent()
|
||||
.setName("idMatchingType")
|
||||
.setValue(new Coding()
|
||||
.setSystem("https://ths-greifswald.de/fhir/CodeSystem/gics/IdMatchingType")
|
||||
.setCode("AT_LEAST_ONE")
|
||||
)
|
||||
)
|
||||
.addParameter("ignoreVersionNumber", false)
|
||||
.addParameter("unknownStateIsConsideredAsDecline", false)
|
||||
.addParameter("requestDate", new DateType().setValue(requestDate));
|
||||
|
||||
requestParameter.addParameter(
|
||||
new ParametersParameterComponent().setName("config").addPart().setResource(nestedConfigParameters)
|
||||
);
|
||||
|
||||
return requestParameter;
|
||||
}
|
||||
|
||||
private TtpConsentStatus evaluateConsentResponse(String consentStatusResponse) {
|
||||
if (consentStatusResponse == null) {
|
||||
return TtpConsentStatus.FAILED_TO_ASK;
|
||||
}
|
||||
try {
|
||||
var response = fhirContext.newJsonParser().parseResource(consentStatusResponse);
|
||||
|
||||
if (response instanceof Parameters responseParameters) {
|
||||
|
||||
var responseValue = responseParameters.getParameter("consented").getValue();
|
||||
var isConsented = responseValue.castToBoolean(responseValue);
|
||||
if (!isConsented.hasValue()) {
|
||||
return TtpConsentStatus.FAILED_TO_ASK;
|
||||
}
|
||||
if (isConsented.booleanValue()) {
|
||||
return TtpConsentStatus.BROAD_CONSENT_GIVEN;
|
||||
} else {
|
||||
return TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED;
|
||||
}
|
||||
} else if (response instanceof OperationOutcome outcome) {
|
||||
log.error("failed to get consent status from ttp. probably configuration error. "
|
||||
+ "outcome: '{}'", fhirContext.newJsonParser().encodeToString(outcome));
|
||||
|
||||
}
|
||||
} catch (DataFormatException dfe) {
|
||||
log.error("failed to parse response to FHIR R4 resource.", dfe);
|
||||
}
|
||||
return TtpConsentStatus.FAILED_TO_ASK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle getConsent(String patientId, Date requestDate, ConsentDomain consentDomain) {
|
||||
return currentConsentForPersonAndTemplate(patientId, consentDomain, requestDate);
|
||||
}
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
import java.util.Date;
|
||||
import org.hl7.fhir.r4.model.Bundle;
|
||||
|
||||
public interface IConsentService {
|
||||
|
||||
/**
|
||||
* Get broad consent status for a patient identifier
|
||||
*
|
||||
* @param personIdentifierValue patient identifier used for consent data
|
||||
* @return status of broad consent
|
||||
* @apiNote cannot not differ between not asked and rejected
|
||||
*
|
||||
*/
|
||||
TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue);
|
||||
|
||||
/**
|
||||
* Get broad consent policies with respect to a request date
|
||||
*
|
||||
* @param personIdentifierValue patient identifier used for consent data
|
||||
* @param requestDate target date until consent data should be considered
|
||||
* @return consent policies as bundle; <p>if empty patient has not been asked, yet.</p>
|
||||
*/
|
||||
Bundle getConsent(String personIdentifierValue, Date requestDate, ConsentDomain consentDomain);
|
||||
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
import java.util.Date;
|
||||
import org.hl7.fhir.r4.model.Bundle;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class MtbFileConsentService implements IConsentService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MtbFileConsentService.class);
|
||||
|
||||
public MtbFileConsentService() {
|
||||
log.info("ConsentCheckFileBased initialized...");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) {
|
||||
return TtpConsentStatus.UNKNOWN_CHECK_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* EMPTY METHOD: NOT IMPLEMENTED
|
||||
*
|
||||
* @return empty bundle
|
||||
*/
|
||||
@Override
|
||||
public Bundle getConsent(String personIdentifierValue, Date requestDate,
|
||||
ConsentDomain consentDomain) {
|
||||
return new Bundle();
|
||||
}
|
||||
}
|
@@ -0,0 +1,38 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
public enum TtpConsentStatus {
|
||||
/**
|
||||
* Valid consent found
|
||||
*/
|
||||
BROAD_CONSENT_GIVEN,
|
||||
/**
|
||||
* Missing or rejected...actually unknown
|
||||
*/
|
||||
BROAD_CONSENT_MISSING_OR_REJECTED,
|
||||
/**
|
||||
* No Broad consent policy found
|
||||
*/
|
||||
BROAD_CONSENT_MISSING,
|
||||
/**
|
||||
* Research policy has been rejected
|
||||
*/
|
||||
BROAD_CONSENT_REJECTED,
|
||||
|
||||
GENOM_DE_CONSENT_SEQUENCING_PERMIT,
|
||||
/**
|
||||
* No GenomDE consent policy found
|
||||
*/
|
||||
GENOM_DE_CONSENT_MISSING,
|
||||
/**
|
||||
* GenomDE consent policy found, but has been rejected
|
||||
*/
|
||||
GENOM_DE_SEQUENCING_REJECTED,
|
||||
/**
|
||||
* Consent status is validate via file property 'consent.status'
|
||||
*/
|
||||
UNKNOWN_CHECK_FILE,
|
||||
/**
|
||||
* Due technical problems consent status is unknown
|
||||
*/
|
||||
FAILED_TO_ASK
|
||||
}
|
@@ -23,4 +23,6 @@ public interface Generator {
|
||||
|
||||
String generate(String id);
|
||||
|
||||
String generateGenomDeTan(String id);
|
||||
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 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
|
||||
@@ -21,100 +21,92 @@ package dev.dnpm.etl.processor.pseudonym;
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext;
|
||||
import ca.uhn.fhir.parser.IParser;
|
||||
import dev.dnpm.etl.processor.config.AppFhirConfig;
|
||||
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 java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import org.apache.commons.lang3.NotImplementedException;
|
||||
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.apache.hc.core5.net.URIBuilder;
|
||||
import org.hl7.fhir.r4.model.Identifier;
|
||||
import org.hl7.fhir.r4.model.Parameters;
|
||||
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
|
||||
import org.hl7.fhir.r4.model.StringType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.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.http.*;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.web.client.HttpClientErrorException.BadRequest;
|
||||
import org.springframework.web.client.HttpClientErrorException.Unauthorized;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
public class GpasPseudonymGenerator implements Generator {
|
||||
|
||||
private final static FhirContext r4Context = FhirContext.forR4();
|
||||
private final FhirContext r4Context;
|
||||
private final String gPasUrl;
|
||||
private final String psnTargetDomain;
|
||||
private final HttpHeaders httpHeader;
|
||||
private final RetryTemplate retryTemplate = defaultTemplate();
|
||||
private final RetryTemplate retryTemplate;
|
||||
private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class);
|
||||
private final RestTemplate restTemplate;
|
||||
private final @NotNull String genomDeTanDomain;
|
||||
private final @NotNull String pidPsnDomain;
|
||||
protected final static String createOrGetPsn = "$pseudonymizeAllowCreate";
|
||||
protected final static String createMultiDomainPsn = "$pseudonymize-secondary";
|
||||
private final static String SINGLE_PSN_PART_NAME = "pseudonym";
|
||||
private final static String MULTI_PSN_PART_NAME = "value";
|
||||
|
||||
private SSLContext customSslContext;
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg) {
|
||||
|
||||
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate,
|
||||
RestTemplate restTemplate, AppFhirConfig appFhirConfig) {
|
||||
this.retryTemplate = retryTemplate;
|
||||
this.restTemplate = restTemplate;
|
||||
this.gPasUrl = gpasCfg.getUri();
|
||||
this.psnTargetDomain = gpasCfg.getTarget();
|
||||
this.pidPsnDomain = gpasCfg.getPatientDomain();
|
||||
this.genomDeTanDomain = gpasCfg.getGenomDeTanDomain();
|
||||
this.r4Context = appFhirConfig.fhirContext();
|
||||
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("{} has been initialized", this.getClass().getName());
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generate(String id) {
|
||||
var gPasRequestBody = getGpasRequestBody(id);
|
||||
var responseEntity = getGpasPseudonym(gPasRequestBody);
|
||||
var gPasPseudonymResult = (Parameters) r4Context.newJsonParser()
|
||||
.parseResource(responseEntity.getBody());
|
||||
return generate(id, PsnDomainType.SINGLE_PSN_DOMAIN);
|
||||
}
|
||||
|
||||
return unwrapPseudonym(gPasPseudonymResult);
|
||||
@Override
|
||||
public String generateGenomDeTan(String id) {
|
||||
return generate(id, PsnDomainType.MULTI_PSN_DOMAIN);
|
||||
}
|
||||
|
||||
protected String generate(String id, PsnDomainType domainType) {
|
||||
switch (domainType) {
|
||||
case SINGLE_PSN_DOMAIN -> {
|
||||
final var requestBody = createSinglePsnRequestBody(id, pidPsnDomain);
|
||||
final var responseEntity = getGpasPseudonym(requestBody, createOrGetPsn);
|
||||
final var gPasPseudonymResult = (Parameters) r4Context.newJsonParser()
|
||||
.parseResource(responseEntity.getBody());
|
||||
|
||||
return unwrapPseudonym(gPasPseudonymResult, SINGLE_PSN_PART_NAME);
|
||||
}
|
||||
case MULTI_PSN_DOMAIN -> {
|
||||
final var requestBody = createMultiPsnRequestBody(id, genomDeTanDomain);
|
||||
final var responseEntity = getGpasPseudonym(requestBody, createMultiDomainPsn);
|
||||
final var gPasPseudonymResult = (Parameters) r4Context.newJsonParser()
|
||||
.parseResource(responseEntity.getBody());
|
||||
|
||||
return unwrapPseudonym(gPasPseudonymResult, MULTI_PSN_PART_NAME);
|
||||
}
|
||||
}
|
||||
throw new NotImplementedException(
|
||||
"give domain type '%s' is unexpected and is currently not supported!".formatted(
|
||||
domainType));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static String unwrapPseudonym(Parameters gPasPseudonymResult) {
|
||||
public static String unwrapPseudonym(Parameters gPasPseudonymResult, String targetPartName) {
|
||||
final var parameters = gPasPseudonymResult.getParameter().stream().findFirst();
|
||||
|
||||
if (parameters.isEmpty()) {
|
||||
@@ -122,7 +114,7 @@ public class GpasPseudonymGenerator implements Generator {
|
||||
}
|
||||
|
||||
final var identifier = (Identifier) parameters.get().getPart().stream()
|
||||
.filter(a -> a.getName().equals("pseudonym"))
|
||||
.filter(a -> a.getName().equals(targetPartName))
|
||||
.findFirst()
|
||||
.orElseGet(ParametersParameterComponent::new).getValue();
|
||||
|
||||
@@ -144,43 +136,80 @@ public class GpasPseudonymGenerator implements Generator {
|
||||
return psnValue.replaceAll(forbiddenCharsRegex, "_");
|
||||
}
|
||||
|
||||
|
||||
@NotNull
|
||||
protected ResponseEntity<String> getGpasPseudonym(String gPasRequestBody) {
|
||||
protected ResponseEntity<String> getGpasPseudonym(String gPasRequestBody, String apiEndpoint) {
|
||||
|
||||
HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader);
|
||||
ResponseEntity<String> responseEntity;
|
||||
var restTemplate = getRestTemplete();
|
||||
|
||||
try {
|
||||
responseEntity = retryTemplate.execute(
|
||||
ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity,
|
||||
var targetUrl = buildRequestUrl(apiEndpoint);
|
||||
ResponseEntity<String> responseEntity = retryTemplate.execute(
|
||||
ctx -> restTemplate.exchange(targetUrl, HttpMethod.POST, requestEntity,
|
||||
String.class));
|
||||
|
||||
if (responseEntity.getStatusCode().is2xxSuccessful()) {
|
||||
log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode());
|
||||
} else {
|
||||
log.warn("API request unsuccessful. Response: {}", requestEntity.getBody());
|
||||
throw new PseudonymRequestFailed("API request unsuccessful gPas unsuccessful.");
|
||||
return responseEntity;
|
||||
}
|
||||
} catch (RestClientException rce) {
|
||||
if (rce instanceof BadRequest) {
|
||||
String msg = "gPas or request configuration is incorrect. Please check both."
|
||||
+ rce.getMessage();
|
||||
log.debug(
|
||||
msg);
|
||||
throw new PseudonymRequestFailed(msg, rce);
|
||||
}
|
||||
if (rce instanceof Unauthorized) {
|
||||
var msg = "gPas access credentials are invalid check your configuration. msg: '%s".formatted(
|
||||
rce.getMessage());
|
||||
log.error(msg);
|
||||
throw new PseudonymRequestFailed(msg, rce);
|
||||
}
|
||||
|
||||
return responseEntity;
|
||||
} catch (Exception unexpected) {
|
||||
throw new PseudonymRequestFailed(
|
||||
"API request due unexpected error unsuccessful gPas unsuccessful.", unexpected);
|
||||
}
|
||||
throw new PseudonymRequestFailed(
|
||||
"API request due unexpected error unsuccessful gPas unsuccessful.");
|
||||
|
||||
}
|
||||
|
||||
protected String getGpasRequestBody(String id) {
|
||||
var requestParameters = new Parameters();
|
||||
protected URI buildRequestUrl(String apiEndpoint) throws URISyntaxException {
|
||||
var gPasUrl1 = gPasUrl;
|
||||
if (gPasUrl.lastIndexOf("/") == gPasUrl.length() - 1) {
|
||||
gPasUrl1 = gPasUrl.substring(0, gPasUrl.length() - 1);
|
||||
}
|
||||
var urlBuilder = new URIBuilder(new URI(gPasUrl1)).appendPath(apiEndpoint);
|
||||
|
||||
return urlBuilder.build();
|
||||
}
|
||||
|
||||
protected String createSinglePsnRequestBody(String id, String targetDomain) {
|
||||
final var requestParameters = new Parameters();
|
||||
requestParameters.addParameter().setName("target")
|
||||
.setValue(new StringType().setValue(psnTargetDomain));
|
||||
.setValue(new StringType().setValue(targetDomain));
|
||||
requestParameters.addParameter().setName("original")
|
||||
.setValue(new StringType().setValue(id));
|
||||
final IParser iParser = r4Context.newJsonParser();
|
||||
return iParser.encodeResourceToString(requestParameters);
|
||||
}
|
||||
|
||||
protected String createMultiPsnRequestBody(String id, String targetDomain) {
|
||||
final var param = new Parameters();
|
||||
ParametersParameterComponent targetParam = param.addParameter().setName("original");
|
||||
targetParam.addPart(
|
||||
new ParametersParameterComponent().setName("target")
|
||||
.setValue(new StringType(targetDomain)));
|
||||
targetParam.addPart(
|
||||
new ParametersParameterComponent().setName("value").setValue(new StringType(id)));
|
||||
targetParam
|
||||
.addPart(new ParametersParameterComponent().setName("count").setValue(
|
||||
new StringType("1")));
|
||||
|
||||
final IParser iParser = r4Context.newJsonParser();
|
||||
return iParser.encodeResourceToString(param);
|
||||
}
|
||||
|
||||
|
||||
@NotNull
|
||||
protected HttpHeaders getHttpHeaders(String gPasUserName, String gPasPassword) {
|
||||
var headers = new HttpHeaders();
|
||||
@@ -190,99 +219,7 @@ public class GpasPseudonymGenerator implements Generator {
|
||||
return headers;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
headers.setBasicAuth(gPasUserName, gPasPassword);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,12 @@
|
||||
package dev.dnpm.etl.processor.pseudonym;
|
||||
|
||||
public enum PsnDomainType {
|
||||
/**
|
||||
* one pseudonym per original value
|
||||
*/
|
||||
SINGLE_PSN_DOMAIN,
|
||||
/**
|
||||
* multiple pseudonymes for one original value
|
||||
*/
|
||||
MULTI_PSN_DOMAIN
|
||||
}
|
@@ -20,9 +20,10 @@
|
||||
package dev.dnpm.etl.processor
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
@SpringBootApplication(exclude = [SecurityAutoConfiguration::class])
|
||||
class EtlProcessorApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -19,13 +19,15 @@
|
||||
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import dev.dnpm.etl.processor.security.Role
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
|
||||
@ConfigurationProperties(AppConfigProperties.NAME)
|
||||
data class AppConfigProperties(
|
||||
var bwhcUri: String?,
|
||||
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN,
|
||||
var transformations: List<TransformationProperties> = listOf()
|
||||
var transformations: List<TransformationProperties> = listOf(),
|
||||
var maxRetryAttempts: Int = 3,
|
||||
var duplicationDetection: Boolean = true,
|
||||
var genomDeTestSubmission: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app"
|
||||
@@ -34,6 +36,7 @@ data class AppConfigProperties(
|
||||
|
||||
@ConfigurationProperties(PseudonymizeConfigProperties.NAME)
|
||||
data class PseudonymizeConfigProperties(
|
||||
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN,
|
||||
val prefix: String = "UNKNOWN",
|
||||
) {
|
||||
companion object {
|
||||
@@ -44,31 +47,99 @@ data class PseudonymizeConfigProperties(
|
||||
@ConfigurationProperties(GPasConfigProperties.NAME)
|
||||
data class GPasConfigProperties(
|
||||
val uri: String?,
|
||||
val target: String = "etl-processor",
|
||||
val patientDomain: String = "etl-processor",
|
||||
val genomDeTanDomain: String = "ccdn",
|
||||
val username: String?,
|
||||
val password: String?,
|
||||
val sslCaLocation: String?,
|
||||
|
||||
) {
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app.pseudonymize.gpas"
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(ConsentConfigProperties.NAME)
|
||||
data class ConsentConfigProperties(
|
||||
var service: ConsentService = ConsentService.NONE
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app.consent"
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(GIcsConfigProperties.NAME)
|
||||
data class GIcsConfigProperties(
|
||||
/**
|
||||
* Base URL to gICS System
|
||||
*
|
||||
*/
|
||||
val uri: String?,
|
||||
val username: String? = null,
|
||||
val password: String? = null,
|
||||
|
||||
/**
|
||||
* gICS specific system
|
||||
* **/
|
||||
val personIdentifierSystem: String =
|
||||
"https://ths-greifswald.de/fhir/gics/identifiers/Patienten-ID",
|
||||
|
||||
/**
|
||||
* Domain of broad consent resources
|
||||
**/
|
||||
val broadConsentDomainName: String = "MII",
|
||||
|
||||
/**
|
||||
* Domain of Modelvorhaben 64e consent resources
|
||||
**/
|
||||
val genomDeConsentDomainName: String = "GenomDE_MV",
|
||||
|
||||
/**
|
||||
* Value to expect in case of positiv consent
|
||||
*/
|
||||
val broadConsentPolicyCode: String = "2.16.840.1.113883.3.1937.777.24.5.3.6",
|
||||
|
||||
/**
|
||||
* Consent Policy which should be used for consent check
|
||||
*/
|
||||
val broadConsentPolicySystem: String = "urn:oid:2.16.840.1.113883.3.1937.777.24.5.3",
|
||||
|
||||
/**
|
||||
* Value to expect in case of positiv consent
|
||||
*/
|
||||
val genomeDePolicyCode: String = "sequencing",
|
||||
|
||||
/**
|
||||
* Consent Policy which should be used for consent check
|
||||
*/
|
||||
val genomeDePolicySystem: String = "https://ths-greifswald.de/fhir/CodeSystem/gics/Policy/GenomDE_MV",
|
||||
|
||||
/**
|
||||
* Consent version (fixed version)
|
||||
*
|
||||
*/
|
||||
val genomeDeConsentVersion: String = "2.0"
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app.consent.gics"
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(RestTargetProperties.NAME)
|
||||
data class RestTargetProperties(
|
||||
val uri: String?,
|
||||
val username: String?,
|
||||
val password: String?
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app.rest"
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(KafkaTargetProperties.NAME)
|
||||
data class KafkaTargetProperties(
|
||||
val topic: String = "etl-processor",
|
||||
val responseTopic: String = "${topic}_response",
|
||||
val groupId: String = "${topic}_group",
|
||||
@ConfigurationProperties(KafkaProperties.NAME)
|
||||
data class KafkaProperties(
|
||||
val inputTopic: String?,
|
||||
val outputTopic: String = "etl-processor",
|
||||
val outputResponseTopic: String = "${outputTopic}_response",
|
||||
val groupId: String = "${outputTopic}_group",
|
||||
val servers: String = ""
|
||||
) {
|
||||
companion object {
|
||||
@@ -76,11 +147,29 @@ data class KafkaTargetProperties(
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
BUILDIN,
|
||||
GPAS
|
||||
}
|
||||
|
||||
enum class ConsentService {
|
||||
NONE,
|
||||
GICS
|
||||
}
|
||||
|
||||
data class TransformationProperties(
|
||||
val path: String,
|
||||
val from: String,
|
||||
|
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -20,41 +20,90 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||
import dev.dnpm.etl.processor.consent.MtbFileConsentService
|
||||
import dev.dnpm.etl.processor.consent.GicsConsentService
|
||||
import dev.dnpm.etl.processor.consent.IConsentService
|
||||
import dev.dnpm.etl.processor.monitoring.*
|
||||
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
|
||||
import dev.dnpm.etl.processor.pseudonym.Generator
|
||||
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
|
||||
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.ConsentProcessor
|
||||
import dev.dnpm.etl.processor.services.Transformation
|
||||
import dev.dnpm.etl.processor.services.TransformationService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.AnyNestedCondition
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
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.Conditional
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.context.annotation.ConfigurationCondition
|
||||
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 kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(
|
||||
value = [
|
||||
AppConfigProperties::class,
|
||||
PseudonymizeConfigProperties::class,
|
||||
GPasConfigProperties::class
|
||||
GPasConfigProperties::class,
|
||||
ConsentConfigProperties::class,
|
||||
GIcsConfigProperties::class
|
||||
]
|
||||
)
|
||||
@EnableScheduling
|
||||
class AppConfiguration {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AppConfiguration::class.java)
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
|
||||
@Bean
|
||||
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties): Generator {
|
||||
return GpasPseudonymGenerator(configProperties)
|
||||
fun restTemplate(): RestTemplate {
|
||||
return RestTemplate()
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "BUILDIN", matchIfMissing = true)
|
||||
@Bean
|
||||
fun appFhirConfig(): AppFhirConfig {
|
||||
return AppFhirConfig()
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
|
||||
@Bean
|
||||
fun gpasPseudonymGenerator(
|
||||
configProperties: GPasConfigProperties,
|
||||
retryTemplate: RetryTemplate,
|
||||
restTemplate: RestTemplate,
|
||||
appFhirConfig: AppFhirConfig
|
||||
): Generator {
|
||||
logger.info("Selected 'GpasPseudonym Generator'")
|
||||
return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate, appFhirConfig)
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(
|
||||
value = ["app.pseudonymize.generator"],
|
||||
havingValue = "BUILDIN",
|
||||
matchIfMissing = true
|
||||
)
|
||||
@Bean
|
||||
fun buildinPseudonymGenerator(): Generator {
|
||||
logger.info("Selected 'BUILDIN Pseudonym Generator'")
|
||||
return AnonymizingGenerator()
|
||||
}
|
||||
|
||||
@@ -67,8 +116,57 @@ class AppConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun reportService(objectMapper: ObjectMapper): ReportService {
|
||||
return ReportService(objectMapper)
|
||||
fun reportService(): ReportService {
|
||||
return ReportService(getObjectMapper())
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun getObjectMapper(): ObjectMapper {
|
||||
return JacksonConfig().objectMapper()
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun transformationService(
|
||||
configProperties: AppConfigProperties
|
||||
): TransformationService {
|
||||
logger.info("Apply ${configProperties.transformations.size} transformation rules")
|
||||
return TransformationService(getObjectMapper(), 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
|
||||
@@ -77,15 +175,107 @@ class AppConfiguration {
|
||||
}
|
||||
|
||||
@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
|
||||
})
|
||||
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()
|
||||
}
|
||||
|
||||
@Conditional(GicsEnabledCondition::class)
|
||||
@Bean
|
||||
fun gicsConsentService(
|
||||
gIcsConfigProperties: GIcsConfigProperties,
|
||||
retryTemplate: RetryTemplate,
|
||||
restTemplate: RestTemplate,
|
||||
appFhirConfig: AppFhirConfig
|
||||
): IConsentService {
|
||||
return GicsConsentService(
|
||||
gIcsConfigProperties,
|
||||
retryTemplate,
|
||||
restTemplate,
|
||||
appFhirConfig
|
||||
)
|
||||
}
|
||||
|
||||
@Conditional(GicsEnabledCondition::class)
|
||||
@Bean
|
||||
fun consentProcessor(
|
||||
configProperties: AppConfigProperties,
|
||||
gIcsConfigProperties: GIcsConfigProperties,
|
||||
getObjectMapper: ObjectMapper,
|
||||
appFhirConfig: AppFhirConfig,
|
||||
gicsConsentService: IConsentService
|
||||
): ConsentProcessor {
|
||||
return ConsentProcessor(
|
||||
configProperties,
|
||||
gIcsConfigProperties,
|
||||
getObjectMapper,
|
||||
appFhirConfig.fhirContext(),
|
||||
gicsConsentService
|
||||
)
|
||||
}
|
||||
|
||||
@Conditional(GicsEnabledCondition::class)
|
||||
@Bean
|
||||
fun gIcsConnectionCheckService(
|
||||
restTemplate: RestTemplate,
|
||||
gIcsConfigProperties: GIcsConfigProperties,
|
||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
): ConnectionCheckService {
|
||||
return GIcsConnectionCheckService(
|
||||
restTemplate,
|
||||
gIcsConfigProperties,
|
||||
connectionCheckUpdateProducer
|
||||
)
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun iGetConsentService(): IConsentService {
|
||||
return MtbFileConsentService()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class GicsEnabledCondition :
|
||||
AnyNestedCondition(ConfigurationCondition.ConfigurationPhase.REGISTER_BEAN) {
|
||||
|
||||
@ConditionalOnProperty(name = ["app.consent.service"], havingValue = "gics")
|
||||
@ConditionalOnProperty(name = ["app.consent.gics.uri"])
|
||||
class OnGicsServiceSelected {
|
||||
// Just for Condition
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -0,0 +1,16 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
|
||||
@Configuration
|
||||
class AppFhirConfig {
|
||||
private val fhirCtx: FhirContext = FhirContext.forR4()
|
||||
|
||||
@Bean
|
||||
fun fhirContext(): FhirContext {
|
||||
return fhirCtx
|
||||
}
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
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
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -20,8 +20,14 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.consent.ConsentEvaluator
|
||||
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.MtbFileSender
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import dev.dnpm.etl.processor.services.kafka.KafkaResponseProcessor
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
@@ -35,12 +41,14 @@ import org.springframework.kafka.core.ConsumerFactory
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.kafka.listener.ContainerProperties
|
||||
import org.springframework.kafka.listener.KafkaMessageListenerContainer
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import reactor.core.publisher.Sinks
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(
|
||||
value = [KafkaTargetProperties::class]
|
||||
value = [KafkaProperties::class]
|
||||
)
|
||||
@ConditionalOnProperty(value = ["app.kafka.topic", "app.kafka.servers"])
|
||||
@ConditionalOnProperty(value = ["app.kafka.servers"])
|
||||
@ConditionalOnMissingBean(MtbFileSender::class)
|
||||
@Order(-5)
|
||||
class AppKafkaConfiguration {
|
||||
@@ -50,20 +58,21 @@ class AppKafkaConfiguration {
|
||||
@Bean
|
||||
fun kafkaMtbFileSender(
|
||||
kafkaTemplate: KafkaTemplate<String, String>,
|
||||
kafkaTargetProperties: KafkaTargetProperties,
|
||||
kafkaProperties: KafkaProperties,
|
||||
retryTemplate: RetryTemplate,
|
||||
objectMapper: ObjectMapper
|
||||
): MtbFileSender {
|
||||
logger.info("Selected 'KafkaMtbFileSender'")
|
||||
return KafkaMtbFileSender(kafkaTemplate, kafkaTargetProperties, objectMapper)
|
||||
return KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun kafkaListenerContainer(
|
||||
fun kafkaResponseListenerContainer(
|
||||
consumerFactory: ConsumerFactory<String, String>,
|
||||
kafkaTargetProperties: KafkaTargetProperties,
|
||||
kafkaProperties: KafkaProperties,
|
||||
kafkaResponseProcessor: KafkaResponseProcessor
|
||||
): KafkaMessageListenerContainer<String, String> {
|
||||
val containerProperties = ContainerProperties(kafkaTargetProperties.responseTopic)
|
||||
val containerProperties = ContainerProperties(kafkaProperties.outputResponseTopic)
|
||||
containerProperties.messageListener = kafkaResponseProcessor
|
||||
return KafkaMessageListenerContainer(consumerFactory, containerProperties)
|
||||
}
|
||||
@@ -76,4 +85,34 @@ class AppKafkaConfiguration {
|
||||
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,
|
||||
consentEvaluator: ConsentEvaluator
|
||||
): KafkaInputListener {
|
||||
return KafkaInputListener(requestProcessor, consentEvaluator, 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
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -19,8 +19,12 @@
|
||||
|
||||
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.RestMtbFileSender
|
||||
import dev.dnpm.etl.processor.output.RestDipMtbFileSender
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
@@ -28,7 +32,9 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import reactor.core.publisher.Sinks
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(
|
||||
@@ -44,14 +50,23 @@ class AppRestConfiguration {
|
||||
private val logger = LoggerFactory.getLogger(AppRestConfiguration::class.java)
|
||||
|
||||
@Bean
|
||||
fun restTemplate(): RestTemplate {
|
||||
return RestTemplate()
|
||||
fun restMtbFileSender(
|
||||
restTemplate: RestTemplate,
|
||||
restTargetProperties: RestTargetProperties,
|
||||
retryTemplate: RetryTemplate,
|
||||
reportService: ReportService,
|
||||
): MtbFileSender {
|
||||
logger.info("Selected 'RestDipMtbFileSender'")
|
||||
return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun restMtbFileSender(restTemplate: RestTemplate, restTargetProperties: RestTargetProperties): MtbFileSender {
|
||||
logger.info("Selected 'RestMtbFileSender'")
|
||||
return RestMtbFileSender(restTemplate, restTargetProperties)
|
||||
fun restConnectionCheckService(
|
||||
restTemplate: RestTemplate,
|
||||
restTargetProperties: RestTargetProperties,
|
||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
): ConnectionCheckService {
|
||||
return RestConnectionCheckService(restTemplate, restTargetProperties, connectionCheckUpdateProducer)
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* 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("/mtb/**", 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("/mtb/**", 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)
|
||||
}
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import org.hl7.fhir.r4.model.Consent
|
||||
|
||||
class ConsentResourceDeserializer : JsonDeserializer<Consent>() {
|
||||
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Consent {
|
||||
|
||||
val jsonNode = p?.readValueAsTree<JsonNode>()
|
||||
val json = jsonNode?.toString()
|
||||
|
||||
return JacksonConfig.fhirContext().newJsonParser().parseResource(json) as Consent
|
||||
}
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.databind.JsonSerializer
|
||||
import com.fasterxml.jackson.databind.SerializerProvider
|
||||
import org.hl7.fhir.r4.model.Consent
|
||||
|
||||
class ConsentResourceSerializer : JsonSerializer<Consent>() {
|
||||
override fun serialize(
|
||||
value: Consent, gen: JsonGenerator, serializers: SerializerProvider
|
||||
) {
|
||||
val json = JacksonConfig.fhirContext().newJsonParser().encodeResourceToString(value)
|
||||
gen.writeRawValue(json)
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import org.hl7.fhir.r4.model.Consent
|
||||
|
||||
class FhirResourceModule : SimpleModule() {
|
||||
init {
|
||||
addSerializer(Consent::class.java, ConsentResourceSerializer())
|
||||
addDeserializer(Consent::class.java, ConsentResourceDeserializer())
|
||||
}
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
|
||||
@Configuration
|
||||
class JacksonConfig {
|
||||
|
||||
companion object {
|
||||
var fhirContext: FhirContext = FhirContext.forR4()
|
||||
|
||||
@JvmStatic
|
||||
fun fhirContext(): FhirContext {
|
||||
return fhirContext
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun objectMapper(): ObjectMapper = ObjectMapper().registerModule(FhirResourceModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).registerModule(
|
||||
JavaTimeModule()
|
||||
)
|
||||
}
|
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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.consent
|
||||
|
||||
import dev.pcvolkmer.mv64e.mtb.ConsentProvision
|
||||
import dev.pcvolkmer.mv64e.mtb.ModelProjectConsentPurpose
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* Evaluates consent using provided consent service and file based consent information
|
||||
*/
|
||||
@Service
|
||||
class ConsentEvaluator(
|
||||
private val consentService: IConsentService
|
||||
) {
|
||||
fun check(mtbFile: Mtb): ConsentEvaluation {
|
||||
val ttpConsentStatus = consentService.getTtpBroadConsentStatus(mtbFile.patient.id)
|
||||
val consentGiven = ttpConsentStatus == TtpConsentStatus.BROAD_CONSENT_GIVEN
|
||||
|| ttpConsentStatus == TtpConsentStatus.GENOM_DE_CONSENT_SEQUENCING_PERMIT
|
||||
// Aktuell nur Modellvorhaben Consent im File
|
||||
|| ttpConsentStatus == TtpConsentStatus.UNKNOWN_CHECK_FILE && mtbFile.metadata?.modelProjectConsent?.provisions?.any {
|
||||
it.purpose == ModelProjectConsentPurpose.SEQUENCING
|
||||
&& it.type == ConsentProvision.PERMIT
|
||||
} == true
|
||||
|
||||
return ConsentEvaluation(ttpConsentStatus, consentGiven)
|
||||
}
|
||||
}
|
||||
|
||||
data class ConsentEvaluation(private val ttpConsentStatus: TtpConsentStatus, private val consentGiven: Boolean) {
|
||||
/**
|
||||
* Checks if any required consent is present
|
||||
*/
|
||||
fun hasConsent(): Boolean {
|
||||
return consentGiven
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the consent status
|
||||
*/
|
||||
fun getStatus(): TtpConsentStatus {
|
||||
if (ttpConsentStatus == TtpConsentStatus.UNKNOWN_CHECK_FILE) {
|
||||
// in case ttp check is disabled - we propagate rejected status anyway
|
||||
return TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED
|
||||
}
|
||||
return ttpConsentStatus
|
||||
}
|
||||
}
|
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 dev.dnpm.etl.processor.CustomMediaType
|
||||
import dev.dnpm.etl.processor.PatientId
|
||||
import dev.dnpm.etl.processor.RequestId
|
||||
import dev.dnpm.etl.processor.consent.ConsentEvaluator
|
||||
import dev.dnpm.etl.processor.consent.TtpConsentStatus
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.kafka.listener.MessageListener
|
||||
import java.nio.charset.Charset
|
||||
|
||||
class KafkaInputListener(
|
||||
private val requestProcessor: RequestProcessor,
|
||||
private val consentEvaluator: ConsentEvaluator,
|
||||
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 -> handleDnpmV2Message(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()?.toString(Charset.forName("UTF-8"))
|
||||
}
|
||||
|
||||
private fun handleDnpmV2Message(record: ConsumerRecord<String, String>) {
|
||||
val mtbFile = objectMapper.readValue(record.value(), Mtb::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 (consentEvaluator.check(mtbFile).hasConsent()) {
|
||||
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, TtpConsentStatus.UNKNOWN_CHECK_FILE)
|
||||
} else {
|
||||
requestProcessor.processDeletion(
|
||||
patientId,
|
||||
requestId,
|
||||
TtpConsentStatus.UNKNOWN_CHECK_FILE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -17,38 +17,50 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
package dev.dnpm.etl.processor.input
|
||||
|
||||
import de.ukw.ccc.bwhc.dto.Consent
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.CustomMediaType
|
||||
import dev.dnpm.etl.processor.PatientId
|
||||
import dev.dnpm.etl.processor.consent.ConsentEvaluator
|
||||
import dev.dnpm.etl.processor.consent.TtpConsentStatus
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping(path = ["mtbfile", "mtb"])
|
||||
class MtbFileRestController(
|
||||
private val requestProcessor: RequestProcessor,
|
||||
private val consentEvaluator: ConsentEvaluator
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java)
|
||||
|
||||
@PostMapping(path = ["/mtbfile"])
|
||||
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Void> {
|
||||
if (mtbFile.consent.status == Consent.Status.ACTIVE) {
|
||||
logger.debug("Accepted MTB File for processing")
|
||||
@GetMapping
|
||||
fun info(): ResponseEntity<String> {
|
||||
return ResponseEntity.ok("Test")
|
||||
}
|
||||
|
||||
@PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE, CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE])
|
||||
fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity<Unit> {
|
||||
val consentEvaluation = consentEvaluator.check(mtbFile)
|
||||
if (consentEvaluation.hasConsent()) {
|
||||
logger.debug("Accepted MTB File (DNPM V2) for processing")
|
||||
requestProcessor.processMtbFile(mtbFile)
|
||||
} else {
|
||||
logger.debug("Accepted MTB File and process deletion")
|
||||
requestProcessor.processDeletion(mtbFile.patient.id)
|
||||
logger.debug("Accepted MTB File (DNPM V2) and process deletion")
|
||||
val patientId = PatientId(mtbFile.patient.id)
|
||||
requestProcessor.processDeletion(patientId, consentEvaluation.getStatus())
|
||||
}
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
@DeleteMapping(path = ["/mtbfile/{patientId}"])
|
||||
fun deleteData(@PathVariable patientId: String): ResponseEntity<Void> {
|
||||
@DeleteMapping(path = ["{patientId}"])
|
||||
fun deleteData(@PathVariable patientId: String): ResponseEntity<Unit> {
|
||||
logger.debug("Accepted patient ID to process deletion")
|
||||
requestProcessor.processDeletion(patientId)
|
||||
requestProcessor.processDeletion(PatientId(patientId), TtpConsentStatus.UNKNOWN_CHECK_FILE)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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.GIcsConfigProperties
|
||||
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()
|
||||
|
||||
data class GIcsConnectionCheckResult(
|
||||
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(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
class GIcsConnectionCheckService(
|
||||
private val restTemplate: RestTemplate,
|
||||
private val gIcsConfigProperties: GIcsConfigProperties,
|
||||
@Qualifier("connectionCheckUpdateProducer")
|
||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
) : ConnectionCheckService {
|
||||
|
||||
private var result = ConnectionCheckResult.GIcsConnectionCheckResult(false, Instant.now(), Instant.now())
|
||||
|
||||
@PostConstruct
|
||||
@Scheduled(cron = "0 * * * * *")
|
||||
fun check() {
|
||||
result = try {
|
||||
|
||||
val uri = UriComponentsBuilder.fromUriString(
|
||||
gIcsConfigProperties.uri.toString()).path("/metadata").build().toUri()
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
if (!gIcsConfigProperties.username.isNullOrBlank() && !gIcsConfigProperties.password.isNullOrBlank()) {
|
||||
headers.setBasicAuth(gIcsConfigProperties.username, gIcsConfigProperties.password)
|
||||
}
|
||||
|
||||
val available = restTemplate.exchange(
|
||||
uri,
|
||||
HttpMethod.GET,
|
||||
HttpEntity<Void>(headers),
|
||||
Void::class.java
|
||||
).statusCode == HttpStatus.OK
|
||||
|
||||
ConnectionCheckResult.GIcsConnectionCheckResult(
|
||||
available,
|
||||
Instant.now(),
|
||||
if (result.available == available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ConnectionCheckResult.GIcsConnectionCheckResult(
|
||||
false,
|
||||
Instant.now(),
|
||||
if (!result.available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
}
|
||||
connectionCheckUpdateProducer.emitNext(
|
||||
result,
|
||||
Sinks.EmitFailureHandler.FAIL_FAST
|
||||
)
|
||||
}
|
||||
|
||||
override fun connectionAvailable(): ConnectionCheckResult.GIcsConnectionCheckResult {
|
||||
return this.result
|
||||
}
|
||||
}
|
@@ -19,11 +19,15 @@
|
||||
|
||||
package dev.dnpm.etl.processor.monitoring
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonValue
|
||||
import com.fasterxml.jackson.core.JsonParseException
|
||||
import com.fasterxml.jackson.databind.JsonMappingException
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService.Issue
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService.Severity
|
||||
import java.util.Optional
|
||||
|
||||
class ReportService(
|
||||
private val objectMapper: ObjectMapper
|
||||
@@ -54,11 +58,25 @@ class ReportService(
|
||||
private data class DataQualityReport(val issues: List<Issue>)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class Issue(val severity: Severity, val message: String)
|
||||
data class Issue(
|
||||
val severity: Severity,
|
||||
@JsonAlias("details") val message: String,
|
||||
val path: Optional<String> = Optional.empty()
|
||||
)
|
||||
|
||||
enum class Severity(@JsonValue val value: String) {
|
||||
FATAL("fatal"),
|
||||
ERROR("error"),
|
||||
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,50 +19,79 @@
|
||||
|
||||
package dev.dnpm.etl.processor.monitoring
|
||||
|
||||
import dev.dnpm.etl.processor.*
|
||||
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.relational.core.mapping.Column
|
||||
import org.springframework.data.relational.core.mapping.Embedded
|
||||
import org.springframework.data.relational.core.mapping.Table
|
||||
import org.springframework.data.repository.CrudRepository
|
||||
import org.springframework.data.repository.PagingAndSortingRepository
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
typealias RequestId = UUID
|
||||
|
||||
@Table("request")
|
||||
data class Request(
|
||||
@Id val id: Long? = null,
|
||||
val uuid: String = RequestId.randomUUID().toString(),
|
||||
val patientId: String,
|
||||
val pid: String,
|
||||
val fingerprint: String,
|
||||
val uuid: RequestId = randomRequestId(),
|
||||
val patientPseudonym: PatientPseudonym,
|
||||
val pid: PatientId,
|
||||
@Column("fingerprint")
|
||||
val fingerprint: Fingerprint,
|
||||
val type: RequestType,
|
||||
var status: RequestStatus,
|
||||
var processedAt: Instant = Instant.now(),
|
||||
@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(
|
||||
val description: String,
|
||||
val dataQualityReport: String = ""
|
||||
)
|
||||
|
||||
@JvmRecord
|
||||
data class CountedState(
|
||||
val count: Int,
|
||||
val status: RequestStatus,
|
||||
)
|
||||
|
||||
interface RequestRepository : CrudRepository<Request, Long> {
|
||||
interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRepository<Request, Long> {
|
||||
|
||||
fun findAllByPatientIdOrderByProcessedAtDesc(patientId: String): List<Request>
|
||||
fun findAllByPatientPseudonymOrderByProcessedAtDesc(patientId: PatientPseudonym): List<Request>
|
||||
|
||||
fun findByUuidEquals(uuid: String): Optional<Request>
|
||||
fun findByUuidEquals(uuid: RequestId): 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;")
|
||||
fun countStates(): List<CountedState>
|
||||
|
||||
@Query("SELECT count(*) AS count, status FROM (" +
|
||||
"SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
|
||||
"SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " +
|
||||
"WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " +
|
||||
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
|
||||
fun findPatientUniqueStates(): List<CountedState>
|
||||
@@ -71,7 +100,7 @@ interface RequestRepository : CrudRepository<Request, Long> {
|
||||
fun countDeleteStates(): List<CountedState>
|
||||
|
||||
@Query("SELECT count(*) AS count, status FROM (" +
|
||||
"SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
|
||||
"SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " +
|
||||
"WHERE type = 'DELETE'" +
|
||||
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
|
||||
fun findPatientUniqueDeleteStates(): List<CountedState>
|
||||
|
@@ -24,5 +24,6 @@ enum class RequestStatus(val value: String) {
|
||||
WARNING("warning"),
|
||||
ERROR("error"),
|
||||
UNKNOWN("unknown"),
|
||||
DUPLICATION("duplication")
|
||||
DUPLICATION("duplication"),
|
||||
NO_CONSENT("no-consent")
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -20,33 +20,50 @@
|
||||
package dev.dnpm.etl.processor.output
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.ukw.ccc.bwhc.dto.Consent
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.config.KafkaTargetProperties
|
||||
import dev.dnpm.etl.processor.CustomMediaType
|
||||
import dev.dnpm.etl.processor.config.KafkaProperties
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
import dev.pcvolkmer.mv64e.mtb.MvhMetadata
|
||||
import org.apache.kafka.clients.producer.ProducerRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
|
||||
class KafkaMtbFileSender(
|
||||
private val kafkaTemplate: KafkaTemplate<String, String>,
|
||||
private val kafkaTargetProperties: KafkaTargetProperties,
|
||||
private val kafkaProperties: KafkaProperties,
|
||||
private val retryTemplate: RetryTemplate,
|
||||
private val objectMapper: ObjectMapper
|
||||
) : MtbFileSender {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java)
|
||||
|
||||
override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
|
||||
override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response {
|
||||
return try {
|
||||
val result = kafkaTemplate.send(
|
||||
kafkaTargetProperties.topic,
|
||||
key(request),
|
||||
objectMapper.writeValueAsString(Data(request.requestId, request.mtbFile))
|
||||
)
|
||||
if (result.get() != null) {
|
||||
logger.debug("Sent file via KafkaMtbFileSender")
|
||||
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
||||
} else {
|
||||
MtbFileSender.Response(RequestStatus.ERROR)
|
||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
||||
val record =
|
||||
ProducerRecord(
|
||||
kafkaProperties.outputTopic,
|
||||
key(request),
|
||||
objectMapper.writeValueAsString(request)
|
||||
)
|
||||
when (request) {
|
||||
is DnpmV2MtbFileRequest -> record.headers()
|
||||
.add(
|
||||
"contentType",
|
||||
CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
val result = kafkaTemplate.send(record)
|
||||
if (result.get() != null) {
|
||||
logger.debug("Sent file via KafkaMtbFileSender")
|
||||
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
||||
} else {
|
||||
MtbFileSender.Response(RequestStatus.ERROR)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("An error occurred sending to kafka", e)
|
||||
@@ -54,28 +71,33 @@ class KafkaMtbFileSender(
|
||||
}
|
||||
}
|
||||
|
||||
override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
|
||||
val dummyMtbFile = MtbFile.builder()
|
||||
.withConsent(
|
||||
Consent.builder()
|
||||
.withPatient(request.patientId)
|
||||
.withStatus(Consent.Status.REJECTED)
|
||||
.build()
|
||||
)
|
||||
override fun send(request: DeleteRequest): MtbFileSender.Response {
|
||||
val dummyMtbFile = Mtb.builder()
|
||||
.metadata(MvhMetadata())
|
||||
.build()
|
||||
|
||||
return try {
|
||||
val result = kafkaTemplate.send(
|
||||
kafkaTargetProperties.topic,
|
||||
key(request),
|
||||
objectMapper.writeValueAsString(Data(request.requestId, dummyMtbFile))
|
||||
)
|
||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
||||
val record =
|
||||
ProducerRecord(
|
||||
kafkaProperties.outputTopic,
|
||||
key(request),
|
||||
// Always use old BwhcV1FileRequest with Consent REJECT
|
||||
objectMapper.writeValueAsString(
|
||||
DnpmV2MtbFileRequest(
|
||||
request.requestId,
|
||||
dummyMtbFile
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (result.get() != null) {
|
||||
logger.debug("Sent deletion request via KafkaMtbFileSender")
|
||||
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
||||
} else {
|
||||
MtbFileSender.Response(RequestStatus.ERROR)
|
||||
val result = kafkaTemplate.send(record)
|
||||
if (result.get() != null) {
|
||||
logger.debug("Sent deletion request via KafkaMtbFileSender")
|
||||
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
||||
} else {
|
||||
MtbFileSender.Response(RequestStatus.ERROR)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("An error occurred sending to kafka", e)
|
||||
@@ -83,14 +105,15 @@ class KafkaMtbFileSender(
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(request: MtbFileSender.MtbFileRequest): String {
|
||||
return "{\"pid\": \"${request.mtbFile.patient.id}\", " +
|
||||
"\"eid\": \"${request.mtbFile.episode.id}\"}"
|
||||
override fun endpoint(): String {
|
||||
return "${this.kafkaProperties.servers} (${this.kafkaProperties.outputTopic}/${this.kafkaProperties.outputResponseTopic})"
|
||||
}
|
||||
|
||||
private fun key(request: MtbFileSender.DeleteRequest): String {
|
||||
return "{\"pid\": \"${request.patientId}\"}"
|
||||
private fun key(request: MtbRequest): String {
|
||||
return when (request) {
|
||||
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,21 +19,17 @@
|
||||
|
||||
package dev.dnpm.etl.processor.output
|
||||
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import org.springframework.http.HttpStatusCode
|
||||
|
||||
interface MtbFileSender {
|
||||
fun send(request: MtbFileRequest): Response
|
||||
fun <T> send(request: MtbFileRequest<T>): Response
|
||||
|
||||
fun send(request: DeleteRequest): Response
|
||||
|
||||
fun endpoint(): 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 {
|
||||
|
49
src/main/kotlin/dev/dnpm/etl/processor/output/MtbRequest.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.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 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
|
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -19,38 +19,77 @@
|
||||
|
||||
package dev.dnpm.etl.processor.output
|
||||
|
||||
import dev.dnpm.etl.processor.CustomMediaType
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.monitoring.asRequestStatus
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import org.springframework.web.client.RestClientException
|
||||
import org.springframework.web.client.RestClientResponseException
|
||||
import org.springframework.web.client.RestTemplate
|
||||
|
||||
class RestMtbFileSender(
|
||||
abstract class RestMtbFileSender(
|
||||
private val restTemplate: RestTemplate,
|
||||
private val restTargetProperties: RestTargetProperties
|
||||
private val restTargetProperties: RestTargetProperties,
|
||||
private val retryTemplate: RetryTemplate,
|
||||
private val reportService: ReportService
|
||||
) : MtbFileSender {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java)
|
||||
|
||||
override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
|
||||
abstract fun sendUrl(): String
|
||||
|
||||
abstract fun deleteUrl(patientId: PatientPseudonym): String
|
||||
|
||||
override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response {
|
||||
try {
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
val entityReq = HttpEntity(request.mtbFile, headers)
|
||||
val response = restTemplate.postForEntity(
|
||||
"${restTargetProperties.uri}/MTBFile",
|
||||
entityReq,
|
||||
String::class.java
|
||||
)
|
||||
if (!response.statusCode.is2xxSuccessful) {
|
||||
logger.warn("Error sending to remote system: {}", response.body)
|
||||
return MtbFileSender.Response(response.statusCode.asRequestStatus(), "Status-Code: ${response.statusCode.value()}")
|
||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
||||
val headers = getHttpHeaders(request)
|
||||
val entityReq = HttpEntity(request.content, headers)
|
||||
val response = restTemplate.postForEntity(
|
||||
sendUrl(),
|
||||
entityReq,
|
||||
String::class.java
|
||||
)
|
||||
if (!response.statusCode.is2xxSuccessful) {
|
||||
logger.warn("Error sending to remote system: {}", response.body)
|
||||
return@execute MtbFileSender.Response(
|
||||
reportService.deserialize(response.body).asRequestStatus(),
|
||||
"Status-Code: ${response.statusCode.value()}"
|
||||
)
|
||||
}
|
||||
logger.debug("Sent file via RestMtbFileSender")
|
||||
return@execute MtbFileSender.Response(reportService.deserialize(response.body).asRequestStatus(), response.body.orEmpty())
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
|
||||
} catch (e: RestClientResponseException) {
|
||||
logger.info(restTargetProperties.uri!!.toString())
|
||||
logger.error("Request data not accepted by remote system", e)
|
||||
return MtbFileSender.Response(reportService.deserialize(e.responseBodyAsString).asRequestStatus(), e.responseBodyAsString)
|
||||
}
|
||||
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
|
||||
}
|
||||
|
||||
override fun send(request: DeleteRequest): MtbFileSender.Response {
|
||||
try {
|
||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
||||
val headers = getHttpHeaders(request)
|
||||
val entityReq = HttpEntity(null, headers)
|
||||
restTemplate.delete(
|
||||
deleteUrl(request.patientId),
|
||||
entityReq,
|
||||
String::class.java
|
||||
)
|
||||
logger.debug("Sent file via RestMtbFileSender")
|
||||
return@execute MtbFileSender.Response(RequestStatus.SUCCESS)
|
||||
}
|
||||
logger.debug("Sent file via RestMtbFileSender")
|
||||
return MtbFileSender.Response(response.statusCode.asRequestStatus(), response.body.orEmpty())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
|
||||
} catch (e: RestClientException) {
|
||||
@@ -60,25 +99,25 @@ class RestMtbFileSender(
|
||||
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
|
||||
}
|
||||
|
||||
override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
|
||||
try {
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
val entityReq = HttpEntity(null, headers)
|
||||
restTemplate.delete(
|
||||
"${restTargetProperties.uri}/Patient/${request.patientId}",
|
||||
entityReq,
|
||||
String::class.java
|
||||
)
|
||||
logger.debug("Sent file via RestMtbFileSender")
|
||||
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)
|
||||
override fun endpoint(): String {
|
||||
return this.restTargetProperties.uri.orEmpty()
|
||||
}
|
||||
|
||||
private fun getHttpHeaders(request: MtbRequest): HttpHeaders {
|
||||
val username = restTargetProperties.username
|
||||
val password = restTargetProperties.password
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = when (request) {
|
||||
is DnpmV2MtbFileRequest -> CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON
|
||||
else -> MediaType.APPLICATION_JSON
|
||||
}
|
||||
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
|
||||
|
||||
if (username.isNullOrBlank() || password.isNullOrBlank()) {
|
||||
return headers
|
||||
}
|
||||
|
||||
headers.setBasicAuth(username, password)
|
||||
return headers
|
||||
}
|
||||
|
||||
}
|
@@ -21,9 +21,12 @@ package dev.dnpm.etl.processor.pseudonym
|
||||
|
||||
import org.apache.commons.codec.binary.Base32
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
class AnonymizingGenerator : Generator {
|
||||
companion object fun getSecureRandom() : SecureRandom {
|
||||
return SecureRandom()
|
||||
}
|
||||
|
||||
override fun generate(id: String): String {
|
||||
return Base32().encodeAsString(DigestUtils.sha256(id))
|
||||
@@ -31,4 +34,14 @@ class AnonymizingGenerator : Generator {
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
override fun generateGenomDeTan(id: String?): String {
|
||||
|
||||
val bytes = ByteArray(64 / 2)
|
||||
getSecureRandom().nextBytes(bytes)
|
||||
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
|
||||
}
|
||||
|
||||
}
|
@@ -19,6 +19,8 @@
|
||||
|
||||
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
|
||||
|
||||
class PseudonymizeService(
|
||||
@@ -26,11 +28,19 @@ class PseudonymizeService(
|
||||
private val configProperties: PseudonymizeConfigProperties
|
||||
) {
|
||||
|
||||
fun patientPseudonym(patientId: String): String {
|
||||
fun patientPseudonym(patientId: PatientId): PatientPseudonym {
|
||||
return when (generator) {
|
||||
is GpasPseudonymGenerator -> generator.generate(patientId)
|
||||
else -> "${configProperties.prefix}_${generator.generate(patientId)}"
|
||||
is GpasPseudonymGenerator -> PatientPseudonym(generator.generate(patientId.value))
|
||||
else -> PatientPseudonym("${configProperties.prefix}_${generator.generate(patientId.value)}")
|
||||
}
|
||||
}
|
||||
|
||||
fun genomDeTan(patientId: PatientId): String {
|
||||
return generator.generateGenomDeTan(patientId.value)
|
||||
}
|
||||
|
||||
fun prefix(): String {
|
||||
return configProperties.prefix
|
||||
}
|
||||
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 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
|
||||
@@ -19,35 +19,296 @@
|
||||
|
||||
package dev.dnpm.etl.processor.pseudonym
|
||||
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.PatientId
|
||||
import dev.pcvolkmer.mv64e.mtb.ModelProjectConsent
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
import dev.pcvolkmer.mv64e.mtb.MvhMetadata
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
|
||||
infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
|
||||
val patientPseudonym = pseudonymizeService.patientPseudonym(this.patient.id)
|
||||
/** 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.episode.patient = patientPseudonym
|
||||
this.carePlans.forEach { it.patient = patientPseudonym }
|
||||
this.patient.id = patientPseudonym
|
||||
this.claims.forEach { it.patient = patientPseudonym }
|
||||
this.consent.patient = patientPseudonym
|
||||
this.claimResponses.forEach { it.patient = patientPseudonym }
|
||||
this.diagnoses.forEach { it.patient = patientPseudonym }
|
||||
this.ecogStatus.forEach { it.patient = patientPseudonym }
|
||||
this.familyMemberDiagnoses.forEach { it.patient = patientPseudonym }
|
||||
this.geneticCounsellingRequests.forEach { it.patient = patientPseudonym }
|
||||
this.histologyReevaluationRequests.forEach { it.patient = patientPseudonym }
|
||||
this.histologyReports.forEach {
|
||||
it.patient = patientPseudonym
|
||||
it.tumorMorphology.patient = patientPseudonym
|
||||
this.episodesOfCare?.forEach { it.patient.id = patientPseudonym }
|
||||
this.carePlans?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
it.rebiopsyRequests?.forEach { it.patient.id = patientPseudonym }
|
||||
it.histologyReevaluationRequests?.forEach { it.patient.id = patientPseudonym }
|
||||
it.medicationRecommendations.forEach { it.patient.id = patientPseudonym }
|
||||
it.studyEnrollmentRecommendations?.forEach { it.patient.id = patientPseudonym }
|
||||
it.procedureRecommendations?.forEach { it.patient.id = patientPseudonym }
|
||||
it.geneticCounselingRecommendation.patient.id = patientPseudonym
|
||||
}
|
||||
this.diagnoses?.forEach { it.patient.id = patientPseudonym }
|
||||
this.guidelineTherapies?.forEach { it.patient.id = patientPseudonym }
|
||||
this.guidelineProcedures?.forEach { it.patient.id = patientPseudonym }
|
||||
this.patient.id = patientPseudonym
|
||||
this.claims?.forEach { it.patient.id = patientPseudonym }
|
||||
this.claimResponses?.forEach { it.patient.id = patientPseudonym }
|
||||
this.diagnoses?.forEach { it.patient.id = patientPseudonym }
|
||||
this.familyMemberHistories?.forEach { it.patient.id = patientPseudonym }
|
||||
this.histologyReports?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
it.results.tumorMorphology?.patient?.id = patientPseudonym
|
||||
it.results.tumorCellContent?.patient?.id = patientPseudonym
|
||||
}
|
||||
this.ngsReports?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
it.results.simpleVariants?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.copyNumberVariants?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.dnaFusions?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.rnaFusions?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.tumorCellContent?.patient?.id = patientPseudonym
|
||||
it.results.brcaness?.patient?.id = patientPseudonym
|
||||
it.results.tmb?.patient?.id = patientPseudonym
|
||||
it.results.hrdScore?.patient?.id = patientPseudonym
|
||||
}
|
||||
this.ihcReports?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
it.results.msiMmr?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.proteinExpression?.forEach { it.patient.id = patientPseudonym }
|
||||
}
|
||||
this.responses?.forEach { it.patient.id = patientPseudonym }
|
||||
this.specimens?.forEach { it.patient.id = patientPseudonym }
|
||||
this.priorDiagnosticReports?.forEach { it.patient.id = patientPseudonym }
|
||||
this.performanceStatus?.forEach { it.patient.id = patientPseudonym }
|
||||
this.systemicTherapies?.forEach {
|
||||
it.history?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
}
|
||||
}
|
||||
this.followUps?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
}
|
||||
|
||||
this.msiFindings?.forEach { it -> it.patient.id = patientPseudonym }
|
||||
|
||||
this.metadata?.researchConsents?.forEach { it ->
|
||||
val entry = it ?: return@forEach
|
||||
if (entry.contains("patient")) {
|
||||
// here we expect only a patient reference any other data like display
|
||||
// need to be removed, since may contain unsecure data
|
||||
entry.remove("patient")
|
||||
entry["patient"] = mapOf("reference" to "Patient/$patientPseudonym")
|
||||
}
|
||||
}
|
||||
this.lastGuidelineTherapies.forEach { it.patient = patientPseudonym }
|
||||
this.molecularPathologyFindings.forEach { it.patient = patientPseudonym }
|
||||
this.molecularTherapies.forEach { molecularTherapy -> molecularTherapy.history.forEach { it.patient = patientPseudonym } }
|
||||
this.ngsReports.forEach { it.patient = patientPseudonym }
|
||||
this.previousGuidelineTherapies.forEach { it.patient = patientPseudonym }
|
||||
this.rebiopsyRequests.forEach { it.patient = patientPseudonym }
|
||||
this.recommendations.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
|
||||
*
|
||||
* @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.diagnoses?.forEach { it ->
|
||||
it?.id = it.id?.let(::anonymize)
|
||||
}
|
||||
}
|
||||
|
||||
this.carePlans?.onEach { carePlan ->
|
||||
carePlan?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
|
||||
diagnoses?.forEach { it -> it?.id = it.id?.let(::anonymize) }
|
||||
geneticCounselingRecommendation?.apply {
|
||||
id = geneticCounselingRecommendation.id?.let(::anonymize)
|
||||
}
|
||||
rebiopsyRequests?.forEach { it ->
|
||||
it.id = it.id?.let(::anonymize)
|
||||
it.tumorEntity?.id = it.tumorEntity?.id?.let(::anonymize)
|
||||
}
|
||||
histologyReevaluationRequests?.forEach { it ->
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.specimen?.id = it.specimen?.id?.let(::anonymize)
|
||||
}
|
||||
|
||||
medicationRecommendations?.forEach { it ->
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.supportingVariants?.forEach { it ->
|
||||
it.variant?.id = it.variant?.id?.let(::anonymize)
|
||||
}
|
||||
it.reason?.id = it.reason?.id?.let(::anonymize)
|
||||
}
|
||||
reason?.id = reason?.id?.let(::anonymize)
|
||||
studyEnrollmentRecommendations?.forEach { it ->
|
||||
it?.reason?.id = it.reason?.id?.let(::anonymize)
|
||||
}
|
||||
|
||||
procedureRecommendations?.forEach { it ->
|
||||
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.supportingVariants?.forEach { it ->
|
||||
it.variant?.id = it.variant?.id?.let(::anonymize)
|
||||
}
|
||||
|
||||
it.reason?.id = it.reason?.id?.let(::anonymize)
|
||||
|
||||
studyEnrollmentRecommendations?.forEach { it ->
|
||||
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.supportingVariants.forEach { it ->
|
||||
it.variant?.id = it?.variant?.id?.let(::anonymize)
|
||||
}
|
||||
responses?.forEach { it ->
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.responses?.forEach { it ->
|
||||
|
||||
it?.id = it.id?.let(::anonymize)
|
||||
it?.therapy?.id = it.therapy?.id?.let(::anonymize)
|
||||
|
||||
}
|
||||
|
||||
this.diagnoses?.forEach { it ->
|
||||
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.histology?.forEach { it -> it.id = it?.id?.let(::anonymize) }
|
||||
}
|
||||
|
||||
this.ngsReports?.forEach { it ->
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.results?.tumorCellContent?.id = it.results.tumorCellContent?.id?.let(::anonymize)
|
||||
it.results?.tumorCellContent?.specimen?.id =
|
||||
it.results?.tumorCellContent?.specimen?.id?.let(::anonymize)
|
||||
it.results?.rnaFusions?.forEach { it ->
|
||||
it?.id = it.id?.let(::anonymize)
|
||||
}
|
||||
it.results?.simpleVariants?.forEach { it ->
|
||||
it?.id = it.id?.let(::anonymize)
|
||||
it?.transcriptId?.value = it.transcriptId?.value?.let(::anonymize)
|
||||
}
|
||||
it.results?.tmb?.id = it.results?.tmb?.id?.let(::anonymize)
|
||||
it.results?.tmb?.specimen?.id = it.results?.tmb?.specimen?.id?.let(::anonymize)
|
||||
|
||||
it.results?.brcaness?.id = it.results?.brcaness?.id?.let(::anonymize)
|
||||
it.results?.brcaness?.specimen?.id = it.results?.brcaness?.specimen?.id?.let(::anonymize)
|
||||
it.results?.copyNumberVariants?.forEach { it -> it?.id = it.id?.let(::anonymize) }
|
||||
it.results?.hrdScore?.id = it.results?.hrdScore?.id?.let(::anonymize)
|
||||
it.results?.hrdScore?.specimen?.id = it.results?.hrdScore?.specimen?.id?.let(::anonymize)
|
||||
it.results?.rnaSeqs?.forEach { it -> it?.id = it.id?.let(::anonymize) }
|
||||
it.results?.dnaFusions?.forEach { it -> it?.id = it.id?.let(::anonymize) }
|
||||
it.specimen?.id = it?.specimen?.id?.let(::anonymize)
|
||||
|
||||
}
|
||||
|
||||
this.histologyReports?.forEach { it ->
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.results?.tumorCellContent?.id = it.results?.tumorCellContent?.id?.let(::anonymize)
|
||||
it.results?.tumorCellContent?.specimen?.id =
|
||||
it.results?.tumorCellContent?.specimen?.id?.let(::anonymize)
|
||||
|
||||
it.results?.tumorMorphology?.id = it.results?.tumorMorphology?.id?.let(::anonymize)
|
||||
it.results?.tumorMorphology?.specimen?.id =
|
||||
it.results?.tumorMorphology?.specimen?.id?.let(::anonymize)
|
||||
it.specimen?.id = it.specimen?.id?.let(::anonymize)
|
||||
|
||||
}
|
||||
this.claimResponses?.forEach { it ->
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.claim?.id = it.claim?.id?.let(::anonymize)
|
||||
}
|
||||
this.claims?.forEach { it ->
|
||||
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.recommendation?.id = it.recommendation?.id?.let(::anonymize)
|
||||
|
||||
}
|
||||
this.familyMemberHistories?.forEach { it -> it.id = it?.id?.let(::anonymize) }
|
||||
this.guidelineProcedures?.forEach { it ->
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.reason?.id = it.reason?.id?.let(::anonymize)
|
||||
it.basedOn?.id = it.basedOn?.id?.let(::anonymize)
|
||||
|
||||
}
|
||||
|
||||
this.guidelineTherapies?.forEach { it ->
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.reason?.id = it.reason?.id?.let(::anonymize)
|
||||
it.basedOn?.id = it.basedOn?.id?.let(::anonymize)
|
||||
}
|
||||
this.ihcReports?.forEach { it ->
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.specimen?.id = it.specimen?.id?.let(::anonymize)
|
||||
it.results.proteinExpression.forEach { it -> it?.id = it.id.let(::anonymize) }
|
||||
}
|
||||
|
||||
this.msiFindings?.forEach { it ->
|
||||
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.specimen?.id = it.specimen?.id?.let(::anonymize)
|
||||
}
|
||||
|
||||
this.performanceStatus?.forEach { it -> it.id = it?.id?.let(::anonymize) }
|
||||
|
||||
this.priorDiagnosticReports?.forEach { it ->
|
||||
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.specimen?.id = it.specimen?.id?.let(::anonymize)
|
||||
}
|
||||
|
||||
this.specimens?.forEach { it ->
|
||||
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.diagnosis?.id = it.diagnosis?.id?.let(::anonymize)
|
||||
|
||||
}
|
||||
|
||||
this.systemicTherapies?.forEach { it ->
|
||||
|
||||
it.history?.forEach { it ->
|
||||
|
||||
it.id = it?.id?.let(::anonymize)
|
||||
it.reason?.id = it.reason?.id?.let(::anonymize)
|
||||
it.basedOn?.id = it.basedOn?.id?.let(::anonymize)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun Mtb.ensureMetaDataIsInitialized() {
|
||||
// init metadata if necessary
|
||||
if (this.metadata == null) {
|
||||
val mvhMetadata = MvhMetadata.builder().build()
|
||||
this.metadata = mvhMetadata
|
||||
}
|
||||
if (this.metadata.researchConsents == null) {
|
||||
this.metadata.researchConsents = mutableListOf()
|
||||
}
|
||||
if (this.metadata.modelProjectConsent == null) {
|
||||
this.metadata.modelProjectConsent = ModelProjectConsent()
|
||||
this.metadata.modelProjectConsent.provisions = mutableListOf()
|
||||
} else if (this.metadata.modelProjectConsent.provisions != null) {
|
||||
// make sure list can be changed
|
||||
this.metadata.modelProjectConsent.provisions =
|
||||
this.metadata.modelProjectConsent.provisions.toMutableList()
|
||||
}
|
||||
}
|
||||
|
||||
infix fun Mtb.addGenomDeTan(pseudonymizeService: PseudonymizeService) {
|
||||
this.metadata.transferTan = pseudonymizeService.genomDeTan(PatientId(this.patient.id))
|
||||
}
|
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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>
|
45
src/main/kotlin/dev/dnpm/etl/processor/security/UserRole.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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>
|
||||
|
||||
}
|
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,273 @@
|
||||
package dev.dnpm.etl.processor.services
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.core.type.TypeReference
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.config.AppConfigProperties
|
||||
import dev.dnpm.etl.processor.config.GIcsConfigProperties
|
||||
import dev.dnpm.etl.processor.consent.ConsentDomain
|
||||
import dev.dnpm.etl.processor.consent.IConsentService
|
||||
import dev.dnpm.etl.processor.consent.MtbFileConsentService
|
||||
import dev.dnpm.etl.processor.pseudonym.ensureMetaDataIsInitialized
|
||||
import dev.pcvolkmer.mv64e.mtb.*
|
||||
import org.apache.commons.lang3.NotImplementedException
|
||||
import org.hl7.fhir.r4.model.*
|
||||
import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent
|
||||
import org.hl7.fhir.r4.model.Coding
|
||||
import org.hl7.fhir.r4.model.Consent.ConsentState
|
||||
import org.hl7.fhir.r4.model.Consent.ProvisionComponent
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.io.IOException
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
@Service
|
||||
class ConsentProcessor(
|
||||
private val appConfigProperties: AppConfigProperties,
|
||||
private val gIcsConfigProperties: GIcsConfigProperties,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val fhirContext: FhirContext,
|
||||
private val consentService: IConsentService
|
||||
) {
|
||||
private var logger: Logger = LoggerFactory.getLogger("ConsentProcessor")
|
||||
|
||||
/**
|
||||
* In case an instance of {@link ICheckConsent} is active, consent will be embedded and checked.
|
||||
*
|
||||
* Logic:
|
||||
* * <c>true</c> IF consent check is disabled.
|
||||
* * <c>true</c> IF broad consent (BC) has been given.
|
||||
* * <c>true</c> BC has been asked AND declined but genomDe consent has been consented.
|
||||
* * ELSE <c>false</c> is returned.
|
||||
*
|
||||
* @param mtbFile File v2 (will be enriched with consent data)
|
||||
* @return true if consent is given
|
||||
*
|
||||
*/
|
||||
fun consentGatedCheckAndTryEmbedding(mtbFile: Mtb): Boolean {
|
||||
if (consentService is MtbFileConsentService) {
|
||||
// consent check is disabled
|
||||
return true
|
||||
}
|
||||
|
||||
mtbFile.ensureMetaDataIsInitialized()
|
||||
|
||||
val personIdentifierValue = mtbFile.patient.id
|
||||
val requestDate = Date.from(Instant.now(Clock.systemUTC()))
|
||||
|
||||
// 1. Broad consent Entry exists?
|
||||
// 1.1. -> yes and research consent is given -> send mtb file
|
||||
// 1.2. -> no -> return status error - consent has not been asked
|
||||
// 2. -> Broad consent found but rejected -> is GenomDe consent provision 'sequencing' given?
|
||||
// 2.1 -> yes -> send mtb file
|
||||
// 2.2 -> no -> warn/info no consent given
|
||||
|
||||
/*
|
||||
* broad consent
|
||||
*/
|
||||
val broadConsent = consentService.getConsent(
|
||||
personIdentifierValue, requestDate, ConsentDomain.BROAD_CONSENT
|
||||
)
|
||||
val broadConsentHasBeenAsked = broadConsent.entry.isNotEmpty()
|
||||
|
||||
// fast exit - if patient has not been asked, we can skip and exit
|
||||
if (!broadConsentHasBeenAsked) return false
|
||||
|
||||
val genomeDeConsent = consentService.getConsent(
|
||||
personIdentifierValue, requestDate, ConsentDomain.MODELLVORHABEN_64E
|
||||
)
|
||||
|
||||
addGenomeDbProvisions(mtbFile, genomeDeConsent)
|
||||
|
||||
if (genomeDeConsent.entry.isNotEmpty()) setGenomDeSubmissionType(mtbFile)
|
||||
|
||||
embedBroadConsentResources(mtbFile, broadConsent)
|
||||
|
||||
val broadConsentStatus = getProvisionTypeByPolicyCode(
|
||||
broadConsent, requestDate, ConsentDomain.BROAD_CONSENT
|
||||
)
|
||||
|
||||
val genomDeSequencingStatus = getProvisionTypeByPolicyCode(
|
||||
genomeDeConsent, requestDate, ConsentDomain.MODELLVORHABEN_64E
|
||||
)
|
||||
|
||||
if (Consent.ConsentProvisionType.NULL == broadConsentStatus) {
|
||||
// bc not asked
|
||||
return false
|
||||
}
|
||||
if (Consent.ConsentProvisionType.PERMIT == broadConsentStatus || Consent.ConsentProvisionType.PERMIT == genomDeSequencingStatus) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun embedBroadConsentResources(mtbFile: Mtb, broadConsent: Bundle) {
|
||||
for (entry in broadConsent.entry) {
|
||||
val resource = entry.resource
|
||||
if (resource is Consent) {
|
||||
// since jackson convertValue does not work here,
|
||||
// we need another step to back to string, before we convert to object map
|
||||
val asJsonString = fhirContext.newJsonParser().encodeResourceToString(resource)
|
||||
try {
|
||||
val mapOfJson: HashMap<String?, Any?>? =
|
||||
objectMapper.readValue<HashMap<String?, Any?>?>(
|
||||
asJsonString, object : TypeReference<HashMap<String?, Any?>?>() {})
|
||||
mtbFile.metadata.researchConsents.add(mapOfJson)
|
||||
} catch (e: JsonProcessingException) {
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addGenomeDbProvisions(mtbFile: Mtb, consentGnomeDe: Bundle) {
|
||||
for (entry in consentGnomeDe.entry) {
|
||||
val resource = entry.resource
|
||||
if (resource !is Consent) {
|
||||
continue
|
||||
}
|
||||
|
||||
// We expect only one provision in collection, therefore get first or none
|
||||
val provisions = resource.provision.provision
|
||||
if (provisions.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
val provisionComponent: ProvisionComponent = provisions.first()
|
||||
|
||||
var provisionCode: String? = null
|
||||
if (provisionComponent.code != null && provisionComponent.code.isNotEmpty()) {
|
||||
val codableConcept: CodeableConcept = provisionComponent.code.first()
|
||||
if (codableConcept.coding != null && codableConcept.coding.isNotEmpty()) {
|
||||
provisionCode = codableConcept.coding.first().code
|
||||
}
|
||||
}
|
||||
|
||||
if (provisionCode != null) {
|
||||
try {
|
||||
val modelProjectConsentPurpose =
|
||||
ModelProjectConsentPurpose.forValue(provisionCode)
|
||||
|
||||
if (ModelProjectConsentPurpose.SEQUENCING == modelProjectConsentPurpose) {
|
||||
// CONVENTION: wrapping date is date of SEQUENCING consent
|
||||
mtbFile.metadata.modelProjectConsent.date = resource.dateTime
|
||||
}
|
||||
|
||||
val provision = Provision.builder()
|
||||
.type(ConsentProvision.valueOf(provisionComponent.type.name))
|
||||
.date(provisionComponent.period.start)
|
||||
.purpose(modelProjectConsentPurpose).build()
|
||||
|
||||
mtbFile.metadata.modelProjectConsent.provisions.add(provision)
|
||||
} catch (ioe: IOException) {
|
||||
logger.error(
|
||||
"Provision code '$provisionCode' is unknown and cannot be mapped.",
|
||||
ioe.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (mtbFile.metadata.modelProjectConsent.provisions.isNotEmpty()) {
|
||||
mtbFile.metadata.modelProjectConsent.version =
|
||||
gIcsConfigProperties.genomeDeConsentVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setGenomDeSubmissionType(mtbFile: Mtb) {
|
||||
if (appConfigProperties.genomDeTestSubmission) {
|
||||
mtbFile.metadata.type = MvhSubmissionType.TEST
|
||||
logger.info("genomeDe submission mit TEST")
|
||||
} else {
|
||||
mtbFile.metadata.type = when (mtbFile.metadata.type) {
|
||||
null -> MvhSubmissionType.INITIAL
|
||||
else -> mtbFile.metadata.type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param consentBundle consent resource
|
||||
* @param requestDate date which must be within validation period of provision
|
||||
* @return type of provision, will be [org.hl7.fhir.r4.model.Consent.ConsentProvisionType.NULL] if none is found.
|
||||
*/
|
||||
fun getProvisionTypeByPolicyCode(
|
||||
consentBundle: Bundle, requestDate: Date?, consentDomain: ConsentDomain
|
||||
): Consent.ConsentProvisionType {
|
||||
val code: String?
|
||||
val system: String?
|
||||
if (ConsentDomain.BROAD_CONSENT == consentDomain) {
|
||||
code = gIcsConfigProperties.broadConsentPolicyCode
|
||||
system = gIcsConfigProperties.broadConsentPolicySystem
|
||||
} else if (ConsentDomain.MODELLVORHABEN_64E == consentDomain) {
|
||||
code = gIcsConfigProperties.genomeDePolicyCode
|
||||
system = gIcsConfigProperties.genomeDePolicySystem
|
||||
} else {
|
||||
throw NotImplementedException("unknown consent domain " + consentDomain.name)
|
||||
}
|
||||
|
||||
val provisionTypeByPolicyCode = getProvisionTypeByPolicyCode(
|
||||
consentBundle, code, system, requestDate
|
||||
)
|
||||
return provisionTypeByPolicyCode
|
||||
}
|
||||
|
||||
/**
|
||||
* @param consentBundle consent resource
|
||||
* @param targetCode policyRule and provision code value
|
||||
* @param targetSystem policyRule and provision system value
|
||||
* @param requestDate date which must be within validation period of provision
|
||||
* @return type of provision, will be [org.hl7.fhir.r4.model.Consent.ConsentProvisionType.NULL] if none is found.
|
||||
*/
|
||||
fun getProvisionTypeByPolicyCode(
|
||||
consentBundle: Bundle, targetCode: String?, targetSystem: String?, requestDate: Date?
|
||||
): Consent.ConsentProvisionType {
|
||||
val entriesOfInterest = consentBundle.entry.filter { entry ->
|
||||
val isConsentResource =
|
||||
entry.resource.isResource && entry.resource.resourceType == ResourceType.Consent
|
||||
val consentIsActive = (entry.resource as Consent).status == ConsentState.ACTIVE
|
||||
|
||||
isConsentResource && consentIsActive && checkCoding(
|
||||
targetCode, targetSystem, (entry.resource as Consent).policyRule.coding
|
||||
) && isRequestDateInRange(requestDate, (entry.resource as Consent).provision.period)
|
||||
}.map { entry: BundleEntryComponent ->
|
||||
val consent = (entry.getResource() as Consent)
|
||||
consent.provision.provision.filter { subProvision ->
|
||||
isRequestDateInRange(requestDate, subProvision.period)
|
||||
// search coding entries of current provision for code and system
|
||||
subProvision.code.map { c -> c.coding }.flatten().firstOrNull { code ->
|
||||
targetCode.equals(code.code) && targetSystem.equals(code.system)
|
||||
} != null
|
||||
}.map { subProvision ->
|
||||
subProvision
|
||||
}
|
||||
}.flatten()
|
||||
|
||||
if (entriesOfInterest.isNotEmpty()) {
|
||||
return entriesOfInterest.first().type
|
||||
}
|
||||
return Consent.ConsentProvisionType.NULL
|
||||
}
|
||||
|
||||
fun checkCoding(
|
||||
researchAllowedPolicyOid: String?,
|
||||
researchAllowedPolicySystem: String?,
|
||||
policyRules: Collection<Coding>
|
||||
): Boolean {
|
||||
return policyRules.find { code ->
|
||||
researchAllowedPolicySystem.equals(code.getSystem()) && (researchAllowedPolicyOid.equals(
|
||||
code.getCode()
|
||||
))
|
||||
} != null
|
||||
}
|
||||
|
||||
fun isRequestDateInRange(requestDate: Date?, provPeriod: Period): Boolean {
|
||||
val isRequestDateAfterOrEqualStart = provPeriod.start.compareTo(requestDate)
|
||||
val isRequestDateBeforeOrEqualEnd = provPeriod.end.compareTo(requestDate)
|
||||
return isRequestDateAfterOrEqualStart <= 0 && isRequestDateBeforeOrEqualEnd >= 0
|
||||
}
|
||||
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -20,16 +20,28 @@
|
||||
package dev.dnpm.etl.processor.services
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.*
|
||||
import dev.dnpm.etl.processor.config.AppConfigProperties
|
||||
import dev.dnpm.etl.processor.consent.TtpConsentStatus
|
||||
import dev.dnpm.etl.processor.monitoring.Report
|
||||
import dev.dnpm.etl.processor.monitoring.Request
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.monitoring.RequestType
|
||||
import dev.dnpm.etl.processor.output.DeleteRequest
|
||||
import dev.dnpm.etl.processor.output.DnpmV2MtbFileRequest
|
||||
import dev.dnpm.etl.processor.output.MtbFileRequest
|
||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
|
||||
import dev.dnpm.etl.processor.pseudonym.addGenomDeTan
|
||||
import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith
|
||||
import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith
|
||||
import dev.pcvolkmer.mv64e.mtb.ConsentProvision
|
||||
import dev.pcvolkmer.mv64e.mtb.ModelProjectConsentPurpose
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
import org.apache.commons.codec.binary.Base32
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
@@ -42,34 +54,64 @@ class RequestProcessor(
|
||||
private val sender: MtbFileSender,
|
||||
private val requestService: RequestService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val appConfigProperties: AppConfigProperties,
|
||||
private val consentProcessor: ConsentProcessor?
|
||||
) {
|
||||
|
||||
fun processMtbFile(mtbFile: MtbFile) {
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val pid = mtbFile.patient.id
|
||||
private var logger: Logger = LoggerFactory.getLogger("RequestProcessor")
|
||||
|
||||
mtbFile pseudonymizeWith pseudonymizeService
|
||||
fun processMtbFile(mtbFile: Mtb) {
|
||||
processMtbFile(mtbFile, randomRequestId())
|
||||
}
|
||||
|
||||
val request = MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile))
|
||||
|
||||
fun processMtbFile(mtbFile: Mtb, requestId: RequestId) {
|
||||
val pid = PatientId(extractPatientIdentifier(mtbFile))
|
||||
|
||||
val isConsentOk =
|
||||
consentProcessor != null && consentProcessor.consentGatedCheckAndTryEmbedding(mtbFile) || consentProcessor == null
|
||||
if (isConsentOk) {
|
||||
if (isGenomDeConsented(mtbFile)) {
|
||||
mtbFile addGenomDeTan pseudonymizeService
|
||||
}
|
||||
mtbFile pseudonymizeWith pseudonymizeService
|
||||
mtbFile anonymizeContentWith pseudonymizeService
|
||||
val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile))
|
||||
saveAndSend(request, pid)
|
||||
} else {
|
||||
logger.warn("consent check failed file will not be processed further!")
|
||||
applicationEventPublisher.publishEvent(
|
||||
ResponseEvent(
|
||||
requestId, Instant.now(), RequestStatus.NO_CONSENT
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGenomDeConsented(mtbFile: Mtb): Boolean {
|
||||
val isModelProjectConsented = mtbFile.metadata?.modelProjectConsent?.provisions?.any { p ->
|
||||
p.purpose == ModelProjectConsentPurpose.SEQUENCING && p.type == ConsentProvision.PERMIT
|
||||
} == true
|
||||
return isModelProjectConsented
|
||||
}
|
||||
|
||||
private fun <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) {
|
||||
requestService.save(
|
||||
Request(
|
||||
uuid = requestId,
|
||||
patientId = request.mtbFile.patient.id,
|
||||
pid = pid,
|
||||
fingerprint = fingerprint(request.mtbFile),
|
||||
status = RequestStatus.UNKNOWN,
|
||||
type = RequestType.MTB_FILE
|
||||
request.requestId,
|
||||
request.patientPseudonym(),
|
||||
pid,
|
||||
fingerprint(request),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.UNKNOWN
|
||||
)
|
||||
)
|
||||
|
||||
if (isDuplication(mtbFile)) {
|
||||
if (appConfigProperties.duplicationDetection && isDuplication(request)) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
ResponseEvent(
|
||||
requestId,
|
||||
Instant.now(),
|
||||
RequestStatus.DUPLICATION
|
||||
request.requestId, Instant.now(), RequestStatus.DUPLICATION
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -79,65 +121,78 @@ class RequestProcessor(
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ResponseEvent(
|
||||
requestId,
|
||||
request.requestId,
|
||||
Instant.now(),
|
||||
responseStatus.status,
|
||||
when (responseStatus.status) {
|
||||
RequestStatus.WARNING -> Optional.of(responseStatus.body)
|
||||
RequestStatus.ERROR, RequestStatus.WARNING -> Optional.of(responseStatus.body)
|
||||
else -> Optional.empty()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean {
|
||||
val lastMtbFileRequestForPatient =
|
||||
requestService.lastMtbFileRequestForPatientPseudonym(pseudonymizedMtbFile.patient.id)
|
||||
val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(pseudonymizedMtbFile.patient.id)
|
||||
private fun <T> isDuplication(pseudonymizedMtbFileRequest: MtbFileRequest<T>): Boolean {
|
||||
val patientPseudonym = when (pseudonymizedMtbFileRequest) {
|
||||
is DnpmV2MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
|
||||
}
|
||||
|
||||
return null != lastMtbFileRequestForPatient
|
||||
&& !isLastRequestDeletion
|
||||
&& lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile)
|
||||
val lastMtbFileRequestForPatient =
|
||||
requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym)
|
||||
val isLastRequestDeletion =
|
||||
requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym)
|
||||
|
||||
return null != lastMtbFileRequestForPatient && !isLastRequestDeletion && lastMtbFileRequestForPatient.fingerprint == fingerprint(
|
||||
pseudonymizedMtbFileRequest
|
||||
)
|
||||
}
|
||||
|
||||
fun processDeletion(patientId: String) {
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
fun processDeletion(patientId: PatientId, isConsented: TtpConsentStatus) {
|
||||
processDeletion(patientId, randomRequestId(), isConsented)
|
||||
}
|
||||
|
||||
fun processDeletion(patientId: PatientId, requestId: RequestId, isConsented: TtpConsentStatus) {
|
||||
try {
|
||||
val patientPseudonym = pseudonymizeService.patientPseudonym(patientId)
|
||||
|
||||
val requestStatus: RequestStatus = when (isConsented) {
|
||||
TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED, TtpConsentStatus.BROAD_CONSENT_MISSING, TtpConsentStatus.BROAD_CONSENT_REJECTED -> RequestStatus.NO_CONSENT
|
||||
TtpConsentStatus.FAILED_TO_ASK -> RequestStatus.ERROR
|
||||
TtpConsentStatus.BROAD_CONSENT_GIVEN, TtpConsentStatus.UNKNOWN_CHECK_FILE -> RequestStatus.UNKNOWN
|
||||
TtpConsentStatus.GENOM_DE_CONSENT_SEQUENCING_PERMIT, TtpConsentStatus.GENOM_DE_CONSENT_MISSING, TtpConsentStatus.GENOM_DE_SEQUENCING_REJECTED -> {
|
||||
throw RuntimeException("processDelete should never deal with '" + isConsented.name + "' consent status. This is a bug and need to be fixed!")
|
||||
}
|
||||
}
|
||||
|
||||
requestService.save(
|
||||
Request(
|
||||
uuid = requestId,
|
||||
patientId = patientPseudonym,
|
||||
pid = patientId,
|
||||
fingerprint = fingerprint(patientPseudonym),
|
||||
status = RequestStatus.UNKNOWN,
|
||||
type = RequestType.DELETE
|
||||
requestId,
|
||||
patientPseudonym,
|
||||
patientId,
|
||||
fingerprint(patientPseudonym.value),
|
||||
RequestType.DELETE,
|
||||
requestStatus
|
||||
)
|
||||
)
|
||||
|
||||
val responseStatus = sender.send(MtbFileSender.DeleteRequest(requestId, patientPseudonym))
|
||||
val responseStatus = sender.send(DeleteRequest(requestId, patientPseudonym))
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ResponseEvent(
|
||||
requestId,
|
||||
Instant.now(),
|
||||
responseStatus.status,
|
||||
when (responseStatus.status) {
|
||||
requestId, Instant.now(), responseStatus.status, when (responseStatus.status) {
|
||||
RequestStatus.WARNING, RequestStatus.ERROR -> Optional.of(responseStatus.body)
|
||||
else -> Optional.empty()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
requestService.save(
|
||||
Request(
|
||||
uuid = requestId,
|
||||
patientId = "???",
|
||||
patientPseudonym = emptyPatientPseudonym(),
|
||||
pid = patientId,
|
||||
fingerprint = "",
|
||||
fingerprint = Fingerprint.empty(),
|
||||
status = RequestStatus.ERROR,
|
||||
type = RequestType.DELETE,
|
||||
report = Report("Fehler bei der Pseudonymisierung")
|
||||
@@ -146,14 +201,18 @@ class RequestProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun fingerprint(mtbFile: MtbFile): String {
|
||||
return fingerprint(objectMapper.writeValueAsString(mtbFile))
|
||||
private fun <T> fingerprint(request: MtbFileRequest<T>): Fingerprint {
|
||||
return when (request) {
|
||||
is DnpmV2MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content))
|
||||
}
|
||||
}
|
||||
|
||||
private fun fingerprint(s: String): String {
|
||||
return Base32().encodeAsString(DigestUtils.sha256(s))
|
||||
.replace("=", "")
|
||||
.lowercase()
|
||||
private fun fingerprint(s: String): Fingerprint {
|
||||
return Fingerprint(
|
||||
Base32().encodeAsString(DigestUtils.sha256(s)).replace("=", "").lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun extractPatientIdentifier(mtbFile: Mtb): String = mtbFile.patient.id
|
||||
|
@@ -19,11 +19,13 @@
|
||||
|
||||
package dev.dnpm.etl.processor.services
|
||||
|
||||
import dev.dnpm.etl.processor.monitoring.Request
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.monitoring.RequestType
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.RequestId
|
||||
import dev.dnpm.etl.processor.monitoring.*
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.*
|
||||
|
||||
@Service
|
||||
class RequestService(
|
||||
@@ -32,15 +34,32 @@ class RequestService(
|
||||
|
||||
fun save(request: Request) = requestRepository.save(request)
|
||||
|
||||
fun allRequestsByPatientPseudonym(patientPseudonym: String) = requestRepository
|
||||
.findAllByPatientIdOrderByProcessedAtDesc(patientPseudonym)
|
||||
fun findAll(): Iterable<Request> = requestRepository.findAll()
|
||||
|
||||
fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: String) =
|
||||
fun findAll(pageable: Pageable): Page<Request> = requestRepository.findAll(pageable)
|
||||
|
||||
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))
|
||||
|
||||
fun isLastRequestWithKnownStatusDeletion(patientPseudonym: String) =
|
||||
fun isLastRequestWithKnownStatusDeletion(patientPseudonym: 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 {
|
||||
|
||||
fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests
|
||||
|
@@ -19,8 +19,8 @@
|
||||
|
||||
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.RequestRepository
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.event.EventListener
|
||||
@@ -31,7 +31,7 @@ import java.util.*
|
||||
|
||||
@Service
|
||||
class ResponseProcessor(
|
||||
private val requestRepository: RequestRepository,
|
||||
private val requestService: RequestService,
|
||||
private val statisticsUpdateProducer: Sinks.Many<Any>
|
||||
) {
|
||||
|
||||
@@ -39,7 +39,7 @@ class ResponseProcessor(
|
||||
|
||||
@EventListener(classes = [ResponseEvent::class])
|
||||
fun handleResponseEvent(event: ResponseEvent) {
|
||||
requestRepository.findByUuidEquals(event.requestUuid).ifPresentOrElse({
|
||||
requestService.findByUuid(event.requestUuid).ifPresentOrElse({
|
||||
it.processedAt = event.timestamp
|
||||
it.status = event.status
|
||||
|
||||
@@ -70,13 +70,19 @@ class ResponseProcessor(
|
||||
)
|
||||
}
|
||||
|
||||
RequestStatus.NO_CONSENT -> {
|
||||
it.report = Report(
|
||||
"Einwilligung Status fehlt, widerrufen oder ungeklärt."
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.error("Cannot process response: Unknown response!")
|
||||
return@ifPresentOrElse
|
||||
}
|
||||
}
|
||||
|
||||
requestRepository.save(it)
|
||||
requestService.save(it)
|
||||
|
||||
statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST)
|
||||
}, {
|
||||
@@ -87,7 +93,7 @@ class ResponseProcessor(
|
||||
}
|
||||
|
||||
data class ResponseEvent(
|
||||
val requestUuid: String,
|
||||
val requestUuid: RequestId,
|
||||
val timestamp: Instant,
|
||||
val status: RequestStatus,
|
||||
val body: Optional<String> = Optional.empty()
|
||||
|
@@ -22,11 +22,17 @@ 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 {
|
||||
var json = objectMapper.writeValueAsString(mtbFile)
|
||||
|
||||
fun transform(mtbFile: Mtb): Mtb {
|
||||
val json = transform(objectMapper.writeValueAsString(mtbFile))
|
||||
return objectMapper.readValue(json, Mtb::class.java)
|
||||
}
|
||||
|
||||
private fun transform(content: String): String {
|
||||
var json = content
|
||||
|
||||
transformations.forEach { transformation ->
|
||||
val jsonPath = JsonPath.parse(json)
|
||||
@@ -48,7 +54,7 @@ class TransformationService(private val objectMapper: ObjectMapper, private val
|
||||
json = jsonPath.jsonString()
|
||||
}
|
||||
|
||||
return objectMapper.readValue(json, MtbFile::class.java)
|
||||
return json
|
||||
}
|
||||
|
||||
fun getTransformations(): List<Transformation> {
|
||||
|
@@ -22,6 +22,7 @@ package dev.dnpm.etl.processor.services.kafka
|
||||
import com.fasterxml.jackson.annotation.JsonAlias
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.RequestId
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.output.asRequestStatus
|
||||
import dev.dnpm.etl.processor.services.ResponseEvent
|
||||
@@ -47,7 +48,7 @@ class KafkaResponseProcessor(
|
||||
Optional.empty()
|
||||
}.ifPresentOrElse({ responseBody ->
|
||||
val event = ResponseEvent(
|
||||
responseBody.requestId,
|
||||
RequestId(responseBody.requestId),
|
||||
Instant.ofEpochMilli(data.timestamp()),
|
||||
responseBody.statusCode.asRequestStatus(),
|
||||
when (responseBody.statusCode.asRequestStatus()) {
|
||||
|
63
src/main/kotlin/dev/dnpm/etl/processor/types.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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"
|
||||
}
|
221
src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt
Normal file
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* 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.*
|
||||
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()
|
||||
|
||||
val gIcsConnectionAvailable =
|
||||
connectionCheckServices.filterIsInstance<GIcsConnectionCheckService>().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("gIcsConnectionAvailable", gIcsConnectionAvailable)
|
||||
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"
|
||||
}
|
||||
|
||||
@GetMapping(params = ["gIcsConnectionAvailable"])
|
||||
fun gIcsConnectionAvailable(model: Model): String {
|
||||
val gIcsConnectionAvailable =
|
||||
connectionCheckServices.filterIsInstance<GIcsConnectionCheckService>().firstOrNull()?.connectionAvailable()
|
||||
|
||||
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
|
||||
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
|
||||
model.addAttribute("gIcsConnectionAvailable", gIcsConnectionAvailable)
|
||||
if (tokenService != null) {
|
||||
model.addAttribute("tokensEnabled", true)
|
||||
model.addAttribute("tokens", tokenService.findAll())
|
||||
} else {
|
||||
model.addAttribute("tokens", listOf<Token>())
|
||||
}
|
||||
|
||||
return "configs/gIcsConnectionAvailable"
|
||||
}
|
||||
|
||||
@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"
|
||||
is ConnectionCheckResult.GIcsConnectionCheckResult -> "gics-connection-check"
|
||||
}
|
||||
|
||||
ServerSentEvent.builder<Any>()
|
||||
.event(event).id("none").data(it)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -20,9 +20,13 @@
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
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.RequestId
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.services.RequestService
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.web.PageableDefault
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.ui.Model
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@@ -32,13 +36,29 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
@Controller
|
||||
@RequestMapping(path = ["/"])
|
||||
class HomeController(
|
||||
private val requestRepository: RequestRepository,
|
||||
private val requestService: RequestService,
|
||||
private val reportService: ReportService
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
fun index(model: Model): String {
|
||||
val requests = requestRepository.findAll().sortedByDescending { it.processedAt }.take(25)
|
||||
fun index(
|
||||
@PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable,
|
||||
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)
|
||||
|
||||
return "index"
|
||||
@@ -46,7 +66,7 @@ class HomeController(
|
||||
|
||||
@GetMapping(path = ["/report/{id}"])
|
||||
fun report(@PathVariable id: RequestId, model: Model): String {
|
||||
val request = requestRepository.findByUuidEquals(id.toString()).orElse(null) ?: throw NotFoundException()
|
||||
val request = requestService.findByUuid(id).orElse(null) ?: throw NotFoundException()
|
||||
model.addAttribute("request", request)
|
||||
model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport))
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 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
|
||||
@@ -19,23 +19,29 @@
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.services.TransformationService
|
||||
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
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
|
||||
@Controller
|
||||
@RequestMapping(path = ["transformations"])
|
||||
class TransformationController(
|
||||
private val transformationService: TransformationService
|
||||
class LoginController(
|
||||
private val securityConfigProperties: SecurityConfigProperties?,
|
||||
private val oAuth2ClientProperties: OAuth2ClientProperties?
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
fun index(model: Model): String {
|
||||
model.addAttribute("transformations", transformationService.getTransformations())
|
||||
|
||||
return "transformations"
|
||||
@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"
|
||||
}
|
||||
|
||||
}
|
@@ -19,9 +19,10 @@
|
||||
|
||||
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.RequestType
|
||||
import dev.dnpm.etl.processor.services.RequestService
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.codec.ServerSentEvent
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@@ -38,16 +39,17 @@ import java.time.temporal.ChronoUnit
|
||||
@RestController
|
||||
@RequestMapping(path = ["/statistics"])
|
||||
class StatisticsRestController(
|
||||
@Qualifier("statisticsUpdateProducer")
|
||||
private val statisticsUpdateProducer: Sinks.Many<Any>,
|
||||
private val requestRepository: RequestRepository
|
||||
private val requestService: RequestService
|
||||
) {
|
||||
|
||||
@GetMapping(path = ["requeststates"])
|
||||
fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
|
||||
val states = if (delete) {
|
||||
requestRepository.countDeleteStates()
|
||||
requestService.countDeleteStates()
|
||||
} else {
|
||||
requestRepository.countStates()
|
||||
requestService.countStates()
|
||||
}
|
||||
|
||||
return states
|
||||
@@ -77,7 +79,7 @@ class StatisticsRestController(
|
||||
}
|
||||
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin"))
|
||||
val data = requestRepository.findAll()
|
||||
val data = requestService.findAll()
|
||||
.filter { it.type == requestType }
|
||||
.filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) }
|
||||
.groupBy { formatter.format(it.processedAt) }
|
||||
@@ -113,9 +115,9 @@ class StatisticsRestController(
|
||||
@GetMapping(path = ["requestpatientstates"])
|
||||
fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
|
||||
val states = if (delete) {
|
||||
requestRepository.findPatientUniqueDeleteStates()
|
||||
requestService.findPatientUniqueDeleteStates()
|
||||
} else {
|
||||
requestRepository.findPatientUniqueStates()
|
||||
requestService.findPatientUniqueStates()
|
||||
}
|
||||
|
||||
return states.map {
|
||||
@@ -152,6 +154,10 @@ class StatisticsRestController(
|
||||
.build(),
|
||||
ServerSentEvent.builder<Any>()
|
||||
.event("deleterequestpatientstates").id("none").data(this.requestPatientStates(true))
|
||||
.build(),
|
||||
|
||||
ServerSentEvent.builder<Any>()
|
||||
.event("newrequest").id("none").data("newrequest")
|
||||
.build()
|
||||
)
|
||||
)
|
||||
|
@@ -3,14 +3,128 @@ spring:
|
||||
compose:
|
||||
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
|
||||
|
||||
# kafka:
|
||||
# security:
|
||||
# protocol: "SSL"
|
||||
# ssl:
|
||||
# key-store-type: "PEM"
|
||||
# key-store-certificate-chain: -----BEGIN CERTIFICATE-----
|
||||
# MIIDCzCCAfOgAwIBAgIUaXNh4PahaKeLUaab2rUPSVESx28wDQYJKoZIhvcNAQEL
|
||||
# BQAwFTETMBEGA1UEAwwKRXhhbXBsZSBDQTAeFw0yNTA4MjExODEyMTFaFw0zNTA4
|
||||
# MTkxODEyMTFaMBUxEzARBgNVBAMMCkV4YW1wbGUgQ0EwggEiMA0GCSqGSIb3DQEB
|
||||
# AQUAA4IBDwAwggEKAoIBAQCsqalqVOLFglVbX9oSHU91ebyL1kPyb/2N90UGQIcD
|
||||
# UAjzKxxysId1Vdvtbbwgli6UgfPwlzFP2Wlw51h496yL4QU/9tNV956UJ1RoS/fG
|
||||
# qBAEHctqavfMI27UQmIzw4pGMkGzEQxRMc6a9pdabBhbMMTJsjtmOv2YMYHj1HHK
|
||||
# Dr7wTBTt2l0eRyCR0kZ8XGIMWhYowPa4EMpC7+4e5Nf/7LSJZWLLy9jXPpazsjkJ
|
||||
# jEmDNlFfx2tZiq0Wz2Xj1pZSDLbcuIX4IHcLfMvagibfrCMX/h6+WuW42sWPRuBW
|
||||
# wB6cHGlXs+K/gBBWxtD7sOTacO5hbHFsfaJOhSEIGoIpAgMBAAGjUzBRMB0GA1Ud
|
||||
# DgQWBBT2S/C2++ECY+CSuN5KKql0umfbTDAfBgNVHSMEGDAWgBT2S/C2++ECY+CS
|
||||
# uN5KKql0umfbTDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBj
|
||||
# H4DdwqrOHg7sVsqiwDsZfTharpUDCYeG5XhrJQlnA9eKwyofTb929W/fjOwBdDtg
|
||||
# 9THT/omR0lA8/UyHtezMT6nMsCn4HG2mXvx6ghgvA3jrFTEY7R80dHkboLMTV3u4
|
||||
# RYgC9S3BJPcbJYpM0cXzkp2T0F4FxWZlfqefuedHuX3zcCxpgVD56qQb2a131TX7
|
||||
# O3UDJfVg8a65IFtehndqILgLVrf7w6+pbmDAlCg5RKrt2USEYyZXYdyTryJbdtn4
|
||||
# BCLp0avYtSYVUGwgH0oUCpkjQRwMg1003TTz8SNnmE7mAXHYljyYejnjL8vBHfch
|
||||
# 8tTDVXQn08BT9H3jZTnF
|
||||
# -----END CERTIFICATE-----
|
||||
# -----BEGIN CERTIFICATE-----
|
||||
# MIIC+TCCAeGgAwIBAgIUUoCwz8GS6xQ3mmI7RUUYSNPIOi4wDQYJKoZIhvcNAQEL
|
||||
# BQAwFTETMBEGA1UEAwwKRXhhbXBsZSBDQTAeFw0yNTA4MjExODE0NDhaFw0zNTA4
|
||||
# MTkxODE0NDhaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEB
|
||||
# BQADggEPADCCAQoCggEBAL9PW99MhhBwdEmTHyZgfnhfTrxZPrNU6z1UdV8b82Lk
|
||||
# 3p75o8eCKa9iOd7DDQlo75hQBhhX0+Xc3mucrstx5p8TYFMbypif8ojWh3LM++P8
|
||||
# tz3ezQZlq86ycyKpm8dqlA03b227tFDfiYTev2eD2HN40BU7yDAYhhqd/QW8+MV2
|
||||
# jkcRGv5cE21GZxWmPUpkVN+bNoBC8H90WmkST90LfeYF+wZnlsAJZH6AQzR1GnGD
|
||||
# ICE5evMhC78hvRnpgeA310SyxssZEigkePL5lTZOBPY2IuzBqL05agyVTiVq4Xd6
|
||||
# y3xOqXoxxOhZu06yd3nymorqeTgbF1fW8wQF0u3KsFECAwEAAaNCMEAwHQYDVR0O
|
||||
# BBYEFHk9jMWRAAt2YsBSxUcOQVoWayoHMB8GA1UdIwQYMBaAFPZL8Lb74QJj4JK4
|
||||
# 3koqqXS6Z9tMMA0GCSqGSIb3DQEBCwUAA4IBAQBqabAA9INONDaLHqs9i9YQHm/g
|
||||
# AnB7xRl/RFbERKKCTSMZUYM8oEaaH0W2ENoPMc/7xOB/R8a7Rm62PTr6syxwhZrY
|
||||
# 5NtGKJOD+rh90/5l83tulf93KqOJtGkiv6NBDvCNrITcA+UKRk/z4GcFi2YjWAl4
|
||||
# wvY44lzTasMKSpjUQ5N0VNANcW3nVuEgPQ8rrr0NOK/5j4guPjsXDsixa47gqblA
|
||||
# 5xGfBKeVmEXdPbzawZfP4hPIw7DpX2m8Y0erswF1ZxkIV73V3TDsFSLcqSKSzZr6
|
||||
# mtj8COlV9Us7zqaJbV5eOl7GN1T9orZJwZmX1Z46gCkkSLYDP/dqtl2j9JgN
|
||||
# -----END CERTIFICATE-----
|
||||
# ### For dev/testing purposes only!
|
||||
# key-store-key: -----BEGIN PRIVATE KEY-----
|
||||
# MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/T1vfTIYQcHRJ
|
||||
# kx8mYH54X068WT6zVOs9VHVfG/Ni5N6e+aPHgimvYjneww0JaO+YUAYYV9Pl3N5r
|
||||
# nK7LceafE2BTG8qYn/KI1odyzPvj/Lc93s0GZavOsnMiqZvHapQNN29tu7RQ34mE
|
||||
# 3r9ng9hzeNAVO8gwGIYanf0FvPjFdo5HERr+XBNtRmcVpj1KZFTfmzaAQvB/dFpp
|
||||
# Ek/dC33mBfsGZ5bACWR+gEM0dRpxgyAhOXrzIQu/Ib0Z6YHgN9dEssbLGRIoJHjy
|
||||
# +ZU2TgT2NiLswai9OWoMlU4lauF3est8Tql6McToWbtOsnd58pqK6nk4GxdX1vME
|
||||
# BdLtyrBRAgMBAAECggEAC1wXfPlqxoQe65WAVoOJTvV90+JKvlRPCZu/wm+C8r7b
|
||||
# Vz5Ekt6wQflHrWoQlpv0CivKSNzCONZ2IJazrGHti0mXwSeXzptEyApRDaiNVnrV
|
||||
# mKdnrjcQThw7iPXgSaWS9/vwMmhgayLy5ABkBi4GhsjINlKP7wctw1vZP+N6NCNd
|
||||
# Ql3taStvDKmG0SfJHF6/2o/XBpof3IJEL7ghbzyTTbWWaO34J1mJ8A+AmjGhj9GE
|
||||
# Dp3XuOrO9W7MVd1nfZDtGBS8qf80AwROyodZZRma9vZuWJZ5aQFi2CnUEtU1T+Uv
|
||||
# tW+F6tg2FOMr8M0Fb79wGIDwSF8u/QcTvwhEzZAfiQKBgQDioOofnE1oB1DOMnqZ
|
||||
# SOFjs+vsirvS6G3lo27+HkE3TgvCHR4sk1305AiXtjmPu8iaUCo9qn18MtduY2RS
|
||||
# CcKMOG/FxhmDyP5I29DhJRhvERIpJd0kcSDQOgtaoVPC1XzIlyTqte6nGX9kAnA/
|
||||
# x/OOXrZ0hjhMNDcZzf2NasPYJQKBgQDYGqTobkVBk+eekNWklnTh41/649rUIgTu
|
||||
# JStArtY2hgaEInYcGa2e7cEj7nIHA0iGy3EJ2yvwoUIyxtoXVcGohu2IrzlhS33T
|
||||
# R4jA7nE2/yHZrEMEJovuSU0eMw7rgvEtL79Q0RToYnTY1EU6X/BBoFfiiEeNMHKz
|
||||
# zjDOOQ6ZvQKBgGCWChIc0FSkwYiPtPZ9PCn89XCjk/cIPkYfiF9fT5Ydeh9pv4Fp
|
||||
# 8SI8yXi3HgMnGhDCV65eagqztGMEky3voO2X4/MbQaaL0+wDWxuJbsdvNBk7XOt6
|
||||
# F20HP+2JUiR4Ti1DVWV+0k5/LG7YJzTXp/KmZQZ2aan4mv8xbn2F4h/NAoGAI4ou
|
||||
# OLN53FEQtHkpSYoc6tFUBZTXdi+qE+g09sxKGmlsROrN9c0bSpnbO6eJRTH7CYAH
|
||||
# tRFAZrB+jI87ar8FvEuEYQhALYoWxVpsWR5drCfFT2EPHG2icavIbQEEoSLFuyKx
|
||||
# Gf9oqtcWVFqEkBcbEg/mpDC5Y7TmCEAOsrubdRkCgYEAl7B+EzIdG0rabGoti09q
|
||||
# QXfyiTjR7nQYkhpLxMCeNlCpQ8Y15XSa8bm1UIGYqj/ZBpeBNhrj64IHoub5Vd43
|
||||
# tzbb8yNgoLUd16TU1VvyccCMGQVPIF8RkDsAtEawV2eoXbHAstN99xbC8jsLNZRQ
|
||||
# fcfgTiQaXRJmlVx6jfbfZd4=
|
||||
# -----END PRIVATE KEY-----
|
||||
# trust-store-type: "PEM"
|
||||
# trust-store-certificates: -----BEGIN CERTIFICATE-----
|
||||
# MIIDCzCCAfOgAwIBAgIUaXNh4PahaKeLUaab2rUPSVESx28wDQYJKoZIhvcNAQEL
|
||||
# BQAwFTETMBEGA1UEAwwKRXhhbXBsZSBDQTAeFw0yNTA4MjExODEyMTFaFw0zNTA4
|
||||
# MTkxODEyMTFaMBUxEzARBgNVBAMMCkV4YW1wbGUgQ0EwggEiMA0GCSqGSIb3DQEB
|
||||
# AQUAA4IBDwAwggEKAoIBAQCsqalqVOLFglVbX9oSHU91ebyL1kPyb/2N90UGQIcD
|
||||
# UAjzKxxysId1Vdvtbbwgli6UgfPwlzFP2Wlw51h496yL4QU/9tNV956UJ1RoS/fG
|
||||
# qBAEHctqavfMI27UQmIzw4pGMkGzEQxRMc6a9pdabBhbMMTJsjtmOv2YMYHj1HHK
|
||||
# Dr7wTBTt2l0eRyCR0kZ8XGIMWhYowPa4EMpC7+4e5Nf/7LSJZWLLy9jXPpazsjkJ
|
||||
# jEmDNlFfx2tZiq0Wz2Xj1pZSDLbcuIX4IHcLfMvagibfrCMX/h6+WuW42sWPRuBW
|
||||
# wB6cHGlXs+K/gBBWxtD7sOTacO5hbHFsfaJOhSEIGoIpAgMBAAGjUzBRMB0GA1Ud
|
||||
# DgQWBBT2S/C2++ECY+CSuN5KKql0umfbTDAfBgNVHSMEGDAWgBT2S/C2++ECY+CS
|
||||
# uN5KKql0umfbTDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBj
|
||||
# H4DdwqrOHg7sVsqiwDsZfTharpUDCYeG5XhrJQlnA9eKwyofTb929W/fjOwBdDtg
|
||||
# 9THT/omR0lA8/UyHtezMT6nMsCn4HG2mXvx6ghgvA3jrFTEY7R80dHkboLMTV3u4
|
||||
# RYgC9S3BJPcbJYpM0cXzkp2T0F4FxWZlfqefuedHuX3zcCxpgVD56qQb2a131TX7
|
||||
# O3UDJfVg8a65IFtehndqILgLVrf7w6+pbmDAlCg5RKrt2USEYyZXYdyTryJbdtn4
|
||||
# BCLp0avYtSYVUGwgH0oUCpkjQRwMg1003TTz8SNnmE7mAXHYljyYejnjL8vBHfch
|
||||
# 8tTDVXQn08BT9H3jZTnF
|
||||
# -----END CERTIFICATE-----
|
||||
|
||||
app:
|
||||
#rest:
|
||||
# uri: http://localhost:9000/bwhc/etl/api
|
||||
kafka:
|
||||
topic: test
|
||||
response-topic: test_response
|
||||
servers: localhost:9094
|
||||
rest:
|
||||
uri: http://localhost/api
|
||||
#kafka:
|
||||
# servers: localhost:9094
|
||||
# group-id: "test1234"
|
||||
# input-topic: test_input
|
||||
# output-topic: test_output
|
||||
# output-response-topic: test_response
|
||||
security:
|
||||
admin-user: admin
|
||||
admin-password: "{noop}very-secret"
|
||||
enable-oidc: "true"
|
||||
|
||||
server:
|
||||
port: 8000
|
||||
|
||||
|
||||
|
@@ -5,3 +5,16 @@ spring:
|
||||
group-id: ${app.kafka.group-id}
|
||||
flyway:
|
||||
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
|
||||
|
@@ -0,0 +1,8 @@
|
||||
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
|
||||
);
|
@@ -0,0 +1,7 @@
|
||||
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
|
||||
);
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;
|
@@ -0,0 +1,9 @@
|
||||
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)
|
||||
);
|
@@ -0,0 +1,8 @@
|
||||
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)
|
||||
);
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;
|
BIN
src/main/resources/static/bg.jpeg
Normal file
After Width: | Height: | Size: 3.0 KiB |
45
src/main/resources/static/echarts.min.js
vendored
41
src/main/resources/static/icon.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?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>
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/main/resources/static/kafka.png
Normal file
After 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 dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions);
|
||||
|
||||
window.onload = () => {
|
||||
const formatTimeElements = () => {
|
||||
Array.from(document.getElementsByTagName('time')).forEach((timeTag) => {
|
||||
let date = Date.parse(timeTag.getAttribute('datetime'));
|
||||
if (! isNaN(date)) {
|
||||
@@ -13,6 +13,9 @@ window.onload = () => {
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('load', formatTimeElements);
|
||||
window.addEventListener('htmx:afterRequest', formatTimeElements);
|
||||
|
||||
function drawPieChart(url, elemId, title, data) {
|
||||
if (data) {
|
||||
update(elemId, data);
|
||||
|
BIN
src/main/resources/static/server.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
@@ -1,5 +1,8 @@
|
||||
:root {
|
||||
--table-border: rgba(96, 96, 96, 1);
|
||||
--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);
|
||||
@@ -19,47 +22,135 @@
|
||||
--bg-gray-op: rgba(112, 128, 144, .35);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
* {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
background: linear-gradient(-5deg, var(--bg-blue-op), transparent 10em);
|
||||
min-height: 100vh;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 0 5em 0;
|
||||
font-size: .8rem;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
|
||||
min-height: 100vh;
|
||||
|
||||
background: url(bg.jpeg) no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
nav {
|
||||
margin: 0 auto;
|
||||
background: #d5dad5;
|
||||
height: 3rem;
|
||||
padding: 1em 0;
|
||||
|
||||
line-height: 1.5rem;
|
||||
max-width: 1140px;
|
||||
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #004a8f;
|
||||
text-transform: uppercase;
|
||||
nav a.nav-home {
|
||||
float: left;
|
||||
|
||||
color: var(--text);
|
||||
line-height: 1.5em;
|
||||
text-decoration: none;
|
||||
line-height: 2rem;
|
||||
font-weight: 700;
|
||||
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
nav a.nav-home > img {
|
||||
width: 1.5em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
nav > ul {
|
||||
margin: 0 3rem;
|
||||
margin: 0 0 0 auto;
|
||||
padding: 0;
|
||||
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
nav > ul > li {
|
||||
background: #fbfbfb;
|
||||
display: block;
|
||||
float: left;
|
||||
padding: 2px 1rem;
|
||||
border-left: 1px solid #d5dad5;
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
nav > ul > li:first-of-type {
|
||||
border-left: none;
|
||||
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-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
nav li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--bg-blue);
|
||||
}
|
||||
|
||||
.breadcrumps {
|
||||
@@ -85,7 +176,7 @@ nav > ul > li:first-of-type {
|
||||
}
|
||||
|
||||
.breadcrumps ul li a {
|
||||
color: #333333;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -98,6 +189,10 @@ main {
|
||||
max-width: 1140px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin: 3em 0;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
@@ -139,16 +234,139 @@ form.samplecode-input input:focus-visible {
|
||||
background: lightgreen;
|
||||
}
|
||||
|
||||
table {
|
||||
border-top: 1px solid var(--table-border);
|
||||
border-left: 1px solid var(--table-border);
|
||||
border-spacing: 0;
|
||||
border-radius: 3px;
|
||||
.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 {
|
||||
min-width: 100%;
|
||||
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 {
|
||||
width: 100vw;
|
||||
position: fixed;
|
||||
@@ -165,64 +383,78 @@ table.samples {
|
||||
display: block;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
td, th {
|
||||
th, td {
|
||||
padding: 0.4rem .2rem;
|
||||
|
||||
border-right: 1px solid var(--table-border);
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
line-height: 2em;
|
||||
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
border-bottom: 1px solid var(--bg-gray);
|
||||
}
|
||||
|
||||
td {
|
||||
font-family: monospace;
|
||||
border-bottom: 1px solid var(--bg-gray-op);
|
||||
}
|
||||
|
||||
td.bg-blue, th.bg-blue {
|
||||
tr:last-of-type > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
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;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
tr:has(td.bg-blue) {
|
||||
background: var(--bg-blue-op);
|
||||
}
|
||||
|
||||
td.bg-green, th.bg-green {
|
||||
td.bg-green > small, th.bg-green > small {
|
||||
background: var(--bg-green);
|
||||
color: white;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
tr:has(td.bg-green) {
|
||||
background: var(--bg-green-op);
|
||||
}
|
||||
|
||||
td.bg-yellow, th.bg-yellow {
|
||||
td.bg-yellow > small, th.bg-yellow > small {
|
||||
background: var(--bg-yellow);
|
||||
color: white;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
tr:has(td.bg-yellow) {
|
||||
background: var(--bg-yellow-op);
|
||||
}
|
||||
|
||||
td.bg-red, th.bg-red {
|
||||
td.bg-red > small, th.bg-red > small {
|
||||
background: var(--bg-red);
|
||||
color: white;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
tr:has(td.bg-red) {
|
||||
background: var(--bg-red-op);
|
||||
}
|
||||
|
||||
td.bg-gray, th.bg-gray {
|
||||
td.bg-gray > small, th.bg-gray > small {
|
||||
background: var(--bg-gray);
|
||||
color: white;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
.bg-path {
|
||||
@@ -261,7 +493,6 @@ td.clipboard.clipped {
|
||||
padding: 4px 8px;
|
||||
|
||||
line-height: 1.2rem;
|
||||
vertical-align: middle;
|
||||
|
||||
border: 0 solid transparent;
|
||||
border-radius: 3px;
|
||||
@@ -273,38 +504,38 @@ td.clipboard.clipped {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:active,
|
||||
.btn:hover {
|
||||
filter: drop-shadow(1px 2px 2px gray);
|
||||
filter: drop-shadow(0px 1px 1px var(--bg-gray)) var(--dark);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
filter: drop-shadow(1px 1px 2px gray);
|
||||
translate: 0 1px;
|
||||
}
|
||||
|
||||
.btn.btn-red {
|
||||
background: red;
|
||||
background: var(--bg-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.btn-red:hover, .btn.btn-red:active {
|
||||
background: darkred !important;
|
||||
}
|
||||
|
||||
.btn.btn-blue {
|
||||
background: slategray;
|
||||
background: var(--bg-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.btn-blue:hover, .btn.btn-blue:active {
|
||||
background: darkslategray !important;
|
||||
}
|
||||
|
||||
.btn.btn-delete:before {
|
||||
content: '\1F5D1';
|
||||
padding: .2rem;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
.btn:disabled {
|
||||
background: slategray !important;
|
||||
color: lightgray;
|
||||
filter: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input.inline {
|
||||
border: none;
|
||||
font-size: 1.1rem;
|
||||
@@ -340,19 +571,140 @@ input.inline:focus-visible {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chart {
|
||||
padding: 1rem;
|
||||
margin: .2rem;
|
||||
.charts {
|
||||
display: grid;
|
||||
grid-gap: 1em;
|
||||
grid-template:
|
||||
"a b" 28em
|
||||
"c c" 28em / 1fr 1fr;
|
||||
}
|
||||
|
||||
border: 1px solid var(--table-border);
|
||||
.charts > .grid-left {
|
||||
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;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
width: calc(100% - 2.4rem - 4px);
|
||||
height: 320px;
|
||||
.notification.info {
|
||||
color: var(--bg-blue);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.chart-50pc {
|
||||
width: calc(50% - 2.4rem - 4px);
|
||||
.no-token {
|
||||
padding: 1em;
|
||||
background: var(--bg-red-op);
|
||||
}
|
||||
|
||||
.issue-message {
|
||||
font-family: monospace;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.issue-path {
|
||||
font-family: monospace;
|
||||
line-height: 1rem;
|
||||
}
|
11
src/main/resources/static/user.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?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>
|
After Width: | Height: | Size: 588 B |
130
src/main/resources/templates/configs.html
Normal file
@@ -0,0 +1,130 @@
|
||||
<!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/gIcsConnectionAvailable.html}" th:hx-get="@{/configs?gIcsConnectionAvailable}" hx-trigger="sse:gics-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>
|
@@ -0,0 +1,24 @@
|
||||
<th:block th:if="${gIcsConnectionAvailable == null}">
|
||||
<h2><span>🟦</span> gICS nicht konfiguriert - Einwilligung wird über Dateiinhalt geprüft</h2>
|
||||
</th:block>
|
||||
<th:block th:if="${gIcsConnectionAvailable != null}">
|
||||
<h2><span th:if="${gIcsConnectionAvailable.available}">✅</span><span th:if="${not(gIcsConnectionAvailable.available)}">⚡</span> Verbindung zu gICS</h2>
|
||||
<div>
|
||||
Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gIcsConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(gIcsConnectionAvailable.timestamp)}"></time>
|
||||
|
|
||||
Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gIcsConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(gIcsConnectionAvailable.lastChange)}"></time>
|
||||
</div>
|
||||
<div>
|
||||
<span>Die Verbindung ist aktuell</span>
|
||||
<strong th:if="${gIcsConnectionAvailable.available}" style="color: green">verfügbar.</strong>
|
||||
<strong th:if="${not(gIcsConnectionAvailable.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="${gIcsConnectionAvailable.available ? 'available' : ''}"></span>
|
||||
<img th:src="@{/server.png}" alt="gICS" />
|
||||
<span>ETL-Processor</span>
|
||||
<span></span>
|
||||
<span>gICS</span>
|
||||
</div>
|
||||
</th:block>
|
@@ -0,0 +1,24 @@
|
||||
<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>
|
@@ -0,0 +1,27 @@
|
||||
<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>
|