1
0
mirror of https://github.com/pcvolkmer/etl-processor.git synced 2025-04-20 01:36:50 +00:00

Compare commits

..

276 Commits

Author SHA1 Message Date
c6b37fda69
feat: support multiple request content types (#109) 2025-04-06 22:17:46 +02:00
8e3de6a220
feat: add pseudonymization for patient IDs (#107) 2025-04-06 14:42:09 +02:00
c5c553f817
refactor: move CustomMediaType into types.kt (#105) 2025-04-06 13:43:58 +02:00
7d97365aea
feat: add endpoint for DNPM-Datamodel V2 using content negotiation (#104)
This simply adds an REST endpoint without proper implementation. The goal is to accept DNPM V2 JSON data.
2025-04-06 13:36:30 +02:00
48b1e62e22
feat: remove obsolete config params (#101) 2025-04-04 17:31:50 +02:00
66cc818755
feat: remove SSL-CA-Location config (#99) 2025-04-04 17:06:09 +02:00
9d4786fae3
refactor: update use of deprecated methods (#96) 2025-04-04 16:39:47 +02:00
b78dc3519b
refactor: replace deprecated MockBean annotations (#95) 2025-04-04 16:13:07 +02:00
46015c5b66 chore: update to Spring Boot 3.4 2025-04-04 15:33:49 +02:00
a4d0b73d2b docs: update some information in README.md 2025-04-04 14:55:14 +02:00
9307fc0dad docs: change etl image and highlight important information 2025-04-04 14:49:37 +02:00
586d388e57 docs: add info about DNPM:DIP support 2025-04-04 14:36:30 +02:00
7ae34719fd
feat: add new MTB endpoint path (#93) 2025-04-04 14:34:31 +02:00
033750eb10
feat: show issue path if available in response body (#92) 2025-04-04 13:59:51 +02:00
befeef3153
feat: use issue severity to create status (#90) 2025-04-03 17:06:03 +02:00
98b971d7db
feat: do not retry on validation issues (#89)
This will prevent retry if response is HTTP 400 or HTTP 422.
2025-03-23 13:35:24 +01:00
56a63b276e
Code cleanup (#87)
* refactor: Replace usage of Void with Kotlins Unit

* refactor: make ConnectionCheckService a functional interface

* refactor: ignore unused exception

* refactor: use property access syntax

* refactor: use const value for login path
2025-03-23 12:09:34 +01:00
c0ea5fcd51 test: use Europe/Berlin as timezone in tests 2025-03-23 01:05:06 +01:00
d4fd54f51f Merge branch '0.9.x' 2025-03-22 23:58:47 +01:00
d49671f0d4 build: update image name 2025-03-22 23:40:13 +01:00
84868dc22c Merge branch '0.9.x' 2025-03-22 11:26:16 +01:00
4ad6c4bd0a feat: handle and save issue report for non HTTP 2xx responses 2025-03-22 11:04:32 +01:00
9bdd8ba375 chore: update Spring Boot 2025-03-22 10:20:52 +01:00
f027339425 chore: update gradle 2025-03-22 10:20:35 +01:00
3c5639708f chore: highlight selected config tab 2025-03-21 19:26:25 +01:00
639159c677 docs: add information about DNPM:DIP dev environment 2025-03-21 19:15:20 +01:00
38261d6d2c chore: update bwhc-dto-java
This enables use of WHOGrading version 2021.
2025-03-21 19:11:24 +01:00
47ebe46974 feat: add checks for DNPM:DIP backend
Since DNPM:DIP responds with HTTP 404 on API base path, the Kaplan-Meier Config
endpoint will be used to check availability of DNPM:DIP backend API.
2025-03-20 14:39:40 +01:00
f347653be8 refactor: use UriComponentsBuilder to build URL to be used
This prevents problems using trailing slash in remote API URL.
2025-03-20 14:19:25 +01:00
775a7df1ce chore: use API URL to DNPM:DIP 2025-03-20 14:13:21 +01:00
f66b737f11 docs: add example APP_REST_URI for use with dnpm:dip 2025-03-19 15:29:55 +01:00
3a19212a78 chore: update Spring Boot 2025-03-09 09:38:59 +01:00
280fbd445e chore: update Spring Boot 2025-03-09 09:24:51 +01:00
91e2cf5ef1 refactor: use different sender classes for bwHC and DIP 2025-03-08 11:42:14 +01:00
262c54f2e5 fix: use patient pseudonym value 2025-03-08 11:42:14 +01:00
Niklas Sombert
b25e580113 feat: Support POSTing data to dnpm:dip 2025-03-08 11:42:14 +01:00
ff27b7157d chore: update spring boot and dependency management plugin 2025-02-10 19:18:27 +01:00
1e652a7856 test: explicit request URI check and fix use of expect() 2025-02-09 11:19:35 +01:00
74ff9f08a4 chore: update to mockito-kotlin 5.4.0
With this change, `anyValueClass()` from mockito-kotlin
replaces own implementation.
2024-12-23 18:16:37 +01:00
23cc2f365a chore: update dependencies 2024-12-20 10:45:16 +01:00
53b4cf1a95 chore: update dependencies 2024-11-25 21:02:55 +01:00
5ce13e962b build: add description and group to task 2024-11-02 15:08:25 +01:00
3257493b6a build: update HAPI dependencies
This also overrides 'commons-io' due to CVE-2024-47554
2024-11-02 15:08:25 +01:00
2036077c06 build: do not hard code version numbers in dependencies 2024-11-02 15:08:25 +01:00
8ce3aed870 docs: modify changed urls 2024-11-01 15:38:47 +01:00
998989d319 Merge branch '0.9.x' 2024-11-01 15:32:40 +01:00
e95fa2fb12 build: change BP_OCI_SOURCE 2024-11-01 15:06:24 +01:00
1bcc8c13de build: change group name 2024-11-01 14:44:09 +01:00
2fc3299543
build: replace hard coded repo name with variable (#81) 2024-11-01 14:23:26 +01:00
5575867632
chore: update to Spring Boot 3.2.11 (#80) 2024-11-01 14:14:45 +01:00
46ba565c29
chore: update to Spring Boot 3.3.5 (#79) 2024-11-01 14:05:20 +01:00
Niklas
6cdbd35e64
feat: Allow configuring basic auth for the rest uri (#75) 2024-11-01 13:56:54 +01:00
jlidke
d258d9081b
chore: gPas health check, fetch metadata instead of send invalid gPas request (#73) 2024-11-01 13:54:40 +01:00
eb49ba611b
chore: update to Spring Boot 3.3.4 (#78) 2024-11-01 13:51:06 +01:00
efa736f232
chore: update to Spring Boot 3.3.3 (#77) 2024-11-01 13:42:20 +01:00
4a7030e85b
chore: update to Spring Boot 3.3.2 (#76) 2024-11-01 13:27:31 +01:00
464c8b8c1d refactor: use dedicated type for path param 2024-07-15 11:59:00 +02:00
3f1bb4f4e2 refactor: rename template attribute to reflect content 2024-07-15 11:51:33 +02:00
370ea87095 refactor: rename db column name to reflect content 2024-07-15 11:44:19 +02:00
c8f6e6efc8 refactor: add types for patient id and pseudonym 2024-07-15 10:31:52 +02:00
c949ec07e5 Merge branch 0.9.x 2024-07-15 08:11:11 +02:00
87658bfa58 chore: update to Spring Boot 3.2.7 2024-07-15 07:58:36 +02:00
99efd6c98a fix: downgrade echarts due to dependency issues 2024-07-15 07:43:44 +02:00
e42d11f125 chore: update webjars dependencies 2024-07-15 07:43:32 +02:00
6e0ec6b95a style: use sans-serif font everywhere 2024-06-25 16:16:13 +02:00
0ff56416dd chore: update dev-compose.yml 2024-06-25 16:13:55 +02:00
3a2f6a2bb6 chore: update oidc client local dev password 2024-06-25 16:05:41 +02:00
3eb9e68786 fix: use versions param for commons-compress 2024-06-25 08:18:02 +02:00
59403d1dba chore: update dependency version definitions
This will use httpclient5 version as defined within spring boot.
2024-06-24 10:18:18 +02:00
9f5ac664af chore: update to spring boot 3.3.1 2024-06-24 10:02:12 +02:00
5867ed9dd3
Merge pull request #67 from CCC-MF/66-update-auf-spring-boot-33x
chore: update to spring boot 3.3.0
2024-06-20 11:37:34 +02:00
4d6d1879e6 chore: update to spring boot 3.3.0
This also adds dependency to org.flywaydb:flyway-database-postgresql
2024-06-20 09:14:17 +02:00
2a34c0efc9 fix: downgrade echarts due to dependency issues 2024-06-19 14:25:58 +02:00
0ee00de5aa chore: update webjars dependencies 2024-06-19 14:16:52 +02:00
baeebdb9b8 Merge branch 0.9.x 2024-05-31 13:31:40 +02:00
8b194e7212 chore: update kotlin version 2024-05-31 12:55:32 +02:00
070100eba0 chore: update spring dependency-management plugin 2024-05-31 12:54:28 +02:00
ce1489d9a1 chore: update spring boot dependencies 2024-05-31 12:54:23 +02:00
ca1e73a0b5 chore: update bwhc-dto-java dependency 2024-05-31 12:54:18 +02:00
041bf459ef fix: add missing 'fatal' severity 2024-05-31 12:52:22 +02:00
c922e27758 fix: handle null values in MtbFile
This should not occur but if, it should not result in NPE except for

* Patient
* Consent
* Episode
2024-05-31 12:51:40 +02:00
4d5c0ce1fb chore: remove println 2024-05-31 12:51:36 +02:00
bb0bbf5a28 chore: update webjars-locator dependency 2024-05-31 12:49:51 +02:00
1b4585d601 docs: fix CVE number in dependency comment 2024-05-31 12:44:23 +02:00
dad3ea80ee chore: update integration test dependency
This mitigates CVE-204-26308 and CVE-2024-25710
2024-05-31 12:44:19 +02:00
01446bdece chore: update GitHub workflow actions 2024-05-31 12:34:57 +02:00
43660a4dcb chore: mark as snapshot version 2024-05-31 12:22:08 +02:00
5320466b6c test: exclude test and integrationTest source set
This replaces filtering for classes that contain 'Test' or 'Tests' in simple name.
2024-05-30 14:29:31 +02:00
263cb02416 test: use Kotlin KArgumentCaptor 2024-05-27 15:22:54 +02:00
0b37fd7091 chore: update kotlin version 2024-05-27 12:33:40 +02:00
bdee969409 chore: update spring dependency-management plugin 2024-05-27 12:33:15 +02:00
4c39920afd build: use compilerOptions instead of kotlinOptions
Config kotlinOptions will be deprecated in Kotlin >= 2.0
2024-05-27 12:31:05 +02:00
5e836c48b0 docs: add information about other credentials for MTBfile endpoint 2024-05-27 12:23:50 +02:00
fb5a3c062c feat: allow access to MTBFile endpoint for non-token users 2024-05-27 12:19:24 +02:00
8fc0609aa4 feat: use RequestId type 2024-05-27 11:23:20 +02:00
a846a8765a chore: update spring boot dependencies 2024-05-27 09:08:08 +02:00
8645becd82 chore: update bwhc-dto-java dependency 2024-05-16 17:59:22 +02:00
011511d5ef feat: use details as alias for message in data quality report 2024-05-16 09:08:21 +02:00
e9839c2731 fix: add missing 'fatal' severity 2024-05-15 17:27:04 +02:00
86bee9e2cf feat: show info if no requests present 2024-05-08 13:08:15 +02:00
f419acb924 test: mock UserRoleService only in nested test class 2024-05-08 10:28:11 +02:00
52171e8ebe test: add test for config SSE endpoint 2024-05-08 09:18:29 +02:00
a2124ba83d test: add test for SSE endpoint 2024-05-07 18:45:07 +02:00
a046203339 test: test statistics json response 2024-05-07 16:57:51 +02:00
b40d41ce8c test: add initial tests for StatisticsRestController 2024-05-07 15:57:35 +02:00
57de96771c test: add test for StatisticsController 2024-05-07 15:48:39 +02:00
3bc148f7ea refactor: move classes into package 'security' 2024-05-07 08:58:00 +02:00
8e6b1ec799 refactor: use whenever() instead of when in tests 2024-05-06 19:11:54 +02:00
8e5f5c73ec test: add tests for UserRoleService 2024-05-06 18:54:28 +02:00
d4f984b138 test: add tests for HomeController 2024-05-06 18:01:08 +02:00
24ebbf3b50 test: add tests to and reorganize ConfigControllerTest 2024-05-06 17:14:22 +02:00
9c6bd64a7e test: ensure correct view is rendered 2024-05-06 13:33:36 +02:00
6567aa803c test: add htmlunit based test for LoginController 2024-05-06 13:26:29 +02:00
e874350712 test: add tests for user role requests 2024-05-06 11:53:54 +02:00
94d7b4c4f0 test: add tests for token requests 2024-05-06 11:42:11 +02:00
107429fda7 fix: handle null values in MtbFile
This should not occur but if, it should not result in NPE except for

* Patient
* Consent
* Episode
2024-05-06 10:43:03 +02:00
26b2f65e67 chore: remove println 2024-05-06 10:03:36 +02:00
e863269a42 build: add jacoco code coverage 2024-05-06 09:44:19 +02:00
4ab95ef11f build: update gradle version 2024-05-03 16:44:41 +02:00
2244ef1b86 build: register task to run all tests at once 2024-05-03 16:37:16 +02:00
c3ddb387e2 test: add some basic arch unit tests 2024-05-02 12:30:12 +02:00
ae5d8341cc refactor: use RequestService in ResponseProcessor 2024-05-02 10:12:40 +02:00
40b2558943 refactor: use RequestService in controllers 2024-05-02 09:58:20 +02:00
9a6a0c6138 refactor: use Fingerprint type instead of plain String 2024-04-30 11:08:27 +02:00
5985327219 refactor: use type alias 2024-04-29 18:40:16 +02:00
06f9e8ace9 test: add initial RequestRepository test 2024-04-29 17:34:27 +02:00
365a651918 chore: update dependency to mockito-kotlin 2024-04-29 16:45:25 +02:00
5fcc24f915 refactor: add additional constructors 2024-04-29 10:11:25 +02:00
3bd7239812 chore: use java records 2024-04-26 12:35:37 +02:00
1672ad8640 chore: update webjars-locator dependency 2024-04-25 09:33:35 +02:00
710aeb1f18 docs: fix CVE number in dependency comment 2024-04-25 09:10:33 +02:00
06d11790b6 chore: update integration test dependency
This mitigates CVE-204-26308 and CVE-2024-25710
2024-04-24 12:06:08 +02:00
959f6889d4 refactor: extract custom SSL gPAS rest template creation 2024-04-24 08:56:31 +02:00
0f5a68660d refactor: move method content and call this method 2024-04-24 08:41:47 +02:00
b809a2da02 refactor: move custom rest template init to config class 2024-04-24 08:22:13 +02:00
effffcfc1a test: add tests with simulated gPAS responses 2024-04-23 15:53:22 +02:00
7b3151d227 fix: custom rest template generation after ssl context creation 2024-04-23 15:52:40 +02:00
26b415f336 docs: add deprecation warnings for config options 2024-04-23 15:02:14 +02:00
bda3c30a74 refactor: use default RestTemplate bean if no custom one required 2024-04-23 14:48:24 +02:00
8779600330 chore: change private method name to fix typo 2024-04-23 14:39:37 +02:00
159fb46009 chore: update GitHub workflow actions 2024-04-22 19:30:46 +02:00
eabbbfbb68 refactor: rename method for custom rest template configuration 2024-04-22 19:13:47 +02:00
4db38ef2f0 refactor: use more convenient way to set basic auth header 2024-04-22 19:11:19 +02:00
ed6d21e920 Merge branch 'v0.9.x' 2024-04-19 09:50:19 +02:00
550bee5ad3 chore: use snapshot version 2024-04-19 09:49:10 +02:00
8313420de5 chore: bump version for new release 2024-04-19 09:42:19 +02:00
1651f446fe chore: update spring boot and other dependencies 2024-04-19 09:41:17 +02:00
bd7dccbd87
Merge pull request #61 from CCC-MF/issue_55
Anzeige der letzten Verbindungsstatusänderung
2024-03-26 10:06:27 +01:00
8ae958b8c4 feat: show information if no output is defined 2024-03-26 09:56:31 +01:00
0f144568e3 style: change tab content background to white 2024-03-25 17:27:08 +01:00
08540e3bd7 feat: add timestamp of last connection check change 2024-03-25 17:24:33 +01:00
43af1aa103 feat: add timestamp to connection check display 2024-03-25 17:09:27 +01:00
056a087065 chore: update spring boot dependencies 2024-03-25 16:12:20 +01:00
a730ce2a53 fix: update spring security due to CVE-2024-22257 2024-03-19 16:40:32 +01:00
12eb1feea6 fix: assign new value from scope function 2024-03-12 18:29:42 +01:00
af714f7b64 fix: ignore possible null values in mtb files 2024-03-12 17:56:25 +01:00
f47b0b7de4 build: bump version 2024-03-12 13:27:45 +01:00
d8ba6b67cb docs: change description of ID anonymization 2024-03-12 13:27:29 +01:00
40b89dd4f1
Merge pull request #60 from CCC-MF/issue_44
feat: salted re-hash IDs within MTB file except patient ID
2024-03-12 13:18:32 +01:00
e3aeee61de feat: salted re-hash IDs within MTB file except patient ID 2024-03-12 13:13:31 +01:00
07e59f9b02 docs: update README.md to mention later kafka record processing 2024-03-09 11:09:44 +01:00
f751d64220
Merge pull request #59 from CCC-MF/issue_58
feat: do not use episode id in kafka record key
2024-03-09 11:00:28 +01:00
299bd56d63 feat: do not use episode id in kafka record key 2024-03-09 10:58:03 +01:00
a0c4d1863f
Merge pull request #57 from CCC-MF/issue_56
feat: use requestId from incoming Kafka Record Header
2024-03-08 15:44:39 +01:00
fc1901211d feat: use requestId from incoming Kafka Record Header 2024-03-08 15:42:04 +01:00
bed91439db docs: update etl image 2024-03-07 18:30:44 +01:00
a8e008000e
Merge pull request #54 from CCC-MF/issue_53
Anzeige gPAS Verbindungsstatus
2024-03-06 10:54:44 +01:00
a9c771aa99 test: change tests to mock output connection 2024-03-06 10:50:35 +01:00
256d9d4ff0 chore: change wording 2024-03-06 10:08:23 +01:00
41b87835ca feat: add configuration for deprecated config property 2024-03-06 10:02:31 +01:00
3654962294 feat: initial implementation of gPAS connection check 2024-03-06 10:00:17 +01:00
9382da7101 refactor: do not use singleton like rest template object 2024-03-05 17:03:10 +01:00
67ab0ef2be build: next snapshot version 2024-03-05 16:44:17 +01:00
69d796dab4
Merge pull request #52 from CCC-MF/issue_51
Darstellung und Aufteilung der Konfigurationsseite verbessern
2024-03-05 10:27:27 +01:00
4bfe7dc698 style: layout and style changes for config page 2024-03-05 10:24:25 +01:00
0aec5e4479 style: fixed first column width 2024-03-04 17:03:41 +01:00
b1a83510a6 style: fix statistics chart layout 2024-03-04 16:33:52 +01:00
6806c4fd69 build: bump version 2024-03-04 13:21:07 +01:00
b2016df852 style: fix some style issued 2024-03-04 13:17:57 +01:00
b332f3c5ff
Merge pull request #50 from CCC-MF/issue_49
Administrative Rechte für OIDC-Benutzer
2024-03-04 12:54:31 +01:00
9eb65788e1 style: change login/logout style 2024-03-04 12:50:07 +01:00
9392bcadc9 feat: add admin role assignment 2024-03-04 10:12:12 +01:00
a008641192 fix: remove maxSessionsPreventsLogin 2024-03-01 15:07:23 +01:00
5928d52237
Merge pull request #48 from CCC-MF/issue_36
Freigabe und Berechtigung für OIDC-Benutzer
2024-03-01 14:09:06 +01:00
1eb40b40c9 docs: add documentation for user roles 2024-03-01 14:02:50 +01:00
feb9f2430c feat: add config page for user role assignment 2024-03-01 13:51:06 +01:00
200c5338ea feat: add default new user role config option 2024-03-01 09:34:51 +01:00
5c15ad4518 feat: add user role database table and role-based permissions 2024-03-01 09:30:07 +01:00
0b6decf88d
Merge pull request #47 from CCC-MF/issue_43
feat: add config option to deactivate duplication check
2024-03-01 07:33:16 +01:00
cfdf41d550 feat: add config option to deactivate duplication check 2024-03-01 07:27:58 +01:00
45c65d53ce docs: add information about Kafka input topics 2024-02-29 13:55:14 +01:00
4568f491f5
Merge pull request #46 from CCC-MF/issue_42_kafka
Dateneingang über Apache-Kafka als Alternative zu HTTP-Request
2024-02-29 13:15:57 +01:00
952ad8c0cf test: add test for incoming kafka message processing 2024-02-29 12:49:06 +01:00
3e45bf8494 feat: implement KafkaInputListener 2024-02-29 09:19:32 +01:00
46ddaf10f7 Merge pull request #45 from CCC-MF/issue_34
Verwendung einer applikationsweiten Retry-Konfiguration
2024-02-29 08:57:00 +01:00
408b121f26 test: add test for max retry attempts 2024-02-29 08:47:17 +01:00
61e5273158 feat: add max-retry-attempts config option 2024-02-29 08:29:26 +01:00
50b8f7bbd4 feat: use global RetryTemplate 2024-02-29 08:26:54 +01:00
25f286f73b chore: update spring boot to version 3.2.3 2024-02-25 12:28:34 +01:00
50a6d66718 feat: new kafka config due to kafka input 2024-02-19 17:06:02 +01:00
f5c80f6d81
Merge pull request #41 from CCC-MF/issue_39
feat: add cache-control headers for static resources
2024-02-19 09:08:19 +01:00
7659939d3c
Merge pull request #40 from CCC-MF/issue_37
build: use JDK 21 and update gradle version
2024-02-19 09:02:40 +01:00
f58d4a76cf build: use JDK 21 within workflows 2024-02-19 08:59:42 +01:00
c2dd450579 feat: add cache-control headers for static resources 2024-02-19 08:54:38 +01:00
a1b62ad754 build: use JDK 21 and update gradle version 2024-02-19 08:53:21 +01:00
59d8744c84 refactor: move mtb file controller into package input 2024-02-17 14:58:24 +01:00
d2a6ec17ea docs: add ports to example docker compose file 2024-02-16 13:00:22 +01:00
550403cc9f build: bump snapshot version 2024-02-15 13:41:11 +01:00
d3a4500568
Merge pull request #35 from CCC-MF/issue_33
Deprecate usage of ...SSL_CA_LOCATION config param
2024-02-09 08:19:07 +01:00
2e4fee97a8 feat: Deprecate usage of ...SSL_CA_LOCATION config param 2024-02-09 08:15:01 +01:00
5355eee05c docs: mark gPAS ssl config param as deprecated 2024-02-08 10:05:06 +01:00
3e22000541
Merge pull request #32 from CCC-MF/issue_31
build: update used actions
2024-02-05 08:18:26 +01:00
8c319197d0 build: update used actions 2024-02-05 08:12:28 +01:00
a31d2b4bcc build: bump version 2024-02-05 07:47:17 +01:00
67d5fb4c67 docs: mention quality report page access restriction 2024-02-05 07:29:47 +01:00
329be65d1a feat: forbid access to report if not logged in 2024-02-05 07:18:31 +01:00
91fe3d1c23 docs: add example login image 2024-02-01 18:30:02 +01:00
f4b86ce2ea docs: add OIDC configuration options to README.md 2024-02-01 18:28:33 +01:00
19d0daa442 docs: move README.md to bindings folder 2024-02-01 17:00:16 +01:00
cc9811d11d docs: move README.md to bindings folder 2024-02-01 16:59:23 +01:00
8ce5b06823 fix: make security config optional for login controller 2024-02-01 16:54:41 +01:00
3cc34fb30b feat: usage of CA certificate files within image/container 2024-02-01 16:45:22 +01:00
17e04a3f89 feat: add basic support for OIDC login 2024-01-31 15:57:16 +01:00
f71a775e12 chore: update spring boot to version 3.2.2 2024-01-23 01:04:35 +01:00
45c83e943b docs: Add information about other reference IDs anonymization 2024-01-22 10:33:27 +01:00
6dcbfde62e test: add tests for TokenService 2024-01-21 14:13:09 +01:00
4cdc419478 test: add test to ensure redirect of not logged in 2024-01-20 19:35:40 +01:00
90b529adb4 refactor: move test class to related package 2024-01-20 19:16:52 +01:00
a3bc60986b test: add security related tests for MtbFileRestController 2024-01-19 14:11:03 +01:00
f5df0b5d22 test: add tests to ensure TokenService is present if required 2024-01-19 13:10:36 +01:00
972ac745e9 fix: add missing token screenshot 2024-01-18 14:44:44 +01:00
358373cf70
Merge pull request #30 from CCC-MF/issue_29
Issue #29: Unterstützung für Endpoint-Tokens
2024-01-18 14:29:52 +01:00
27a62321fa docs: add documentation about token usage 2024-01-18 14:26:09 +01:00
30cf0fd22e feat #29: add initial support for mtbfile api tokens 2024-01-18 14:13:15 +01:00
531a8589db feat: push connection available state to client 2024-01-17 14:32:42 +01:00
fa89a64ddd
Merge pull request #28 from CCC-MF/issue_24
feat #24: use htmx to refresh connection status every 20s
2024-01-17 12:35:35 +01:00
45ad5e8827 feat #24: use htmx to refresh connection status every 20s 2024-01-17 12:27:44 +01:00
c4eb4d0fe2
feat #25: add link to requests related to patient pseudonyme (#27) 2024-01-15 10:26:56 +01:00
4bc69a353c
Merge pull request #26 from CCC-MF/issue_23
feat #23: add reload button to display on new request
2024-01-15 10:15:36 +01:00
9d30f750f7 feat #23: add reload button to display on new request 2024-01-15 09:17:38 +01:00
a1a252d5a9 build: use webjars for JS dependencies for now 2024-01-15 07:18:14 +01:00
568942bfe5 fix: typo in README.md 2024-01-14 17:31:30 +01:00
15f0432553 test: ensure configured generator bean is created 2024-01-12 21:27:55 +01:00
113bf2dd2e test: add pseudonymize generator property and default to tests 2024-01-12 19:59:01 +01:00
7ac151202a refactor: Use config new pseudonym generator config param
This deprecates the old param:
* `APP_PSEUDONYMIZER`: deprecated
* `APP_PSEUDONYM_GENERATOR`: has precedence
2024-01-12 16:55:18 +01:00
5d9d47c2df fix: append css class, not css style 2024-01-12 13:49:54 +01:00
585468314c feat: add admin credentials to deploy folder 2024-01-11 16:34:03 +01:00
441bff3783 feat: use password with encoding prefix 2024-01-11 15:00:26 +01:00
21959c1698
Merge pull request #21 from CCC-MF/feat_18
feat #18: initial support for authentication
2024-01-11 13:32:37 +01:00
8a11e6e85b feat #18: initial support for authentication 2024-01-11 13:29:33 +01:00
5579ad1453 docs: update documentation 2024-01-11 12:11:38 +01:00
c2026bdd07 feat: show configured endpoints 2024-01-11 08:51:30 +01:00
de6faecb02 refactor: rename css style 2024-01-11 08:50:51 +01:00
3be8bc53ff feat: add graphic to show connection state 2024-01-10 11:16:34 +01:00
fad2f33fd6 refactor: use event listener to listen for page load event 2024-01-10 09:22:51 +01:00
d88e2973da feat: add paginator to request page 2024-01-10 09:12:02 +01:00
af767e4ea6 chore: update images 2024-01-10 07:44:10 +01:00
f98c970348 chore: layout and style changes 2024-01-09 18:09:44 +01:00
75872a149f docs: add some more information within README.doc 2024-01-05 11:53:51 +01:00
e24ba430a5 feat #20: add server forward headers config
closes #20
2024-01-05 11:43:58 +01:00
08914a6f86 docs: link simple docker-compose.yml example within README.md 2024-01-04 13:29:49 +01:00
104f50afcb docs: add docker-compose.yml example 2024-01-04 13:14:10 +01:00
0083e75940
Merge pull request #19 from CCC-MF/feat_17
feat #17: add request retry
2024-01-04 11:56:40 +01:00
c892ff2461 test #17: add tests for retry 2024-01-04 11:50:39 +01:00
4a9cffbaa5 feat #17: initial support for request retry 2024-01-04 07:33:03 +01:00
8a6f9a6e02 build: bump version 2024-01-03 13:25:21 +01:00
91f17f6af5 chore: update mockito-kotlin test dependency 2024-01-03 12:27:26 +01:00
8d4497bf2c build: update kotlin version 2024-01-03 12:25:41 +01:00
4ab20a5f16 fix: add rest uri config to integration tests 2024-01-02 07:22:17 +01:00
167587a473
Merge pull request #16 from CCC-MF/feat_15
feat #15: add connection checks to bwHC backend
2024-01-02 06:53:49 +01:00
e5d80f89b0 feat #15: add connection checks to bwHC backend 2024-01-02 06:51:01 +01:00
5d0e815037 build: bump version 2023-12-29 17:27:21 +01:00
a5a19e0cea chore: update hapi-fhir dependency to 6.10.2
This mitigates CVE-2023-6378, CVE-2023-2976 and CVE-2020-8908
2023-12-29 17:27:17 +01:00
1493a63e02 chore: remove snakeyaml dependency version override
Spring Boot 3.2.1 uses newer version 2.2, so there is no need to
override dependency version.
2023-12-29 17:27:10 +01:00
fe927e65aa chore: remove explicit kafka dependency version
Spring Boot 3.6.1 uses Kafka 3.6.1 that mitigates
CVE-2023-34453, CVE-2023-34454, CVE-2023-34455, CVE-2023-43642
and new CVE-2023-44981 from version 3.6.0
2023-12-29 17:26:51 +01:00
add09c3f9c chore: update spring boot to version 3.2.1 2023-12-29 17:06:47 +01:00
107 changed files with 9919 additions and 1380 deletions

View File

@ -1,4 +1,4 @@
name: "Run build and deploy" name: 'Run build and deploy'
on: on:
release: release:
@ -8,20 +8,20 @@ jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-java@v3 - uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2.4.2 uses: gradle/actions/setup-gradle@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -30,6 +30,6 @@ jobs:
- name: Execute image build and push - name: Execute image build and push
run: | run: |
./gradlew bootBuildImage ./gradlew bootBuildImage
docker tag ghcr.io/ccc-mf/etl-processor 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/ccc-mf/etl-processor docker push ghcr.io/${{ github.repository }}
docker push ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }} docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}

View File

@ -11,14 +11,14 @@ jobs:
tests: tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-java@v3 - uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2.4.2 uses: gradle/actions/setup-gradle@v3
- name: Execute tests - name: Execute tests
run: ./gradlew test run: ./gradlew test
@ -26,14 +26,14 @@ jobs:
integrationTests: integrationTests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-java@v3 - uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2.4.2 uses: gradle/actions/setup-gradle@v3
- name: Execute integration tests - name: Execute integration tests
run: ./gradlew integrationTest run: ./gradlew integrationTest

2
.gitignore vendored
View File

@ -5,6 +5,8 @@ build/
!**/src/main/**/build/ !**/src/main/**/build/
!**/src/test/**/build/ !**/src/test/**/build/
bindings/ca-certificates/*.pem
### STS ### ### STS ###
.apt_generated .apt_generated
.classpath .classpath

306
README.md
View File

@ -1,87 +1,221 @@
# ETL-Processor for bwHC data [![Run Tests](https://github.com/CCC-MF/etl-processor/actions/workflows/test.yml/badge.svg)](https://github.com/CCC-MF/etl-processor/actions/workflows/test.yml) # ETL-Processor for DNPM:DIP [![Run Tests](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml/badge.svg)](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml)
Diese Anwendung versendet ein bwHC-MTB-File an das bwHC-Backend und pseudonymisiert die Patienten-ID. Diese Anwendung versendet ein bwHC-MTB-File im bwHC-Datenmodell 1.0 an DNPM:DIP und pseudonymisiert die Patienten-ID.
### Einordnung innerhalb einer DNPM-ETL-Strecke ## 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 **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)**.
Der Inhalt einer Anfrage, wenn ein bwHC-MTBFile, wird pseudonymisiert und auf Duplikate geprüft. Der Inhalt einer Anfrage, wenn ein bwHC-MTBFile, wird pseudonymisiert und auf Duplikate geprüft.
Duplikate werden verworfen, Änderungen werden weitergeleitet. Duplikate werden verworfen, Änderungen werden weitergeleitet.
Löschanfragen werden immer als Löschanfrage an 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.
![Modell DNPM-ETL-Strecke](docs/etl.png) ![Modell DNPM-ETL-Strecke](docs/etl.png)
#### HTTP/REST-Konfiguration ### Duplikaterkennung
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an das bwHC-Backend gesendet. Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über den Konfigurationsparameter
`APP_DUPLICATION_DETECTION=false` deaktiviert werden.
#### Konfiguration für Apache Kafka ### 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. Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung an Apache Kafka übergeben.
Eine Antwort wird dabei ebenfalls mithilfe von Apache Kafka übermittelt und nach der Entgegennahme verarbeitet. Eine Antwort wird dabei ebenfalls mithilfe von Apache Kafka übermittelt und nach der Entgegennahme verarbeitet.
Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc
## Pseudonymisierung der Patienten-ID ## Konfiguration
### 🔥 Wichtige Änderungen in Version 0.10
Ab Version 0.10 wird [DNPM:DIP](https://github.com/dnpm-dip) unterstützt und als Standardendpunkt verwendet.
Soll noch das alte bwHC-Backend verwendet werden, so ist die Umgebungsvariable `APP_REST_IS_BWHC` auf `true` zu setzen.
### 🔥 Breaking Changes nach Version 0.10
In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfigurationsoptionen entfernt:
* `APP_KAFKA_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_TOPIC`
* `APP_KAFKA_RESPONSE_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`
Der Pfad zum Versenden von MTB-Daten ist nun offiziell `/mtb`.
In Versionen **nach Version 0.10** wird die Unterstützung des Pfads `/mtbfile` entfernt.
### Pseudonymisierung der Patienten-ID
Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet. Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet.
Ist diese nicht gesetzt. wird intern eine Anonymisierung der Patienten-ID vorgenommen. Ist diese nicht gesetzt. wird intern eine Anonymisierung der Patienten-ID vorgenommen.
* `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes 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 * `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 Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID.
entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des konfigurierten Prefixes Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den aktuellen Kontext nicht
vergleichbare IDs bereitzustellen.
#### Eingebaute Anonymisierung
Wurde keine oder die Verwendung der eingebauten Anonymisierung konfiguriert, so wird für die Patienten-ID der
entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des konfigurierten Präfixes
als Patienten-Pseudonym verwendet. als Patienten-Pseudonym verwendet.
### Pseudonymisierung mit gPAS #### Pseudonymisierung mit gPAS
Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfigurieren. Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfigurieren.
* `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz inklusive Endpoint ( * `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz inklusive Endpoint (z.B. `http://localhost:8080/ttp-fhir/fhir/gpas/$$pseudonymizeAllowCreate`)
z.B. `http://localhost:8080/ttp-fhir/fhir/gpas/$$pseudonymizeAllowCreate`)
* `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname * `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname
* `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername * `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername
* `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort * `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort
* `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`: Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss.
## Transformation von Werten ### 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`.
![Login mit OpenID Connect](docs/login.png)
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.
![Rollenverwaltung](docs/userroles.png)
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.
![Tokenverwaltung](docs/tokens.png)
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, In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht,
der vom bwHC-Backend akzeptiert wird. der von DNPM:DIP akzeptiert wird.
Diese Anwendung bietet daher die Möglichkeit, eine Transformation vorzunehmen. Hierzu muss der "Pfad" innerhalb des JSON-MTB-Files angegeben werden und 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. welcher Wert wie ersetzt werden soll.
Hier ein Beispiel für die erste (Index 0 - weitere dann mit 1,2,...) Transformationsregel: Hier ein Beispiel für die erste (Index 0 - weitere dann mit 1,2, ...) Transformationsregel:
* `APP_TRANSFORMATIONS_0_PATH`: Pfad zum Wert in der JSON-MTB-Datei. Beispiel: `diagnoses[*].icd10.version` für **alle** Diagnosen * `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_FROM`: Angabe des Werts, der ersetzt werden soll. Andere Werte bleiben dabei unverändert.
* `APP_TRANSFORMATIONS_0_TO`: Angabe des neuen Werts. * `APP_TRANSFORMATIONS_0_TO`: Angabe des neuen Werts.
## Mögliche Endpunkte ### Mögliche Endpunkte zur Datenübermittlung
Für REST-Requests als auch zur Nutzung von Kafka-Topics können Endpunkte konfiguriert werden. Für REST-Requests als auch zur Nutzung von Kafka-Topics können Endpunkte konfiguriert werden.
Es ist dabei nur die Konfiguration eines Endpunkts zulässig. Es ist dabei nur die Konfiguration eines Endpunkts zulässig.
Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpunkt verwendet. Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpunkt verwendet.
### REST #### REST
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das bwHC-Backend gesendet wird: Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an DNPM:DIP gesendet wird:
* `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api` * `APP_REST_URI`: URI der zu benutzenden API der Backend-Instanz. Zum Beispiel:
* `http://localhost:9000/bwhc/etl/api` für **bwHC Backend**
* `http://localhost:9000/api` für **dnpm:dip**
* `APP_REST_USERNAME`: Basic-Auth-Benutzername für den REST-Endpunkt
* `APP_REST_PASSWORD`: Basic-Auth-Passwort für den REST-Endpunkt
* `APP_REST_IS_BWHC`: `true` für **bwHC Backend**, weglassen oder `false` für **dnpm:dip**
### Kafka-Topics #### Kafka-Topics
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird: Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird:
* `APP_KAFKA_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen * `APP_KAFKA_OUTPUT_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_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_GROUP_ID`: Kafka GroupID des Consumers. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_group".
* `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste * `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste
@ -89,10 +223,12 @@ Wird keine Rückantwort über Apache Kafka empfangen und es gibt keine weitere M
Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden. Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden.
Lässt sich keine Verbindung zu dem 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. 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. Generell werden in Apache Kafka alle Records entsprechend der Konfiguration vorgehalten.
So wird ohne spezielle Konfiguration ein Record für 7 Tage in Apache Kafka gespeichert. So wird ohne spezielle Konfiguration ein Record für 7 Tage in Apache Kafka gespeichert.
@ -105,7 +241,7 @@ Beispiel - auszuführen innerhalb des Kafka-Containers: Löschen alter Records n
kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config retention.ms=86400000 kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config retention.ms=86400000
``` ```
#### Key based Retention ##### Key based Retention
Möchten Sie hingegen immer nur die letzte Meldung für einen Patienten und eine Erkrankung in Apache Kafka vorhalten, Möchten Sie hingegen immer nur die letzte Meldung für einen Patienten und eine Erkrankung in Apache Kafka vorhalten,
so ist die nachfolgend genannte Konfiguration der Kafka-Topics hilfreich. so ist die nachfolgend genannte Konfiguration der Kafka-Topics hilfreich.
@ -121,19 +257,42 @@ kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-co
kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config cleanup.policy=[delete,compact] kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config cleanup.policy=[delete,compact]
``` ```
Da als Key eines Records die (pseudonymisierte) Patienten-ID und die (anonymisierte) Erkrankungs-ID verwendet wird, Da als Key eines Records die (pseudonymisierte) Patienten-ID verwendet wird, stehen mit obiger Konfiguration
stehen mit obiger Konfiguration der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden Key zur Verfügung.
Key zur Verfügung.
Da der Key sowohl für die Records in Richtung bwHC-Backend für die Rückantwort identisch aufgebaut ist, lassen sich so Da der Key sowohl für die Records in Richtung DNPM:DIP, als auch für die Rückantwort identisch aufgebaut ist, lassen sich so
auch im Falle eines Consent-Widerspruchs die enthaltenen Daten als auch die Offenlegung durch Verifikationsdaten in der auch im Falle eines Consent-Widerspruchs die enthaltenen Daten als auch die Offenlegung durch Verifikationsdaten in der
Antwort effektiv verhindern, da diese nach 10 Sekunden gelöscht werden. Antwort effektiv verhindern, da diese nach 10 Sekunden gelöscht werden.
Es steht dann nur noch die jeweils letzten Information zur Verfügung, dass für einen Patienten/eine Erkrankung Es steht dann nur noch die jeweils letzten Information zur Verfügung, dass für einen Patienten/eine Erkrankung
ein Consent-Widerspruch erfolgte. ein Consent-Widerspruch erfolgte.
Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für andere Auswertungen verwenden möchten.
### Antworten und Statusauswertung
Anfragen an das bwHC-Backend aus Versionen bis 0.9.x wurden wie folgt behandelt:
| HTTP-Response | Status |
|----------------|-----------|
| `HTTP 200` | `SUCCESS` |
| `HTTP 201` | `WARNING` |
| `HTTP 400-...` | `ERROR` |
Dies konnte dazu führen, dass zwar mit einem `HTTP 201` geantwortet wurde, aber dennoch in der Issue-Liste die
Severity `error` aufgetaucht ist.
Ab Version 0.10 wird die Issue-Liste der Antwort verwendet und die darion enthaltene höchste Severity-Stufe als Ergebnis verwendet.
| Höchste Severity | Status |
|------------------|-----------|
| `info` | `SUCCESS` |
| `warning` | `WARNING` |
| `error`, `fatal` | `ERROR` |
## Docker-Images ## Docker-Images
Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/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 ### Images lokal bauen
@ -141,8 +300,37 @@ Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/CCC-MF/
./gradlew bootBuildImage ./gradlew bootBuildImage
``` ```
### Integration eines eigenen Root CA Zertifikats
Wird eine eigene Root CA verwendet, die nicht offiziell signiert ist, wird es zu Problemen beim SSL-Handshake kommen, wenn z.B. gPAS zur Generierung von Pseudonymen verwendet wird.
Hier bietet es sich an, das Root CA Zertifikat in das Image zu integrieren.
#### Integration beim Bauen des Images
Hier muss die Zeile `"BP_EMBED_CERTS" to "true"` in der Datei `build.gradle.kts` verwendet werden und darf nicht als Kommentar verwendet werden.
Die PEM-Datei mit dem/den Root CA Zertifikat(en) muss dabei im vorbereiteten Verzeichnis [`bindings/ca-certificates`](bindings/ca-certificates) enthalten sein.
#### Integration zur Laufzeit
Hier muss die Umgebungsvariable `SERVICE_BINDING_ROOT` z.B. auf den Wert `/bindings` gesetzt sein.
Zudem muss ein Verzeichnis `bindings/ca-certificates` - analog zum Verzeichnis [`bindings/ca-certificates`](bindings/ca-certificates) mit einer PEM-Datei als Docker-Volume eingebunden werden.
Beispiel für Docker-Compose:
```
...
environment:
SERVICE_BINDING_ROOT: /bindings
...
volumes:
- "/path/to/bindings/ca-certificates/:/bindings/ca-certificates/:ro"
...
```
## Deployment ## Deployment
*Ausführen als Docker Conatiner:* *Ausführen als Docker Container:*
```bash ```bash
cd ./deploy cd ./deploy
@ -154,6 +342,50 @@ Wenn gewünscht, Änderungen in der `.env` vornehmen.
docker compose up -d docker compose up -d
``` ```
### Einfaches Beispiel für ein eigenes Docker-Compose-File
Die Datei [`docs/docker-compose.yml`](docs/docker-compose.yml) zeigt eine einfache Konfiguration für REST-Requests basierend
auf Docker-Compose mit der gestartet werden kann.
### Betrieb hinter einem Reverse-Proxy
Die Anwendung verarbeitet `X-Forwarded`-HTTP-Header und kann daher auch hinter einem Reverse-Proxy betrieben werden.
Dabei werden, je nachdem welche Header durch den Reverse-Proxy gesendet werden auch Protokoll, Host oder auch Path-Präfix
automatisch erkannt und verwendet werden. Dadurch ist z.B. eine abweichende Angabe des Pfads problemlos möglich.
#### Beispiel *Traefik* (mit Docker-Labels):
Das folgende Beispiel zeigt die Konfiguration in einer Docker-Compose-Datei mit Service-Labels.
```
...
deploy:
labels:
- "traefik.http.routers.etl.rule=PathPrefix(`/etl-processor`)"
- "traefik.http.routers.etl.middlewares=etl-path-strip"
- "traefik.http.middlewares.etl-path-strip.stripprefix.prefixes=/etl-processor"
...
```
#### Beispiel *nginx*
Das folgende Beispiel zeigt die Konfiguration einer _location_ in einer nginx-Konfigurationsdatei.
```
...
location /etl-processor {
set $upstream http://<beispiel:8080>/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass $upstream;
}
...
```
## Entwicklungssetup ## Entwicklungssetup
Zum Starten einer lokalen Entwicklungs- und Testumgebung kann die beiliegende Datei `dev-compose.yml` verwendet werden. Zum Starten einer lokalen Entwicklungs- und Testumgebung kann die beiliegende Datei `dev-compose.yml` verwendet werden.
@ -173,3 +405,5 @@ Die Datei `application-dev.yml` enthält hierzu die Konfiguration für das Profi
Beim Ausführen der Integrationstests wird eine Testdatenbank in einem Docker-Container gestartet. Beim Ausführen der Integrationstests wird eine Testdatenbank in einem Docker-Container gestartet.
Siehe hier auch die Klasse `AbstractTestcontainerTest` unter `src/integrationTest`. Siehe hier auch die Klasse `AbstractTestcontainerTest` unter `src/integrationTest`.
Ein einfaches Entwickler-Setup inklusive DNPM:DIP ist mit Hilfe von https://github.com/pcvolkmer/dnpmdip-devenv realisierbar.

5
bindings/README.md Normal file
View 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.

View File

@ -0,0 +1 @@
ca-certificates

View File

@ -1,31 +1,36 @@
import org.gradle.api.tasks.testing.logging.TestLogEvent import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
plugins { plugins {
war war
id("org.springframework.boot") version "3.2.0" id("org.springframework.boot") version "3.4.4"
id("io.spring.dependency-management") version "1.1.4" id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version "1.9.21" kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.21" kotlin("plugin.spring") version "1.9.25"
jacoco
} }
group = "de.ukw.ccc" group = "dev.dnpm"
version = "0.3.0" version = "0.11.0-SNAPSHOT"
var versions = mapOf( var versions = mapOf(
"bwhc-dto-java" to "0.2.0", "bwhc-dto-java" to "0.4.0",
"hapi-fhir" to "6.6.2", "mtb-dto" to "0.1.0-SNAPSHOT",
"httpclient5" to "5.2.1", "hapi-fhir" to "7.6.0",
"mockito-kotlin" to "5.1.0" "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"
) )
// Override Apache Kafka to be used
// Fixes: CVE-2023-34455, CVE-2023-34454, CVE-2023-34453 and CVE-2023-43642
extra["kafka.version"] = "3.6.0"
java { java {
sourceCompatibility = JavaVersion.VERSION_17 toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
} }
sourceSets { sourceSets {
@ -44,9 +49,18 @@ configurations {
compileOnly { compileOnly {
extendsFrom(configurations.annotationProcessor.get()) extendsFrom(configurations.annotationProcessor.get())
} }
all {
resolutionStrategy {
cacheChangingModulesFor(5, "minutes")
}
}
} }
repositories { repositories {
maven {
url = uri("https://git.dnpm.dev/api/packages/public-snapshots/maven")
}
maven { maven {
url = uri("https://git.dnpm.dev/api/packages/public/maven") url = uri("https://git.dnpm.dev/api/packages/public/maven")
} }
@ -58,35 +72,51 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.kafka:spring-kafka") implementation("org.springframework.kafka:spring-kafka")
// fix CVE-2022-1471 implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.yaml:snakeyaml:2.1")
implementation("org.flywaydb:flyway-mysql") implementation("org.flywaydb:flyway-mysql")
implementation("commons-codec:commons-codec") implementation("commons-codec:commons-codec")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("de.ukw.ccc:bwhc-dto-java:${versions["bwhc-dto-java"]}") implementation("de.ukw.ccc:bwhc-dto-java:${versions["bwhc-dto-java"]}")
implementation("dev.pcvolkmer.mv64e:mtb-dto:${versions["mtb-dto"]}") { isChanging = true }
implementation("ca.uhn.hapi.fhir:hapi-fhir-base:${versions["hapi-fhir"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-base:${versions["hapi-fhir"]}")
implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}")
implementation("org.apache.httpcomponents.client5:httpclient5:${versions["httpclient5"]}") implementation("org.apache.httpcomponents.client5:httpclient5")
implementation("com.jayway.jsonpath:json-path") implementation("com.jayway.jsonpath:json-path")
implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}")
implementation("org.webjars.npm:echarts:${versions["echarts"]}")
implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client") runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
runtimeOnly("org.postgresql:postgresql") runtimeOnly("org.postgresql:postgresql")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
developmentOnly("org.springframework.boot:spring-boot-docker-compose") developmentOnly("org.springframework.boot:spring-boot-docker-compose")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat") providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("io.projectreactor:reactor-test") testImplementation("io.projectreactor:reactor-test")
testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}") testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}")
integrationTestImplementation("org.testcontainers:junit-jupiter") integrationTestImplementation("org.testcontainers:junit-jupiter")
integrationTestImplementation("org.testcontainers:postgresql") integrationTestImplementation("org.testcontainers:postgresql")
integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}")
integrationTestImplementation("org.htmlunit:htmlunit")
integrationTestImplementation("org.springframework:spring-webflux")
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
kotlinOptions { compilerOptions {
freeCompilerArgs += "-Xjsr305=strict" freeCompilerArgs.add("-Xjsr305=strict")
jvmTarget = "17" jvmTarget.set(JvmTarget.JVM_21)
} }
} }
@ -97,8 +127,9 @@ tasks.withType<Test> {
} }
} }
task<Test>("integrationTest") { tasks.register<Test>("integrationTest") {
description = "Runs integration tests" description = "Runs integration tests"
group = "verification"
testClassesDirs = sourceSets["integrationTest"].output.classesDirs testClassesDirs = sourceSets["integrationTest"].output.classesDirs
classpath = sourceSets["integrationTest"].runtimeClasspath classpath = sourceSets["integrationTest"].runtimeClasspath
@ -106,11 +137,34 @@ task<Test>("integrationTest") {
shouldRunAfter("test") shouldRunAfter("test")
} }
tasks.register("allTests") {
description = "Run all tests"
group = JavaBasePlugin.VERIFICATION_GROUP
dependsOn(tasks.withType<Test>())
}
tasks.jacocoTestReport {
dependsOn("allTests")
executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec"))
reports {
xml.required = true
}
}
tasks.named<BootBuildImage>("bootBuildImage") { tasks.named<BootBuildImage>("bootBuildImage") {
imageName.set("ghcr.io/ccc-mf/etl-processor") imageName.set("ghcr.io/pcvolkmer/etl-processor")
// Binding for CA Certs
bindings.set(listOf(
"$rootDir/bindings/ca-certificates/:/platform/bindings/ca-certificates"
))
environment.set(environment.get() + mapOf( environment.set(environment.get() + mapOf(
"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/etl-processor",
"BP_OCI_LICENSES" to "AGPLv3", "BP_OCI_LICENSES" to "AGPLv3",
"BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files" "BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files"
)) ))

View File

@ -18,6 +18,11 @@ services:
APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID} APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID}
APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC} APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC}
APP_REST_URI: ${DNPM_BWHC_REST_URI} APP_REST_URI: ${DNPM_BWHC_REST_URI}
APP_REST_USERNAME: ${DNPM_BWHC_REST_USERNAME}
APP_REST_PASSWORD: ${DNPM_BWHC_REST_PASSWORD}
APP_REST_IS_BWHC: ${DNPM_BWHC_REST_IS_BWHC}
APP_SECURITY_ADMIN_USER: ${DNPM_ADMIN_USER}
APP_SECURITY_ADMIN_PASSWORD: ${DNPM_ADMIN_PASSWORD}
SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL} SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL}
SPRING_DATASOURCE_PASSWORD: ${DNPM_MARIADB_USER_PW} SPRING_DATASOURCE_PASSWORD: ${DNPM_MARIADB_USER_PW}
SPRING_DATASOURCE_USERNAME: ${DNPM_MARIADB_DB} SPRING_DATASOURCE_USERNAME: ${DNPM_MARIADB_DB}

View File

@ -2,6 +2,10 @@
DNPM_MONITORING_HTTP_PORT=8088 DNPM_MONITORING_HTTP_PORT=8088
DNPM_LOG_LEVEL=INFO DNPM_LOG_LEVEL=INFO
# ADMIN USER CREDENTIALS
DNPM_ADMIN_USER=admin
DNPM_ADMIN_PASSWORD=
# GPAS or BUILDIN # GPAS or BUILDIN
DNPM_PSEUDONYMIZE_GENERATOR=BUILDIN DNPM_PSEUDONYMIZE_GENERATOR=BUILDIN
DNPM_APP_PSEUDONYMIZE_PREFIX=ANONYM DNPM_APP_PSEUDONYMIZE_PREFIX=ANONYM
@ -24,6 +28,9 @@ DNPM_DATASOURCE_URL=jdbc:mariadb://dnpm-monitor-db:3306/$DNPM_MARIADB_DB
## TARGET SYSTEMS CONFIG ## TARGET SYSTEMS CONFIG
# in case of direct access to bwhc enter endpoint url here # in case of direct access to bwhc enter endpoint url here
DNPM_BWHC_REST_URI= DNPM_BWHC_REST_URI=
DNPM_BWHC_REST_USERNAME=
DNPM_BWHC_REST_PASSWORD=
DNPM_BWHC_REST_IS_BWHC=false
# produce mtb files to this topic - values 'false' disabling kafka processing # produce mtb files to this topic - values 'false' disabling kafka processing
DNPM_KAFKA_TOPIC=false DNPM_KAFKA_TOPIC=false

View File

@ -17,8 +17,9 @@ services:
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
## Use AKHQ as Kafka web frontend
akhq: akhq:
image: tchiotludo/akhq:0.21.0 image: tchiotludo/akhq:0.25.0
environment: environment:
AKHQ_CONFIGURATION: | AKHQ_CONFIGURATION: |
akhq: akhq:
@ -32,6 +33,8 @@ services:
ports: ports:
- "8084:8080" - "8084:8080"
## For use with MariaDB
mariadb: mariadb:
image: mariadb:10 image: mariadb:10
ports: ports:
@ -42,6 +45,7 @@ services:
MARIADB_PASSWORD: dev MARIADB_PASSWORD: dev
MARIADB_ROOT_PASSWORD: dev MARIADB_ROOT_PASSWORD: dev
## For use with Postgres
# postgres: # postgres:
# image: postgres:alpine # image: postgres:alpine
# ports: # ports:

28
docs/docker-compose.yml Normal file
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 115 KiB

BIN
docs/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

BIN
docs/tokens.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
docs/userroles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000 networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

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

View File

@ -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)
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -20,9 +20,15 @@
package dev.dnpm.etl.processor.config package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.input.KafkaInputListener
import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.output.KafkaMtbFileSender import dev.dnpm.etl.processor.output.KafkaMtbFileSender
import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.output.RestMtbFileSender
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.security.TokenRepository
import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.services.RequestProcessor
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -30,14 +36,30 @@ import org.junit.jupiter.api.assertThrows
import org.springframework.beans.factory.NoSuchBeanDefinitionException import org.springframework.beans.factory.NoSuchBeanDefinitionException
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.retry.support.RetryTemplate
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.bean.override.mockito.MockitoBean
@SpringBootTest @SpringBootTest
@ContextConfiguration(classes = [AppConfiguration::class, KafkaAutoConfiguration::class, AppKafkaConfiguration::class, AppRestConfiguration::class]) @ContextConfiguration(
@MockBean(ObjectMapper::class) classes = [
AppConfiguration::class,
AppSecurityConfiguration::class,
KafkaAutoConfiguration::class,
AppKafkaConfiguration::class,
AppRestConfiguration::class
]
)
@MockitoBean(types = [ObjectMapper::class])
@TestPropertySource(
properties = [
"app.pseudonymize.generator=BUILDIN",
]
)
class AppConfigurationTest { class AppConfigurationTest {
@Nested @Nested
@ -60,12 +82,12 @@ class AppConfigurationTest {
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.kafka.servers=localhost:9092", "app.kafka.servers=localhost:9092",
"app.kafka.topic=test", "app.kafka.output-topic=test",
"app.kafka.response-topic=test-response", "app.kafka.output-response-topic=test-response",
"app.kafka.group-id=test" "app.kafka.group-id=test"
] ]
) )
@MockBean(RequestRepository::class) @MockitoBean(types = [RequestRepository::class])
inner class AppConfigurationKafkaTest(private val context: ApplicationContext) { inner class AppConfigurationKafkaTest(private val context: ApplicationContext) {
@Test @Test
@ -81,8 +103,8 @@ class AppConfigurationTest {
properties = [ properties = [
"app.rest.uri=http://localhost:9000", "app.rest.uri=http://localhost:9000",
"app.kafka.servers=localhost:9092", "app.kafka.servers=localhost:9092",
"app.kafka.topic=test", "app.kafka.output-topic=test",
"app.kafka.response-topic=test-response", "app.kafka.output-response-topic=test-response",
"app.kafka.group-id=test" "app.kafka.group-id=test"
] ]
) )
@ -96,6 +118,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 @Nested
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
@ -116,4 +176,104 @@ 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()
}
}
}
}
} }

View 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
}

View File

@ -0,0 +1,225 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.anyValueClass
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import dev.dnpm.etl.processor.security.TokenRepository
import dev.dnpm.etl.processor.security.UserRoleRepository
import dev.dnpm.etl.processor.services.RequestProcessor
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.MediaType
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.post
@WebMvcTest(controllers = [MtbFileRestController::class])
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
@ContextConfiguration(
classes = [
MtbFileRestController::class,
AppSecurityConfiguration::class
]
)
@MockitoBean(types = [TokenRepository::class, RequestProcessor::class])
@TestPropertySource(
properties = [
"app.pseudonymize.generator=BUILDIN",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret",
"app.security.enable-tokens=true"
]
)
class MtbFileRestControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var requestProcessor: RequestProcessor
@BeforeEach
fun setup(
@Autowired mockMvc: MockMvc,
@Autowired requestProcessor: RequestProcessor
) {
this.mockMvc = mockMvc
this.requestProcessor = requestProcessor
}
@Test
fun testShouldGrantPermissionToSendMtbFile() {
mockMvc.post("/mtbfile") {
with(user("onkostarserver").roles("MTBFILE"))
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isAccepted() }
}
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
}
@Test
fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
mockMvc.post("/mtbfile") {
with(user("onkostarserver").roles("ADMIN"))
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isAccepted() }
}
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
}
@Test
fun testShouldDenyPermissionToSendMtbFile() {
mockMvc.post("/mtbfile") {
with(anonymous())
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isUnauthorized() }
}
verify(requestProcessor, never()).processMtbFile(any<MtbFile>())
}
@Test
fun testShouldDenyPermissionToSendMtbFileForUser() {
mockMvc.post("/mtbfile") {
with(user("fakeuser").roles("USER"))
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isForbidden() }
}
verify(requestProcessor, never()).processMtbFile(any<MtbFile>())
}
@Test
fun testShouldGrantPermissionToDeletePatientData() {
mockMvc.delete("/mtbfile/12345678") {
with(user("onkostarserver").roles("MTBFILE"))
}.andExpect {
status { isAccepted() }
}
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
@Test
fun testShouldDenyPermissionToDeletePatientData() {
mockMvc.delete("/mtbfile/12345678") {
with(anonymous())
}.andExpect {
status { isUnauthorized() }
}
verify(requestProcessor, never()).processDeletion(anyValueClass())
}
@Nested
@MockitoBean(types = [UserRoleRepository::class, ClientRegistrationRepository::class])
@TestPropertySource(
properties = [
"app.pseudonymize.generator=BUILDIN",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret",
"app.security.enable-tokens=true",
"app.security.enable-oidc=true"
]
)
inner class WithOidcEnabled {
@Test
fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
mockMvc.post("/mtbfile") {
with(user("onkostarserver").roles("ADMIN"))
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isAccepted() }
}
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
}
@Test
fun testShouldGrantPermissionToSendMtbFileToUser() {
mockMvc.post("/mtbfile") {
with(user("onkostarserver").roles("USER"))
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isAccepted() }
}
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
}
}
companion object {
val mtbFile: MtbFile = MtbFile.builder()
.withPatient(
Patient.builder()
.withId("PID")
.withBirthDate("2000-08-08")
.withGender(Patient.Gender.MALE)
.build()
)
.withConsent(
Consent.builder()
.withId("1")
.withStatus(Consent.Status.ACTIVE)
.withPatient("PID")
.build()
)
.withEpisode(
Episode.builder()
.withId("1")
.withPatient("PID")
.withPeriod(PeriodStart("2023-08-08"))
.build()
)
.build()
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,135 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.pseudonym
import dev.dnpm.etl.processor.config.GPasConfigProperties
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
import org.springframework.test.web.client.response.MockRestResponseCreators.withException
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestTemplate
import java.io.IOException
class GpasPseudonymGeneratorTest {
private lateinit var mockRestServiceServer: MockRestServiceServer
private lateinit var generator: GpasPseudonymGenerator
private lateinit var restTemplate: RestTemplate
@BeforeEach
fun setup() {
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
val gPasConfigProperties = GPasConfigProperties(
"https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate",
"test",
null,
null
)
this.restTemplate = RestTemplate()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.generator = GpasPseudonymGenerator(gPasConfigProperties, retryTemplate, restTemplate)
}
@Test
fun shouldReturnExpectedPseudonym() {
this.mockRestServiceServer.expect {
method(HttpMethod.POST)
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
}.andRespond {
withStatus(HttpStatus.OK).body(getDummyResponseBody("1234", "test", "test1234ABCDEF567890"))
.createResponse(it)
}
assertThat(this.generator.generate("ID1234")).isEqualTo("test1234ABCDEF567890")
}
@Test
fun shouldThrowExceptionIfGpasNotAvailable() {
this.mockRestServiceServer.expect {
method(HttpMethod.POST)
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
}.andRespond {
withException(IOException("Simulated IO error")).createResponse(it)
}
assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
}
@Test
fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() {
this.mockRestServiceServer.expect {
method(HttpMethod.POST)
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
}.andRespond {
withStatus(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
.createResponse(it)
}
assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
}
companion object {
fun getDummyResponseBody(original: String, target: String, pseudonym: String) = """{
"resourceType": "Parameters",
"parameter": [
{
"name": "pseudonym",
"part": [
{
"name": "original",
"valueIdentifier": {
"system": "https://ths-greifswald.de/gpas",
"value": "$original"
}
},
{
"name": "target",
"valueIdentifier": {
"system": "https://ths-greifswald.de/gpas",
"value": "$target"
}
},
{
"name": "pseudonym",
"valueIdentifier": {
"system": "https://ths-greifswald.de/gpas",
"value": "$pseudonym"
}
}
]
}
]
}""".trimIndent()
}
}

View File

@ -19,7 +19,7 @@
package dev.dnpm.etl.processor.services 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.Request
import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
@ -31,18 +31,24 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.junit.jupiter.Testcontainers
import java.time.Instant import java.time.Instant
import java.util.*
@Testcontainers @Testcontainers
@ExtendWith(SpringExtension::class) @ExtendWith(SpringExtension::class)
@SpringBootTest @SpringBootTest
@Transactional @Transactional
@MockBean(MtbFileSender::class) @MockitoBean(types = [MtbFileSender::class])
@TestPropertySource(
properties = [
"app.pseudonymize.generator=buildin",
"app.rest.uri=http://example.com"
]
)
class RequestServiceIntegrationTest : AbstractTestcontainerTest() { class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
private lateinit var requestRepository: RequestRepository private lateinit var requestRepository: RequestRepository
@ -59,7 +65,7 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
@Test @Test
fun shouldResultInEmptyRequestList() { fun shouldResultInEmptyRequestList() {
val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901") val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
assertThat(actual).isEmpty() assertThat(actual).isEmpty()
} }
@ -69,33 +75,33 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
this.requestRepository.saveAll( this.requestRepository.saveAll(
listOf( listOf(
Request( Request(
uuid = UUID.randomUUID().toString(), randomRequestId(),
patientId = "TEST_12345678901", PatientPseudonym("TEST_12345678901"),
pid = "P1", PatientId("P1"),
fingerprint = "0123456789abcdef1", Fingerprint("0123456789abcdef1"),
type = RequestType.MTB_FILE, RequestType.MTB_FILE,
status = RequestStatus.SUCCESS, RequestStatus.SUCCESS,
processedAt = Instant.parse("2023-07-07T02:00:00Z") Instant.parse("2023-07-07T02:00:00Z")
), ),
// Should be ignored - wrong patient ID --> // Should be ignored - wrong patient ID -->
Request( Request(
uuid = UUID.randomUUID().toString(), randomRequestId(),
patientId = "TEST_12345678902", PatientPseudonym("TEST_12345678902"),
pid = "P2", PatientId("P2"),
fingerprint = "0123456789abcdef2", Fingerprint("0123456789abcdef2"),
type = RequestType.MTB_FILE, RequestType.MTB_FILE,
status = RequestStatus.WARNING, RequestStatus.WARNING,
processedAt = Instant.parse("2023-08-08T00:00:00Z") Instant.parse("2023-08-08T00:00:00Z")
), ),
// <-- // <--
Request( Request(
uuid = UUID.randomUUID().toString(), randomRequestId(),
patientId = "TEST_12345678901", PatientPseudonym("TEST_12345678901"),
pid = "P2", PatientId("P2"),
fingerprint = "0123456789abcdee1", Fingerprint("0123456789abcdee1"),
type = RequestType.DELETE, RequestType.DELETE,
status = RequestStatus.SUCCESS, RequestStatus.SUCCESS,
processedAt = Instant.parse("2023-08-08T02:00:00Z") Instant.parse("2023-08-08T02:00:00Z")
) )
) )
) )
@ -105,18 +111,18 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
fun shouldResultInSortedRequestList() { fun shouldResultInSortedRequestList() {
setupTestData() setupTestData()
val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901") val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
assertThat(actual).hasSize(2) assertThat(actual).hasSize(2)
assertThat(actual[0].fingerprint).isEqualTo("0123456789abcdee1") assertThat(actual[0].fingerprint).isEqualTo(Fingerprint("0123456789abcdee1"))
assertThat(actual[1].fingerprint).isEqualTo("0123456789abcdef1") assertThat(actual[1].fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
} }
@Test @Test
fun shouldReturnDeleteRequestAsLastRequest() { fun shouldReturnDeleteRequestAsLastRequest() {
setupTestData() setupTestData()
val actual = requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901") val actual = requestService.isLastRequestWithKnownStatusDeletion(TEST_PATIENT_PSEUDONYM)
assertThat(actual).isTrue() assertThat(actual).isTrue()
} }
@ -125,10 +131,14 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
fun shouldReturnLastMtbFileRequest() { fun shouldReturnLastMtbFileRequest() {
setupTestData() setupTestData()
val actual = requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901") val actual = requestService.lastMtbFileRequestForPatientPseudonym(TEST_PATIENT_PSEUDONYM)
assertThat(actual).isNotNull assertThat(actual).isNotNull
assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef1") assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
}
companion object {
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("TEST_12345678901")
} }
} }

View File

@ -0,0 +1,369 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.config.AppConfiguration
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.security.Role
import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.security.UserRoleService
import dev.dnpm.etl.processor.services.RequestProcessor
import dev.dnpm.etl.processor.services.TransformationService
import org.assertj.core.api.Assertions.assertThat
import org.htmlunit.WebClient
import org.htmlunit.html.HtmlPage
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers.anyString
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.MediaType.TEXT_EVENT_STREAM
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.servlet.*
import org.springframework.test.web.servlet.client.MockMvcWebTestClient
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
import org.springframework.web.context.WebApplicationContext
import reactor.core.publisher.Sinks
import reactor.test.StepVerifier
import java.time.Instant
abstract class MockSink : Sinks.Many<Boolean>
@WebMvcTest(controllers = [ConfigController::class])
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
@ContextConfiguration(
classes = [
ConfigController::class,
AppConfiguration::class,
AppSecurityConfiguration::class
]
)
@TestPropertySource(
properties = [
"app.pseudonymize.generator=BUILDIN"
]
)
@MockitoBean(name = "configsUpdateProducer", types = [MockSink::class])
@MockitoBean(
types = [
Generator::class,
MtbFileSender::class,
RequestProcessor::class,
TransformationService::class,
GPasConnectionCheckService::class,
RestConnectionCheckService::class
]
)
class ConfigControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var webClient: WebClient
private lateinit var requestProcessor: RequestProcessor
private lateinit var connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
@BeforeEach
fun setup(
@Autowired mockMvc: MockMvc,
@Autowired requestProcessor: RequestProcessor,
@Autowired connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) {
this.mockMvc = mockMvc
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
this.requestProcessor = requestProcessor
this.connectionCheckUpdateProducer = connectionCheckUpdateProducer
webClient.options.isThrowExceptionOnScriptError = false
}
@Test
fun testShouldRequestConfigPageIfLoggedIn() {
mockMvc.get("/configs") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
}.andExpect {
status { isOk() }
view { name("configs") }
}
}
@Test
fun testShouldRedirectToLoginPageIfNotLoggedIn() {
mockMvc.get("/configs") {
with(anonymous())
accept(MediaType.TEXT_HTML)
}.andExpect {
status { isFound() }
header {
stringValues(HttpHeaders.LOCATION, "http://localhost/login")
}
}
}
@Nested
@TestPropertySource(
properties = [
"app.security.enable-tokens=true",
"app.security.admin-user=admin"
]
)
@MockitoBean(
types = [
TokenService::class
]
)
inner class WithTokensEnabled {
private lateinit var tokenService: TokenService
@BeforeEach
fun setup(
@Autowired tokenService: TokenService
) {
webClient.options.isThrowExceptionOnScriptError = false
this.tokenService = tokenService
}
@Test
fun testShouldSaveNewToken() {
mockMvc.post("/configs/tokens") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
contentType = MediaType.APPLICATION_FORM_URLENCODED
content = "name=Testtoken"
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/tokens") }
}
val captor = argumentCaptor<String>()
verify(tokenService, times(1)).addToken(captor.capture())
assertThat(captor.firstValue).isEqualTo("Testtoken")
}
@Test
fun testShouldNotSaveTokenWithExstingName() {
whenever(tokenService.addToken(anyString())).thenReturn(Result.failure(RuntimeException("Testfailure")))
mockMvc.post("/configs/tokens") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
contentType = MediaType.APPLICATION_FORM_URLENCODED
content = "name=Testtoken"
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/tokens") }
}
val captor = argumentCaptor<String>()
verify(tokenService, times(1)).addToken(captor.capture())
assertThat(captor.firstValue).isEqualTo("Testtoken")
}
@Test
fun testShouldDeleteToken() {
mockMvc.delete("/configs/tokens/42") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/tokens") }
}
val captor = argumentCaptor<Long>()
verify(tokenService, times(1)).deleteToken(captor.capture())
assertThat(captor.firstValue).isEqualTo(42)
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldRenderConfigPageWithTokens() {
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
assertThat(
page.getElementById("tokens")
).isNotNull
}
}
@Nested
@TestPropertySource(
properties = [
"app.security.enable-tokens=false"
]
)
inner class WithTokensDisabled {
@BeforeEach
fun setup() {
webClient.options.isThrowExceptionOnScriptError = false
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldRenderConfigPageWithoutTokens() {
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
assertThat(
page.getElementById("tokens")
).isNull()
}
}
@Nested
@TestPropertySource(
properties = [
"app.security.enable-tokens=false",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret"
]
)
@MockitoBean(
types = [
UserRoleService::class
]
)
inner class WithUserRolesEnabled {
private lateinit var userRoleService: UserRoleService
@BeforeEach
fun setup(
@Autowired userRoleService: UserRoleService
) {
webClient.options.isThrowExceptionOnScriptError = false
this.userRoleService = userRoleService
}
@Test
fun testShouldDeleteUserRole() {
mockMvc.delete("/configs/userroles/42") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/userroles") }
}
val captor = argumentCaptor<Long>()
verify(userRoleService, times(1)).deleteUserRole(captor.capture())
assertThat(captor.firstValue).isEqualTo(42)
}
@Test
fun testShouldUpdateUserRole() {
mockMvc.put("/configs/userroles/42") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
contentType = MediaType.APPLICATION_FORM_URLENCODED
content = "role=ADMIN"
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/userroles") }
}
val idCaptor = argumentCaptor<Long>()
val roleCaptor = argumentCaptor<Role>()
verify(userRoleService, times(1)).updateUserRole(idCaptor.capture(), roleCaptor.capture())
assertThat(idCaptor.firstValue).isEqualTo(42)
assertThat(roleCaptor.firstValue).isEqualTo(Role.ADMIN)
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldRenderConfigPageWithUserRoles() {
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
assertThat(
page.getElementById("userroles")
).isNotNull
}
}
@Nested
inner class WithUserRolesDisabled {
@BeforeEach
fun setup() {
webClient.options.isThrowExceptionOnScriptError = false
}
@Test
fun testShouldRenderConfigPageWithoutUserRoles() {
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
assertThat(
page.getElementById("userroles")
).isNull()
}
}
@Nested
inner class SseTest {
private lateinit var webClient: WebTestClient
@BeforeEach
fun setup(
applicationContext: WebApplicationContext,
) {
this.webClient = MockMvcWebTestClient
.bindToApplicationContext(applicationContext).build()
}
@Test
fun testShouldRequestSSE() {
val expectedEvent = ConnectionCheckResult.GPasConnectionCheckResult(true, Instant.now(), Instant.now())
connectionCheckUpdateProducer.tryEmitNext(expectedEvent)
connectionCheckUpdateProducer.emitComplete { _, _ -> true }
val result = webClient.get().uri("http://localhost/configs/events").accept(TEXT_EVENT_STREAM).exchange()
.expectStatus().isOk()
.expectHeader().contentType(TEXT_EVENT_STREAM)
.returnResult(ConnectionCheckResult.GPasConnectionCheckResult::class.java)
StepVerifier.create(result.responseBody)
.expectNext(expectedEvent)
.expectComplete()
.verify()
}
}
}

View File

@ -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)
}
}
}

View File

@ -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")
}
}

View File

@ -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") }
}
}
}

View File

@ -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()
}
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -22,52 +22,16 @@ package dev.dnpm.etl.processor.pseudonym;
import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.parser.IParser;
import dev.dnpm.etl.processor.config.GPasConfigProperties; import dev.dnpm.etl.processor.config.GPasConfigProperties;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.ConnectException;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.HashMap;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager;
import org.apache.hc.client5.http.socket.ConnectionSocketFactory;
import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
import org.apache.hc.core5.http.config.Registry;
import org.apache.hc.core5.http.config.RegistryBuilder;
import org.hl7.fhir.r4.model.Identifier; import org.hl7.fhir.r4.model.Identifier;
import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
import org.hl7.fhir.r4.model.StringType; import org.hl7.fhir.r4.model.StringType;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity; import org.springframework.http.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;
import org.springframework.retry.RetryPolicy;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate; import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
public class GpasPseudonymGenerator implements Generator { public class GpasPseudonymGenerator implements Generator {
@ -76,29 +40,18 @@ public class GpasPseudonymGenerator implements Generator {
private final String gPasUrl; private final String gPasUrl;
private final String psnTargetDomain; private final String psnTargetDomain;
private final HttpHeaders httpHeader; private final HttpHeaders httpHeader;
private final RetryTemplate retryTemplate = defaultTemplate(); private final RetryTemplate retryTemplate;
private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class); private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class);
private SSLContext customSslContext; private final RestTemplate restTemplate;
private RestTemplate restTemplate;
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg) {
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, RestTemplate restTemplate) {
this.retryTemplate = retryTemplate;
this.restTemplate = restTemplate;
this.gPasUrl = gpasCfg.getUri(); this.gPasUrl = gpasCfg.getUri();
this.psnTargetDomain = gpasCfg.getTarget(); this.psnTargetDomain = gpasCfg.getTarget();
httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword()); httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword());
try {
if (StringUtils.isNotBlank(gpasCfg.getSslCaLocation())) {
customSslContext = getSslContext(gpasCfg.getSslCaLocation());
log.debug(String.format("%s has been initialized with SSL certificate %s",
this.getClass().getName(), gpasCfg.getSslCaLocation()));
}
} catch (IOException | KeyManagementException | KeyStoreException | CertificateException |
NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
log.debug(String.format("%s has been initialized", this.getClass().getName())); log.debug(String.format("%s has been initialized", this.getClass().getName()));
} }
@ -108,7 +61,7 @@ public class GpasPseudonymGenerator implements Generator {
var gPasRequestBody = getGpasRequestBody(id); var gPasRequestBody = getGpasRequestBody(id);
var responseEntity = getGpasPseudonym(gPasRequestBody); var responseEntity = getGpasPseudonym(gPasRequestBody);
var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() var gPasPseudonymResult = (Parameters) r4Context.newJsonParser()
.parseResource(responseEntity.getBody()); .parseResource(responseEntity.getBody());
return unwrapPseudonym(gPasPseudonymResult); return unwrapPseudonym(gPasPseudonymResult);
} }
@ -122,9 +75,9 @@ public class GpasPseudonymGenerator implements Generator {
} }
final var identifier = (Identifier) parameters.get().getPart().stream() final var identifier = (Identifier) parameters.get().getPart().stream()
.filter(a -> a.getName().equals("pseudonym")) .filter(a -> a.getName().equals("pseudonym"))
.findFirst() .findFirst()
.orElseGet(ParametersParameterComponent::new).getValue(); .orElseGet(ParametersParameterComponent::new).getValue();
// pseudonym // pseudonym
return sanitizeValue(identifier.getValue()); return sanitizeValue(identifier.getValue());
@ -150,12 +103,11 @@ public class GpasPseudonymGenerator implements Generator {
HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader); HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader);
ResponseEntity<String> responseEntity; ResponseEntity<String> responseEntity;
var restTemplate = getRestTemplete();
try { try {
responseEntity = retryTemplate.execute( responseEntity = retryTemplate.execute(
ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity, ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity,
String.class)); String.class));
if (responseEntity.getStatusCode().is2xxSuccessful()) { if (responseEntity.getStatusCode().is2xxSuccessful()) {
log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode()); log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode());
@ -167,16 +119,16 @@ public class GpasPseudonymGenerator implements Generator {
return responseEntity; return responseEntity;
} catch (Exception unexpected) { } catch (Exception unexpected) {
throw new PseudonymRequestFailed( throw new PseudonymRequestFailed(
"API request due unexpected error unsuccessful gPas unsuccessful.", unexpected); "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected);
} }
} }
protected String getGpasRequestBody(String id) { protected String getGpasRequestBody(String id) {
var requestParameters = new Parameters(); var requestParameters = new Parameters();
requestParameters.addParameter().setName("target") requestParameters.addParameter().setName("target")
.setValue(new StringType().setValue(psnTargetDomain)); .setValue(new StringType().setValue(psnTargetDomain));
requestParameters.addParameter().setName("original") requestParameters.addParameter().setName("original")
.setValue(new StringType().setValue(id)); .setValue(new StringType().setValue(id));
final IParser iParser = r4Context.newJsonParser(); final IParser iParser = r4Context.newJsonParser();
return iParser.encodeResourceToString(requestParameters); return iParser.encodeResourceToString(requestParameters);
} }
@ -190,99 +142,7 @@ public class GpasPseudonymGenerator implements Generator {
return headers; return headers;
} }
String authHeader = gPasUserName + ":" + gPasPassword; headers.setBasicAuth(gPasUserName, gPasPassword);
byte[] authHeaderBytes = authHeader.getBytes();
byte[] encodedAuthHeaderBytes = Base64.getEncoder().encode(authHeaderBytes);
String encodedAuthHeader = new String(encodedAuthHeaderBytes);
if (StringUtils.isNotBlank(gPasUserName) && StringUtils.isNotBlank(gPasPassword)) {
headers.set("Authorization", "Basic " + encodedAuthHeader);
}
return headers; return headers;
} }
protected RetryTemplate defaultTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(1000);
backOffPolicy.setMultiplier(1.25);
retryTemplate.setBackOffPolicy(backOffPolicy);
HashMap<Class<? extends Throwable>, Boolean> retryableExceptions = new HashMap<>();
retryableExceptions.put(RestClientException.class, true);
retryableExceptions.put(ConnectException.class, true);
RetryPolicy retryPolicy = new SimpleRetryPolicy(3, retryableExceptions);
retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.registerListener(new RetryListener() {
@Override
public <T, E extends Throwable> void onError(RetryContext context,
RetryCallback<T, E> callback, Throwable throwable) {
log.warn("HTTP Error occurred: {}. Retrying {}", throwable.getMessage(),
context.getRetryCount());
RetryListener.super.onError(context, callback, throwable);
}
});
return retryTemplate;
}
/**
* Read SSL root certificate and return SSLContext
*
* @param certificateLocation file location to root certificate (PEM)
* @return initialized SSLContext
* @throws IOException file cannot be read
* @throws CertificateException in case we have an invalid certificate of type X.509
* @throws KeyStoreException keystore cannot be initialized
* @throws NoSuchAlgorithmException missing trust manager algorithmus
* @throws KeyManagementException key management failed at init SSLContext
*/
@Nullable
protected SSLContext getSslContext(String certificateLocation)
throws IOException, CertificateException, KeyStoreException, KeyManagementException, NoSuchAlgorithmException {
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
FileInputStream fis = new FileInputStream(certificateLocation);
X509Certificate ca = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new BufferedInputStream(fis));
ks.load(null, null);
ks.setCertificateEntry(Integer.toString(1), ca);
TrustManagerFactory tmf = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ks);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), null);
return sslContext;
}
protected RestTemplate getRestTemplete() {
if (restTemplate != null) {
return restTemplate;
}
if (customSslContext == null) {
restTemplate = new RestTemplate();
return restTemplate;
}
final var sslsf = new SSLConnectionSocketFactory(customSslContext);
final Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
.register("https", sslsf).register("http", new PlainConnectionSocketFactory()).build();
final BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager(
socketFactoryRegistry);
final CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager).build();
final HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(
httpClient);
restTemplate = new RestTemplate(requestFactory);
return restTemplate;
}
} }

View File

@ -20,9 +20,10 @@
package dev.dnpm.etl.processor package dev.dnpm.etl.processor
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
@SpringBootApplication @SpringBootApplication(exclude = [SecurityAutoConfiguration::class])
class EtlProcessorApplication class EtlProcessorApplication
fun main(args: Array<String>) { fun main(args: Array<String>) {

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -19,13 +19,15 @@
package dev.dnpm.etl.processor.config package dev.dnpm.etl.processor.config
import dev.dnpm.etl.processor.security.Role
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(AppConfigProperties.NAME) @ConfigurationProperties(AppConfigProperties.NAME)
data class AppConfigProperties( data class AppConfigProperties(
var bwhcUri: String?, var bwhcUri: String?,
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN, var transformations: List<TransformationProperties> = listOf(),
var transformations: List<TransformationProperties> = listOf() var maxRetryAttempts: Int = 3,
var duplicationDetection: Boolean = true
) { ) {
companion object { companion object {
const val NAME = "app" const val NAME = "app"
@ -34,6 +36,7 @@ data class AppConfigProperties(
@ConfigurationProperties(PseudonymizeConfigProperties.NAME) @ConfigurationProperties(PseudonymizeConfigProperties.NAME)
data class PseudonymizeConfigProperties( data class PseudonymizeConfigProperties(
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN,
val prefix: String = "UNKNOWN", val prefix: String = "UNKNOWN",
) { ) {
companion object { companion object {
@ -47,9 +50,7 @@ data class GPasConfigProperties(
val target: String = "etl-processor", val target: String = "etl-processor",
val username: String?, val username: String?,
val password: String?, val password: String?,
val sslCaLocation: String?, ) {
) {
companion object { companion object {
const val NAME = "app.pseudonymize.gpas" const val NAME = "app.pseudonymize.gpas"
} }
@ -58,17 +59,21 @@ data class GPasConfigProperties(
@ConfigurationProperties(RestTargetProperties.NAME) @ConfigurationProperties(RestTargetProperties.NAME)
data class RestTargetProperties( data class RestTargetProperties(
val uri: String?, val uri: String?,
val username: String?,
val password: String?,
val isBwhc: Boolean = false,
) { ) {
companion object { companion object {
const val NAME = "app.rest" const val NAME = "app.rest"
} }
} }
@ConfigurationProperties(KafkaTargetProperties.NAME) @ConfigurationProperties(KafkaProperties.NAME)
data class KafkaTargetProperties( data class KafkaProperties(
val topic: String = "etl-processor", val inputTopic: String?,
val responseTopic: String = "${topic}_response", val outputTopic: String = "etl-processor",
val groupId: String = "${topic}_group", val outputResponseTopic: String = "${outputTopic}_response",
val groupId: String = "${outputTopic}_group",
val servers: String = "" val servers: String = ""
) { ) {
companion object { companion object {
@ -76,6 +81,19 @@ 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 { enum class PseudonymGenerator {
BUILDIN, BUILDIN,
GPAS GPAS

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -20,19 +20,40 @@
package dev.dnpm.etl.processor.config package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
import dev.dnpm.etl.processor.security.TokenRepository
import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.services.Transformation import dev.dnpm.etl.processor.services.Transformation
import dev.dnpm.etl.processor.services.TransformationService import dev.dnpm.etl.processor.services.TransformationService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
import org.springframework.retry.RetryCallback
import org.springframework.retry.RetryContext
import org.springframework.retry.RetryListener
import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplate
import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestTemplate
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@Configuration @Configuration
@EnableConfigurationProperties( @EnableConfigurationProperties(
@ -42,17 +63,23 @@ import reactor.core.publisher.Sinks
GPasConfigProperties::class GPasConfigProperties::class
] ]
) )
@EnableScheduling
class AppConfiguration { class AppConfiguration {
private val logger = LoggerFactory.getLogger(AppConfiguration::class.java) private val logger = LoggerFactory.getLogger(AppConfiguration::class.java)
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
@Bean @Bean
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties): Generator { fun restTemplate(): RestTemplate {
return GpasPseudonymGenerator(configProperties) return RestTemplate()
} }
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "BUILDIN", matchIfMissing = true) @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
@Bean
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator {
return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate)
}
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "BUILDIN", matchIfMissing = true)
@Bean @Bean
fun buildinPseudonymGenerator(): Generator { fun buildinPseudonymGenerator(): Generator {
return AnonymizingGenerator() return AnonymizingGenerator()
@ -71,11 +98,6 @@ class AppConfiguration {
return ReportService(objectMapper) return ReportService(objectMapper)
} }
@Bean
fun statisticsUpdateProducer(): Sinks.Many<Any> {
return Sinks.many().multicast().directBestEffort()
}
@Bean @Bean
fun transformationService( fun transformationService(
objectMapper: ObjectMapper, objectMapper: ObjectMapper,
@ -87,5 +109,66 @@ class AppConfiguration {
}) })
} }
@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
fun statisticsUpdateProducer(): Sinks.Many<Any> {
return Sinks.many().multicast().directBestEffort()
}
@Bean
fun connectionCheckUpdateProducer(): Sinks.Many<ConnectionCheckResult> {
return Sinks.many().multicast().onBackpressureBuffer()
}
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
@Bean
fun gPasConnectionCheckService(
restTemplate: RestTemplate,
gPasConfigProperties: GPasConfigProperties,
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
): ConnectionCheckService {
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
}
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
@ConditionalOnMissingBean
@Bean
fun gPasConnectionCheckServiceOnDeprecatedProperty(
restTemplate: RestTemplate,
gPasConfigProperties: GPasConfigProperties,
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
): ConnectionCheckService {
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
}
@Bean
fun jdbcConfiguration(): AbstractJdbcConfiguration {
return AppJdbcConfiguration()
}
} }

View File

@ -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
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -20,8 +20,13 @@
package dev.dnpm.etl.processor.config package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.input.KafkaInputListener
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.KafkaConnectionCheckService
import dev.dnpm.etl.processor.output.KafkaMtbFileSender import dev.dnpm.etl.processor.output.KafkaMtbFileSender
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.services.RequestProcessor
import dev.dnpm.etl.processor.services.kafka.KafkaResponseProcessor import dev.dnpm.etl.processor.services.kafka.KafkaResponseProcessor
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
@ -35,12 +40,14 @@ import org.springframework.kafka.core.ConsumerFactory
import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.listener.ContainerProperties import org.springframework.kafka.listener.ContainerProperties
import org.springframework.kafka.listener.KafkaMessageListenerContainer import org.springframework.kafka.listener.KafkaMessageListenerContainer
import org.springframework.retry.support.RetryTemplate
import reactor.core.publisher.Sinks
@Configuration @Configuration
@EnableConfigurationProperties( @EnableConfigurationProperties(
value = [KafkaTargetProperties::class] value = [KafkaProperties::class]
) )
@ConditionalOnProperty(value = ["app.kafka.topic", "app.kafka.servers"]) @ConditionalOnProperty(value = ["app.kafka.servers"])
@ConditionalOnMissingBean(MtbFileSender::class) @ConditionalOnMissingBean(MtbFileSender::class)
@Order(-5) @Order(-5)
class AppKafkaConfiguration { class AppKafkaConfiguration {
@ -50,20 +57,21 @@ class AppKafkaConfiguration {
@Bean @Bean
fun kafkaMtbFileSender( fun kafkaMtbFileSender(
kafkaTemplate: KafkaTemplate<String, String>, kafkaTemplate: KafkaTemplate<String, String>,
kafkaTargetProperties: KafkaTargetProperties, kafkaProperties: KafkaProperties,
retryTemplate: RetryTemplate,
objectMapper: ObjectMapper objectMapper: ObjectMapper
): MtbFileSender { ): MtbFileSender {
logger.info("Selected 'KafkaMtbFileSender'") logger.info("Selected 'KafkaMtbFileSender'")
return KafkaMtbFileSender(kafkaTemplate, kafkaTargetProperties, objectMapper) return KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper)
} }
@Bean @Bean
fun kafkaListenerContainer( fun kafkaResponseListenerContainer(
consumerFactory: ConsumerFactory<String, String>, consumerFactory: ConsumerFactory<String, String>,
kafkaTargetProperties: KafkaTargetProperties, kafkaProperties: KafkaProperties,
kafkaResponseProcessor: KafkaResponseProcessor kafkaResponseProcessor: KafkaResponseProcessor
): KafkaMessageListenerContainer<String, String> { ): KafkaMessageListenerContainer<String, String> {
val containerProperties = ContainerProperties(kafkaTargetProperties.responseTopic) val containerProperties = ContainerProperties(kafkaProperties.outputResponseTopic)
containerProperties.messageListener = kafkaResponseProcessor containerProperties.messageListener = kafkaResponseProcessor
return KafkaMessageListenerContainer(consumerFactory, containerProperties) return KafkaMessageListenerContainer(consumerFactory, containerProperties)
} }
@ -76,4 +84,33 @@ class AppKafkaConfiguration {
return KafkaResponseProcessor(applicationEventPublisher, objectMapper) return KafkaResponseProcessor(applicationEventPublisher, objectMapper)
} }
@Bean
@ConditionalOnProperty(value = ["app.kafka.input-topic"])
fun kafkaInputListenerContainer(
consumerFactory: ConsumerFactory<String, String>,
kafkaProperties: KafkaProperties,
kafkaInputListener: KafkaInputListener
): KafkaMessageListenerContainer<String, String> {
val containerProperties = ContainerProperties(kafkaProperties.inputTopic)
containerProperties.messageListener = kafkaInputListener
return KafkaMessageListenerContainer(consumerFactory, containerProperties)
}
@Bean
@ConditionalOnProperty(value = ["app.kafka.input-topic"])
fun kafkaInputListener(
requestProcessor: RequestProcessor,
objectMapper: ObjectMapper
): KafkaInputListener {
return KafkaInputListener(requestProcessor, objectMapper)
}
@Bean
fun kafkaConnectionCheckService(
consumerFactory: ConsumerFactory<String, String>,
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
): ConnectionCheckService {
return KafkaConnectionCheckService(consumerFactory.createConsumer(), connectionCheckUpdateProducer)
}
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -19,8 +19,13 @@
package dev.dnpm.etl.processor.config package dev.dnpm.etl.processor.config
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.output.RestBwhcMtbFileSender
import dev.dnpm.etl.processor.output.RestDipMtbFileSender
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
@ -28,7 +33,9 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order import org.springframework.core.annotation.Order
import org.springframework.retry.support.RetryTemplate
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import reactor.core.publisher.Sinks
@Configuration @Configuration
@EnableConfigurationProperties( @EnableConfigurationProperties(
@ -44,14 +51,28 @@ class AppRestConfiguration {
private val logger = LoggerFactory.getLogger(AppRestConfiguration::class.java) private val logger = LoggerFactory.getLogger(AppRestConfiguration::class.java)
@Bean @Bean
fun restTemplate(): RestTemplate { fun restMtbFileSender(
return RestTemplate() restTemplate: RestTemplate,
restTargetProperties: RestTargetProperties,
retryTemplate: RetryTemplate,
reportService: ReportService,
): MtbFileSender {
if (restTargetProperties.isBwhc) {
logger.info("Selected 'RestBwhcMtbFileSender'")
return RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
}
logger.info("Selected 'RestDipMtbFileSender'")
return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
} }
@Bean @Bean
fun restMtbFileSender(restTemplate: RestTemplate, restTargetProperties: RestTargetProperties): MtbFileSender { fun restConnectionCheckService(
logger.info("Selected 'RestMtbFileSender'") restTemplate: RestTemplate,
return RestMtbFileSender(restTemplate, restTargetProperties) restTargetProperties: RestTargetProperties,
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
): ConnectionCheckService {
return RestConnectionCheckService(restTemplate, restTargetProperties, connectionCheckUpdateProducer)
} }
} }

View File

@ -0,0 +1,196 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.config
import dev.dnpm.etl.processor.security.UserRole
import dev.dnpm.etl.processor.security.UserRoleRepository
import dev.dnpm.etl.processor.security.UserRoleService
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.session.SessionRegistryImpl
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.crypto.factory.PasswordEncoderFactories
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import java.util.*
private const val LOGIN_PATH = "/login"
@Configuration
@EnableConfigurationProperties(
value = [
SecurityConfigProperties::class
]
)
@ConditionalOnProperty(value = ["app.security.admin-user"])
@EnableWebSecurity
class AppSecurityConfiguration(
private val securityConfigProperties: SecurityConfigProperties
) {
private val logger = LoggerFactory.getLogger(AppSecurityConfiguration::class.java)
@Bean
fun userDetailsService(passwordEncoder: PasswordEncoder): InMemoryUserDetailsManager {
val adminUser = if (securityConfigProperties.adminUser.isNullOrBlank()) {
logger.warn("Using random Admin User: admin")
"admin"
} else {
securityConfigProperties.adminUser
}
val adminPassword = if (securityConfigProperties.adminPassword.isNullOrBlank()) {
val random = UUID.randomUUID().toString()
logger.warn("Using random Admin Passwort: {}", random)
passwordEncoder.encode(random)
} else {
securityConfigProperties.adminPassword
}
val user: UserDetails = User.withUsername(adminUser)
.password(adminPassword)
.roles("ADMIN")
.build()
return InMemoryUserDetailsManager(user)
}
@Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
fun filterChainOidc(
http: HttpSecurity,
passwordEncoder: PasswordEncoder,
userRoleRepository: UserRoleRepository,
sessionRegistry: SessionRegistry
): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/configs/**", hasRole("ADMIN"))
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER"))
authorize("/report/**", hasAnyRole("ADMIN", "USER"))
authorize("*.css", permitAll)
authorize("*.ico", permitAll)
authorize("*.jpeg", permitAll)
authorize("*.js", permitAll)
authorize("*.svg", permitAll)
authorize("*.css", permitAll)
authorize("/login/**", permitAll)
authorize(anyRequest, permitAll)
}
httpBasic {
realmName = "ETL-Processor"
}
formLogin {
loginPage = LOGIN_PATH
}
oauth2Login {
loginPage = LOGIN_PATH
}
sessionManagement {
sessionConcurrency {
maximumSessions = 1
expiredUrl = "$LOGIN_PATH?expired"
}
sessionFixation {
newSession()
}
}
csrf { disable() }
}
return http.build()
}
@Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
fun grantedAuthoritiesMapper(
userRoleRepository: UserRoleRepository,
appSecurityConfigProperties: SecurityConfigProperties
): GrantedAuthoritiesMapper {
return GrantedAuthoritiesMapper { grantedAuthority ->
grantedAuthority.filterIsInstance<OidcUserAuthority>()
.onEach {
val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
if (userRole.isEmpty) {
userRoleRepository.save(
UserRole(
null,
it.userInfo.preferredUsername,
appSecurityConfigProperties.defaultNewUserRole
)
)
}
}
.map {
val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
SimpleGrantedAuthority("ROLE_${userRole.get().role.toString().uppercase()}")
}
}
}
@Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true)
fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/configs/**", hasRole("ADMIN"))
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN"))
authorize("/report/**", hasRole("ADMIN"))
authorize(anyRequest, permitAll)
}
httpBasic {
realmName = "ETL-Processor"
}
formLogin {
loginPage = LOGIN_PATH
}
csrf { disable() }
}
return http.build()
}
@Bean
fun sessionRegistry(): SessionRegistry {
return SessionRegistryImpl()
}
@Bean
fun passwordEncoder(): PasswordEncoder {
return PasswordEncoderFactories.createDelegatingPasswordEncoder()
}
@Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
fun userRoleService(userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): UserRoleService {
return UserRoleService(userRoleRepository, sessionRegistry)
}
}

View File

@ -0,0 +1,91 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.services.RequestProcessor
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.kafka.listener.MessageListener
class KafkaInputListener(
private val requestProcessor: RequestProcessor,
private val objectMapper: ObjectMapper
) : MessageListener<String, String> {
private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java)
override fun onMessage(record: ConsumerRecord<String, String>) {
when (guessMimeType(record)) {
MediaType.APPLICATION_JSON_VALUE -> handleBwhcMessage(record)
CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE -> handleDnpmV2Message(record)
else -> {
/* ignore other messages */
}
}
}
private fun guessMimeType(record: ConsumerRecord<String, String>): String {
if (record.headers().headers("contentType").toList().isEmpty()) {
// Fallback if no contentType set (old behavior)
return MediaType.APPLICATION_JSON_VALUE
}
return record.headers().headers("contentType")?.firstOrNull()?.value().contentToString()
}
private fun handleBwhcMessage(record: ConsumerRecord<String, String>) {
val mtbFile = objectMapper.readValue(record.value(), MtbFile::class.java)
val patientId = PatientId(mtbFile.patient.id)
val firstRequestIdHeader = record.headers().headers("requestId")?.firstOrNull()
val requestId = if (null != firstRequestIdHeader) {
RequestId(String(firstRequestIdHeader.value()))
} else {
RequestId("")
}
if (mtbFile.consent.status == Consent.Status.ACTIVE) {
logger.debug("Accepted MTB File for processing")
if (requestId.isBlank()) {
requestProcessor.processMtbFile(mtbFile)
} else {
requestProcessor.processMtbFile(mtbFile, requestId)
}
} else {
logger.debug("Accepted MTB File and process deletion")
if (requestId.isBlank()) {
requestProcessor.processDeletion(patientId)
} else {
requestProcessor.processDeletion(patientId, requestId)
}
}
}
private fun handleDnpmV2Message(record: ConsumerRecord<String, String>) {
// Do not handle DNPM-V2 for now
logger.warn("Ignoring MTB File in DNPM V2 format: Not implemented yet")
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -17,39 +17,57 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.dnpm.etl.processor.web package dev.dnpm.etl.processor.input
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@RestController @RestController
@RequestMapping(path = ["mtbfile", "mtb"])
class MtbFileRestController( class MtbFileRestController(
private val requestProcessor: RequestProcessor, private val requestProcessor: RequestProcessor,
) { ) {
private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java) private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java)
@PostMapping(path = ["/mtbfile"]) @GetMapping
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Void> { fun info(): ResponseEntity<String> {
return ResponseEntity.ok("Test")
}
@PostMapping( consumes = [ MediaType.APPLICATION_JSON_VALUE ] )
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Unit> {
if (mtbFile.consent.status == Consent.Status.ACTIVE) { if (mtbFile.consent.status == Consent.Status.ACTIVE) {
logger.debug("Accepted MTB File for processing") logger.debug("Accepted MTB File (bwHC V1) for processing")
requestProcessor.processMtbFile(mtbFile) requestProcessor.processMtbFile(mtbFile)
} else { } else {
logger.debug("Accepted MTB File and process deletion") logger.debug("Accepted MTB File (bwHC V1) and process deletion")
requestProcessor.processDeletion(mtbFile.patient.id) val patientId = PatientId(mtbFile.patient.id)
requestProcessor.processDeletion(patientId)
} }
return ResponseEntity.accepted().build() return ResponseEntity.accepted().build()
} }
@DeleteMapping(path = ["/mtbfile/{patientId}"]) @PostMapping( consumes = [ CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE] )
fun deleteData(@PathVariable patientId: String): ResponseEntity<Void> { fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity<Unit> {
logger.debug("Accepted patient ID to process deletion") logger.debug("Accepted MTB File (DNPM V2) for processing")
requestProcessor.processDeletion(patientId) requestProcessor.processMtbFile(mtbFile)
return ResponseEntity.accepted().build() return ResponseEntity.accepted().build()
} }
} @DeleteMapping(path = ["{patientId}"])
fun deleteData(@PathVariable patientId: String): ResponseEntity<Unit> {
logger.debug("Accepted patient ID to process deletion")
requestProcessor.processDeletion(PatientId(patientId))
return ResponseEntity.accepted().build()
}
}

View File

@ -0,0 +1,210 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.monitoring
import dev.dnpm.etl.processor.config.GPasConfigProperties
import dev.dnpm.etl.processor.config.RestTargetProperties
import jakarta.annotation.PostConstruct
import org.apache.kafka.clients.consumer.Consumer
import org.apache.kafka.common.errors.TimeoutException
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.*
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder
import reactor.core.publisher.Sinks
import java.time.Instant
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
fun interface ConnectionCheckService {
fun connectionAvailable(): ConnectionCheckResult
}
interface OutputConnectionCheckService : ConnectionCheckService
sealed class ConnectionCheckResult {
abstract val available: Boolean
abstract val timestamp: Instant
abstract val lastChange: Instant
data class KafkaConnectionCheckResult(
override val available: Boolean,
override val timestamp: Instant,
override val lastChange: Instant
) : ConnectionCheckResult()
data class RestConnectionCheckResult(
override val available: Boolean,
override val timestamp: Instant,
override val lastChange: Instant
) : ConnectionCheckResult()
data class GPasConnectionCheckResult(
override val available: Boolean,
override val timestamp: Instant,
override val lastChange: Instant
) : ConnectionCheckResult()
}
class KafkaConnectionCheckService(
private val consumer: Consumer<String, String>,
@Qualifier("connectionCheckUpdateProducer")
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) : OutputConnectionCheckService {
private var result = ConnectionCheckResult.KafkaConnectionCheckResult(false, Instant.now(), Instant.now())
@PostConstruct
@Scheduled(cron = "0 * * * * *")
fun check() {
result = try {
val available = null != consumer.listTopics(5.seconds.toJavaDuration())
ConnectionCheckResult.KafkaConnectionCheckResult(
available,
Instant.now(),
if (result.available == available) { result.lastChange } else { Instant.now() }
)
} catch (_: TimeoutException) {
ConnectionCheckResult.KafkaConnectionCheckResult(
false,
Instant.now(),
if (!result.available) { result.lastChange } else { Instant.now() }
)
}
connectionCheckUpdateProducer.emitNext(
result,
Sinks.EmitFailureHandler.FAIL_FAST
)
}
override fun connectionAvailable(): ConnectionCheckResult.KafkaConnectionCheckResult {
return this.result
}
}
class RestConnectionCheckService(
private val restTemplate: RestTemplate,
private val restTargetProperties: RestTargetProperties,
@Qualifier("connectionCheckUpdateProducer")
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) : OutputConnectionCheckService {
private var result = ConnectionCheckResult.RestConnectionCheckResult(false, Instant.now(), Instant.now())
@PostConstruct
@Scheduled(cron = "0 * * * * *")
fun check() {
result = try {
val available = restTemplate.getForEntity(
if (restTargetProperties.isBwhc) {
UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString()).path("").toUriString()
} else {
UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString())
.pathSegment("mtb")
.pathSegment("kaplan-meier")
.pathSegment("config")
.toUriString()
},
String::class.java
).statusCode == HttpStatus.OK
ConnectionCheckResult.RestConnectionCheckResult(
available,
Instant.now(),
if (result.available == available) { result.lastChange } else { Instant.now() }
)
} catch (_: Exception) {
ConnectionCheckResult.RestConnectionCheckResult(
false,
Instant.now(),
if (!result.available) { result.lastChange } else { Instant.now() }
)
}
connectionCheckUpdateProducer.emitNext(
result,
Sinks.EmitFailureHandler.FAIL_FAST
)
}
override fun connectionAvailable(): ConnectionCheckResult.RestConnectionCheckResult {
return this.result
}
}
class GPasConnectionCheckService(
private val restTemplate: RestTemplate,
private val gPasConfigProperties: GPasConfigProperties,
@Qualifier("connectionCheckUpdateProducer")
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) : ConnectionCheckService {
private var result = ConnectionCheckResult.GPasConnectionCheckResult(false, Instant.now(), Instant.now())
@PostConstruct
@Scheduled(cron = "0 * * * * *")
fun check() {
result = try {
val uri = UriComponentsBuilder.fromUriString(
gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/metadata").toString()
).build().toUri()
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
if (!gPasConfigProperties.username.isNullOrBlank() && !gPasConfigProperties.password.isNullOrBlank()) {
headers.setBasicAuth(gPasConfigProperties.username, gPasConfigProperties.password)
}
val available = restTemplate.exchange(
uri,
HttpMethod.GET,
HttpEntity<Void>(headers),
Void::class.java
).statusCode == HttpStatus.OK
ConnectionCheckResult.GPasConnectionCheckResult(
available,
Instant.now(),
if (result.available == available) { result.lastChange } else { Instant.now() }
)
} catch (_: Exception) {
ConnectionCheckResult.GPasConnectionCheckResult(
false,
Instant.now(),
if (!result.available) { result.lastChange } else { Instant.now() }
)
}
connectionCheckUpdateProducer.emitNext(
result,
Sinks.EmitFailureHandler.FAIL_FAST
)
}
override fun connectionAvailable(): ConnectionCheckResult.GPasConnectionCheckResult {
return this.result
}
}

View File

@ -19,11 +19,15 @@
package dev.dnpm.etl.processor.monitoring package dev.dnpm.etl.processor.monitoring
import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.annotation.JsonValue
import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.monitoring.ReportService.Issue
import dev.dnpm.etl.processor.monitoring.ReportService.Severity
import java.util.Optional
class ReportService( class ReportService(
private val objectMapper: ObjectMapper private val objectMapper: ObjectMapper
@ -54,11 +58,25 @@ class ReportService(
private data class DataQualityReport(val issues: List<Issue>) private data class DataQualityReport(val issues: List<Issue>)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class Issue(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) { enum class Severity(@JsonValue val value: String) {
FATAL("fatal"),
ERROR("error"), ERROR("error"),
WARNING("warning"), WARNING("warning"),
INFO("info") 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
}
} }

View File

@ -19,50 +19,79 @@
package dev.dnpm.etl.processor.monitoring package dev.dnpm.etl.processor.monitoring
import dev.dnpm.etl.processor.*
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jdbc.repository.query.Query import org.springframework.data.jdbc.repository.query.Query
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Embedded import org.springframework.data.relational.core.mapping.Embedded
import org.springframework.data.relational.core.mapping.Table import org.springframework.data.relational.core.mapping.Table
import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.CrudRepository
import org.springframework.data.repository.PagingAndSortingRepository
import java.time.Instant import java.time.Instant
import java.util.* import java.util.*
typealias RequestId = UUID
@Table("request") @Table("request")
data class Request( data class Request(
@Id val id: Long? = null, @Id val id: Long? = null,
val uuid: String = RequestId.randomUUID().toString(), val uuid: RequestId = randomRequestId(),
val patientId: String, val patientPseudonym: PatientPseudonym,
val pid: String, val pid: PatientId,
val fingerprint: String, @Column("fingerprint")
val fingerprint: Fingerprint,
val type: RequestType, val type: RequestType,
var status: RequestStatus, var status: RequestStatus,
var processedAt: Instant = Instant.now(), var processedAt: Instant = Instant.now(),
@Embedded.Nullable var report: Report? = null @Embedded.Nullable var report: Report? = null
) ) {
constructor(
uuid: RequestId,
patientPseudonym: PatientPseudonym,
pid: PatientId,
fingerprint: Fingerprint,
type: RequestType,
status: RequestStatus
) :
this(null, uuid, patientPseudonym, pid, fingerprint, type, status, Instant.now())
constructor(
uuid: RequestId,
patientPseudonym: PatientPseudonym,
pid: PatientId,
fingerprint: Fingerprint,
type: RequestType,
status: RequestStatus,
processedAt: Instant
) :
this(null, uuid, patientPseudonym, pid, fingerprint, type, status, processedAt)
}
@JvmRecord
data class Report( data class Report(
val description: String, val description: String,
val dataQualityReport: String = "" val dataQualityReport: String = ""
) )
@JvmRecord
data class CountedState( data class CountedState(
val count: Int, val count: Int,
val status: RequestStatus, val status: RequestStatus,
) )
interface RequestRepository : CrudRepository<Request, Long> { 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;") @Query("SELECT count(*) AS count, status FROM request WHERE type = 'MTB_FILE' GROUP BY status ORDER BY status, count DESC;")
fun countStates(): List<CountedState> fun countStates(): List<CountedState>
@Query("SELECT count(*) AS count, status FROM (" + @Query("SELECT count(*) AS count, status FROM (" +
"SELECT status, rank() OVER (PARTITION BY patient_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') " + "WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " +
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;") ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
fun findPatientUniqueStates(): List<CountedState> fun findPatientUniqueStates(): List<CountedState>
@ -71,7 +100,7 @@ interface RequestRepository : CrudRepository<Request, Long> {
fun countDeleteStates(): List<CountedState> fun countDeleteStates(): List<CountedState>
@Query("SELECT count(*) AS count, status FROM (" + @Query("SELECT count(*) AS count, status FROM (" +
"SELECT status, rank() OVER (PARTITION BY patient_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'" + "WHERE type = 'DELETE'" +
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;") ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
fun findPatientUniqueDeleteStates(): List<CountedState> fun findPatientUniqueDeleteStates(): List<CountedState>

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -22,31 +22,44 @@ package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.config.KafkaTargetProperties import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.config.KafkaProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.apache.kafka.clients.producer.ProducerRecord
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.KafkaTemplate
import org.springframework.retry.support.RetryTemplate
class KafkaMtbFileSender( class KafkaMtbFileSender(
private val kafkaTemplate: KafkaTemplate<String, String>, private val kafkaTemplate: KafkaTemplate<String, String>,
private val kafkaTargetProperties: KafkaTargetProperties, private val kafkaProperties: KafkaProperties,
private val retryTemplate: RetryTemplate,
private val objectMapper: ObjectMapper private val objectMapper: ObjectMapper
) : MtbFileSender { ) : MtbFileSender {
private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java) private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java)
override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response { override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response {
return try { return try {
val result = kafkaTemplate.send( return retryTemplate.execute<MtbFileSender.Response, Exception> {
kafkaTargetProperties.topic, val record =
key(request), ProducerRecord(kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(request))
objectMapper.writeValueAsString(Data(request.requestId, request.mtbFile)) when (request) {
) is BwhcV1MtbFileRequest -> record.headers()
if (result.get() != null) { .add("contentType", MediaType.APPLICATION_JSON_VALUE.toByteArray())
logger.debug("Sent file via KafkaMtbFileSender")
MtbFileSender.Response(RequestStatus.UNKNOWN) is DnpmV2MtbFileRequest -> record.headers()
} else { .add("contentType", CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray())
MtbFileSender.Response(RequestStatus.ERROR) }
val result = kafkaTemplate.send(record)
if (result.get() != null) {
logger.debug("Sent file via KafkaMtbFileSender")
MtbFileSender.Response(RequestStatus.UNKNOWN)
} else {
MtbFileSender.Response(RequestStatus.ERROR)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.error("An error occurred sending to kafka", e) logger.error("An error occurred sending to kafka", e)
@ -54,28 +67,33 @@ class KafkaMtbFileSender(
} }
} }
override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response { override fun send(request: DeleteRequest): MtbFileSender.Response {
val dummyMtbFile = MtbFile.builder() val dummyMtbFile = MtbFile.builder()
.withConsent( .withConsent(
Consent.builder() Consent.builder()
.withPatient(request.patientId) .withPatient(request.patientId.value)
.withStatus(Consent.Status.REJECTED) .withStatus(Consent.Status.REJECTED)
.build() .build()
) )
.build() .build()
return try { return try {
val result = kafkaTemplate.send( return retryTemplate.execute<MtbFileSender.Response, Exception> {
kafkaTargetProperties.topic, val record =
key(request), ProducerRecord(
objectMapper.writeValueAsString(Data(request.requestId, dummyMtbFile)) kafkaProperties.outputTopic,
) key(request),
// Always use old BwhcV1FileRequest with Consent REJECT
objectMapper.writeValueAsString(BwhcV1MtbFileRequest(request.requestId, dummyMtbFile))
)
if (result.get() != null) { val result = kafkaTemplate.send(record)
logger.debug("Sent deletion request via KafkaMtbFileSender") if (result.get() != null) {
MtbFileSender.Response(RequestStatus.UNKNOWN) logger.debug("Sent deletion request via KafkaMtbFileSender")
} else { MtbFileSender.Response(RequestStatus.UNKNOWN)
MtbFileSender.Response(RequestStatus.ERROR) } else {
MtbFileSender.Response(RequestStatus.ERROR)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.error("An error occurred sending to kafka", e) logger.error("An error occurred sending to kafka", e)
@ -83,14 +101,16 @@ class KafkaMtbFileSender(
} }
} }
private fun key(request: MtbFileSender.MtbFileRequest): String { override fun endpoint(): String {
return "{\"pid\": \"${request.mtbFile.patient.id}\", " + return "${this.kafkaProperties.servers} (${this.kafkaProperties.outputTopic}/${this.kafkaProperties.outputResponseTopic})"
"\"eid\": \"${request.mtbFile.episode.id}\"}"
} }
private fun key(request: MtbFileSender.DeleteRequest): String { private fun key(request: MtbRequest): String {
return "{\"pid\": \"${request.patientId}\"}" return when (request) {
is BwhcV1MtbFileRequest -> "{\"pid\": \"${request.content.patient.id}\"}"
is DnpmV2MtbFileRequest -> "{\"pid\": \"${request.content.patient.id}\"}"
is DeleteRequest -> "{\"pid\": \"${request.patientId.value}\"}"
else -> throw IllegalArgumentException("Unsupported request type: ${request::class.simpleName}")
}
} }
}
data class Data(val requestId: String, val content: MtbFile)
}

View File

@ -19,21 +19,17 @@
package dev.dnpm.etl.processor.output package dev.dnpm.etl.processor.output
import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.springframework.http.HttpStatusCode import org.springframework.http.HttpStatusCode
interface MtbFileSender { interface MtbFileSender {
fun send(request: MtbFileRequest): Response fun <T> send(request: MtbFileRequest<T>): Response
fun send(request: DeleteRequest): Response fun send(request: DeleteRequest): Response
fun endpoint(): String
data class Response(val status: RequestStatus, val body: String = "") data class Response(val status: RequestStatus, val body: String = "")
data class MtbFileRequest(val requestId: String, val mtbFile: MtbFile)
data class DeleteRequest(val requestId: String, val patientId: String)
} }
fun Int.asRequestStatus(): RequestStatus { fun Int.asRequestStatus(): RequestStatus {
@ -47,4 +43,4 @@ fun Int.asRequestStatus(): RequestStatus {
fun HttpStatusCode.asRequestStatus(): RequestStatus { fun HttpStatusCode.asRequestStatus(): RequestStatus {
return this.value().asRequestStatus() return this.value().asRequestStatus()
} }

View File

@ -0,0 +1,59 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.output
import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.RequestId
import dev.pcvolkmer.mv64e.mtb.Mtb
interface MtbRequest {
val requestId: RequestId
}
sealed interface MtbFileRequest<out T> : MtbRequest {
override val requestId: RequestId
val content: T
fun patientPseudonym(): PatientPseudonym
}
data class BwhcV1MtbFileRequest(
override val requestId: RequestId,
override val content: MtbFile
) : MtbFileRequest<MtbFile> {
override fun patientPseudonym(): PatientPseudonym {
return PatientPseudonym(content.patient.id)
}
}
data class DnpmV2MtbFileRequest(
override val requestId: RequestId,
override val content: Mtb
) : MtbFileRequest<Mtb> {
override fun patientPseudonym(): PatientPseudonym {
return PatientPseudonym(content.patient.id)
}
}
data class DeleteRequest(
override val requestId: RequestId,
val patientId: PatientPseudonym
) : MtbRequest

View File

@ -0,0 +1,51 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.output
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.ReportService
import org.springframework.retry.support.RetryTemplate
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder
class RestBwhcMtbFileSender(
restTemplate: RestTemplate,
private val restTargetProperties: RestTargetProperties,
retryTemplate: RetryTemplate,
reportService: ReportService,
) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) {
override fun sendUrl(): String {
return UriComponentsBuilder
.fromUriString(restTargetProperties.uri.toString())
.pathSegment("MTBFile")
.toUriString()
}
override fun deleteUrl(patientId: PatientPseudonym): String {
return UriComponentsBuilder
.fromUriString(restTargetProperties.uri.toString())
.pathSegment("Patient")
.pathSegment(patientId.value)
.toUriString()
}
}

View File

@ -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()
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -19,59 +19,77 @@
package dev.dnpm.etl.processor.output package dev.dnpm.etl.processor.output
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.config.RestTargetProperties import dev.dnpm.etl.processor.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.asRequestStatus
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.HttpEntity import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.retry.support.RetryTemplate
import org.springframework.web.client.RestClientException import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
class RestMtbFileSender( abstract class RestMtbFileSender(
private val restTemplate: RestTemplate, private val restTemplate: RestTemplate,
private val restTargetProperties: RestTargetProperties private val restTargetProperties: RestTargetProperties,
private val retryTemplate: RetryTemplate,
private val reportService: ReportService
) : MtbFileSender { ) : MtbFileSender {
private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java) private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java)
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 { try {
val headers = HttpHeaders() return retryTemplate.execute<MtbFileSender.Response, Exception> {
headers.contentType = MediaType.APPLICATION_JSON val headers = getHttpHeaders(request)
val entityReq = HttpEntity(request.mtbFile, headers) val entityReq = HttpEntity(request.content, headers)
val response = restTemplate.postForEntity( val response = restTemplate.postForEntity(
"${restTargetProperties.uri}/MTBFile", sendUrl(),
entityReq, entityReq,
String::class.java String::class.java
) )
if (!response.statusCode.is2xxSuccessful) { if (!response.statusCode.is2xxSuccessful) {
logger.warn("Error sending to remote system: {}", response.body) logger.warn("Error sending to remote system: {}", response.body)
return MtbFileSender.Response(response.statusCode.asRequestStatus(), "Status-Code: ${response.statusCode.value()}") 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())
} }
logger.debug("Sent file via RestMtbFileSender")
return MtbFileSender.Response(response.statusCode.asRequestStatus(), response.body.orEmpty())
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!) logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
} catch (e: RestClientException) { } catch (e: RestClientResponseException) {
logger.info(restTargetProperties.uri!!.toString()) logger.info(restTargetProperties.uri!!.toString())
logger.error("Cannot send data to remote system", e) logger.error("Request data not accepted by remote system", e)
return MtbFileSender.Response(reportService.deserialize(e.responseBodyAsString).asRequestStatus(), e.responseBodyAsString)
} }
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
} }
override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response { override fun send(request: DeleteRequest): MtbFileSender.Response {
try { try {
val headers = HttpHeaders() return retryTemplate.execute<MtbFileSender.Response, Exception> {
headers.contentType = MediaType.APPLICATION_JSON val headers = getHttpHeaders(request)
val entityReq = HttpEntity(null, headers) val entityReq = HttpEntity(null, headers)
restTemplate.delete( restTemplate.delete(
"${restTargetProperties.uri}/Patient/${request.patientId}", deleteUrl(request.patientId),
entityReq, entityReq,
String::class.java String::class.java
) )
logger.debug("Sent file via RestMtbFileSender") logger.debug("Sent file via RestMtbFileSender")
return MtbFileSender.Response(RequestStatus.SUCCESS) return@execute MtbFileSender.Response(RequestStatus.SUCCESS)
}
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!) logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
} catch (e: RestClientException) { } catch (e: RestClientException) {
@ -81,4 +99,26 @@ class RestMtbFileSender(
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
} }
} override fun endpoint(): String {
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 BwhcV1MtbFileRequest -> MediaType.APPLICATION_JSON
is DnpmV2MtbFileRequest -> CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON
else -> MediaType.APPLICATION_JSON
}
if (username.isNullOrBlank() || password.isNullOrBlank()) {
return headers
}
headers.setBasicAuth(username, password)
return headers
}
}

View File

@ -19,6 +19,8 @@
package dev.dnpm.etl.processor.pseudonym package dev.dnpm.etl.processor.pseudonym
import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties
class PseudonymizeService( class PseudonymizeService(
@ -26,11 +28,15 @@ class PseudonymizeService(
private val configProperties: PseudonymizeConfigProperties private val configProperties: PseudonymizeConfigProperties
) { ) {
fun patientPseudonym(patientId: String): String { fun patientPseudonym(patientId: PatientId): PatientPseudonym {
return when (generator) { return when (generator) {
is GpasPseudonymGenerator -> generator.generate(patientId) is GpasPseudonymGenerator -> PatientPseudonym(generator.generate(patientId.value))
else -> "${configProperties.prefix}_${generator.generate(patientId)}" else -> PatientPseudonym("${configProperties.prefix}_${generator.generate(patientId.value)}")
} }
} }
fun prefix(): String {
return configProperties.prefix
}
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -20,34 +20,296 @@
package dev.dnpm.etl.processor.pseudonym package dev.dnpm.etl.processor.pseudonym
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.PatientId
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.apache.commons.codec.digest.DigestUtils
/** Replaces patient ID with generated patient pseudonym
*
* @param pseudonymizeService The pseudonymizeService to be used
* @return The MTB file containing patient pseudonymes
*/
infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
val patientPseudonym = pseudonymizeService.patientPseudonym(this.patient.id) val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value
this.episode.patient = patientPseudonym this.episode?.patient = patientPseudonym
this.carePlans.forEach { it.patient = patientPseudonym } this.carePlans?.forEach { it.patient = patientPseudonym }
this.patient.id = patientPseudonym this.patient.id = patientPseudonym
this.claims.forEach { it.patient = patientPseudonym } this.claims?.forEach { it.patient = patientPseudonym }
this.consent.patient = patientPseudonym this.consent?.patient = patientPseudonym
this.claimResponses.forEach { it.patient = patientPseudonym } this.claimResponses?.forEach { it.patient = patientPseudonym }
this.diagnoses.forEach { it.patient = patientPseudonym } this.diagnoses?.forEach { it.patient = patientPseudonym }
this.ecogStatus.forEach { it.patient = patientPseudonym } this.ecogStatus?.forEach { it.patient = patientPseudonym }
this.familyMemberDiagnoses.forEach { it.patient = patientPseudonym } this.familyMemberDiagnoses?.forEach { it.patient = patientPseudonym }
this.geneticCounsellingRequests.forEach { it.patient = patientPseudonym } this.geneticCounsellingRequests?.forEach { it.patient = patientPseudonym }
this.histologyReevaluationRequests.forEach { it.patient = patientPseudonym } this.histologyReevaluationRequests?.forEach { it.patient = patientPseudonym }
this.histologyReports.forEach { this.histologyReports?.forEach {
it.patient = patientPseudonym it.patient = patientPseudonym
it.tumorMorphology.patient = patientPseudonym it.tumorMorphology?.patient = patientPseudonym
} }
this.lastGuidelineTherapies.forEach { it.patient = patientPseudonym } this.lastGuidelineTherapies?.forEach { it.patient = patientPseudonym }
this.molecularPathologyFindings.forEach { it.patient = patientPseudonym } this.molecularPathologyFindings?.forEach { it.patient = patientPseudonym }
this.molecularTherapies.forEach { molecularTherapy -> molecularTherapy.history.forEach { it.patient = patientPseudonym } } this.molecularTherapies?.forEach { molecularTherapy ->
this.ngsReports.forEach { it.patient = patientPseudonym } molecularTherapy.history.forEach {
this.previousGuidelineTherapies.forEach { it.patient = patientPseudonym } it.patient = patientPseudonym
this.rebiopsyRequests.forEach { it.patient = patientPseudonym } }
this.recommendations.forEach { it.patient = patientPseudonym } }
this.recommendations.forEach { it.patient = patientPseudonym } this.ngsReports?.forEach { it.patient = patientPseudonym }
this.responses.forEach { it.patient = patientPseudonym } this.previousGuidelineTherapies?.forEach { it.patient = patientPseudonym }
this.studyInclusionRequests.forEach { it.patient = patientPseudonym } this.rebiopsyRequests?.forEach { it.patient = patientPseudonym }
this.specimens.forEach { it.patient = patientPseudonym } this.recommendations?.forEach { it.patient = patientPseudonym }
} this.responses?.forEach { it.patient = patientPseudonym }
this.studyInclusionRequests?.forEach { it.patient = patientPseudonym }
this.specimens?.forEach { it.patient = patientPseudonym }
}
/**
* Creates new hash of content IDs with given prefix except for patient IDs
*
* @param pseudonymizeService The pseudonymizeService to be used
* @return The MTB file containing rehashed content IDs
*/
infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) {
val prefix = pseudonymizeService.prefix()
fun anonymize(id: String): String {
val hash = DigestUtils.sha256Hex("$prefix-$id").substring(0, 41).lowercase()
return "$prefix$hash"
}
this.episode?.apply {
id = id?.let {
anonymize(it)
}
}
this.carePlans?.onEach { carePlan ->
carePlan?.apply {
id = id?.let { anonymize(it) }
diagnosis = diagnosis?.let { anonymize(it) }
geneticCounsellingRequest = geneticCounsellingRequest?.let { anonymize(it) }
rebiopsyRequests = rebiopsyRequests.map { it?.let { anonymize(it) } }
recommendations = recommendations.map { it?.let { anonymize(it) } }
studyInclusionRequests = studyInclusionRequests.map { it?.let { anonymize(it) } }
}
}
this.claims?.onEach { claim ->
claim?.apply {
id = id?.let { anonymize(it) }
therapy = therapy?.let { anonymize(it) }
}
}
this.claimResponses?.onEach { claimResponse ->
claimResponse?.apply {
id = id?.let { anonymize(it) }
claim = claim?.let { anonymize(it) }
}
}
this.consent?.apply {
id = id?.let { anonymize(it) }
}
this.diagnoses?.onEach { diagnosis ->
diagnosis?.apply {
id = id?.let { anonymize(it) }
histologyResults = histologyResults?.map { it?.let { anonymize(it) } }
}
}
this.ecogStatus?.onEach { ecogStatus ->
ecogStatus?.apply {
id = id?.let { anonymize(it) }
}
}
this.familyMemberDiagnoses?.onEach { familyMemberDiagnosis ->
familyMemberDiagnosis?.apply {
id = id?.let { anonymize(it) }
}
}
this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest ->
geneticCounsellingRequest?.apply {
id = id?.let { anonymize(it) }
}
}
this.histologyReevaluationRequests?.onEach { histologyReevaluationRequest ->
histologyReevaluationRequest?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
}
}
this.histologyReports?.onEach { histologyReport ->
histologyReport?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
tumorMorphology?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
}
tumorCellContent?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
}
}
}
this.lastGuidelineTherapies?.onEach { lastGuidelineTherapy ->
lastGuidelineTherapy?.apply {
id = id?.let { anonymize(it) }
diagnosis = diagnosis?.let { anonymize(it) }
}
}
this.molecularPathologyFindings?.onEach { molecularPathologyFinding ->
molecularPathologyFinding?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
}
}
this.molecularTherapies?.onEach { molecularTherapy ->
molecularTherapy?.apply {
history?.onEach { history ->
history?.apply {
id = id?.let { anonymize(it) }
basedOn = basedOn?.let { anonymize(it) }
}
}
}
}
this.ngsReports?.onEach { ngsReport ->
ngsReport?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
tumorCellContent?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
}
simpleVariants?.onEach { simpleVariant ->
simpleVariant?.apply {
id = id?.let { anonymize(it) }
}
}
}
}
this.previousGuidelineTherapies?.onEach { previousGuidelineTherapy ->
previousGuidelineTherapy?.apply {
id = id?.let { anonymize(it) }
diagnosis = diagnosis?.let { anonymize(it) }
medication.forEach { medication ->
medication?.apply {
id = id?.let { anonymize(it) }
}
}
}
}
this.rebiopsyRequests?.onEach { rebiopsyRequest ->
rebiopsyRequest?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
}
}
this.recommendations?.onEach { recommendation ->
recommendation?.apply {
id = id?.let { anonymize(it) }
diagnosis = diagnosis?.let { anonymize(it) }
ngsReport = ngsReport?.let { anonymize(it) }
}
}
this.responses?.onEach { response ->
response?.apply {
id = id?.let { anonymize(it) }
therapy = therapy?.let { anonymize(it) }
}
}
this.studyInclusionRequests?.onEach { studyInclusionRequest ->
studyInclusionRequest?.apply {
id = id?.let { anonymize(it) }
reason = reason?.let { anonymize(it) }
}
}
this.specimens?.onEach { specimen ->
specimen?.apply {
id = id?.let { anonymize(it) }
}
}
}
/** Replaces patient ID with generated patient pseudonym
*
* @since 0.11.0
*
* @param pseudonymizeService The pseudonymizeService to be used
* @return The MTB file containing patient pseudonymes
*/
infix fun Mtb.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value
this.episodesOfCare?.forEach { it.patient.id = patientPseudonym }
this.carePlans?.forEach {
it.patient.id = patientPseudonym
it.rebiopsyRequests?.forEach { it.patient.id = patientPseudonym }
it.histologyReevaluationRequests?.forEach { it.patient.id = patientPseudonym }
it.medicationRecommendations.forEach { it.patient.id = patientPseudonym }
it.studyEnrollmentRecommendations?.forEach { it.patient.id = patientPseudonym }
it.procedureRecommendations?.forEach { it.patient.id = patientPseudonym }
it.geneticCounselingRecommendation.patient.id = patientPseudonym
}
this.diagnoses?.forEach { it.patient.id = patientPseudonym }
this.guidelineTherapies?.forEach { it.patient.id = patientPseudonym }
this.guidelineProcedures?.forEach { it.patient.id = patientPseudonym }
this.patient.id = patientPseudonym
this.claims?.forEach { it.patient.id = patientPseudonym }
this.claimResponses?.forEach { it.patient.id = patientPseudonym }
this.diagnoses?.forEach { it.patient.id = patientPseudonym }
this.histologyReports?.forEach {
it.patient.id = patientPseudonym
it.results.tumorMorphology?.patient?.id = patientPseudonym
it.results.tumorCellContent?.patient?.id = patientPseudonym
}
this.ngsReports?.forEach {
it.patient.id = patientPseudonym
it.results.simpleVariants?.forEach { it.patient.id = patientPseudonym }
it.results.copyNumberVariants?.forEach { it.patient.id = patientPseudonym }
it.results.dnaFusions?.forEach { it.patient.id = patientPseudonym }
it.results.rnaFusions?.forEach { it.patient.id = patientPseudonym }
it.results.tumorCellContent?.patient?.id = patientPseudonym
it.results.brcaness?.patient?.id = patientPseudonym
it.results.tmb?.patient?.id = patientPseudonym
it.results.hrdScore?.patient?.id = patientPseudonym
}
this.ihcReports?.forEach {
it.patient.id = patientPseudonym
it.results.msiMmr?.forEach { it.patient.id = patientPseudonym }
it.results.proteinExpression?.forEach { it.patient.id = patientPseudonym }
}
this.responses?.forEach { it.patient.id = patientPseudonym }
this.specimens?.forEach { it.patient.id = patientPseudonym }
this.priorDiagnosticReports?.forEach { it.patient.id = patientPseudonym }
this.performanceStatus.forEach { it.patient.id = patientPseudonym }
this.systemicTherapies.forEach {
it.history?.forEach {
it.patient.id = patientPseudonym
}
}
}
/**
* Creates new hash of content IDs with given prefix except for patient IDs
*
* @since 0.11.0
*
* @param pseudonymizeService The pseudonymizeService to be used
* @return The MTB file containing rehashed content IDs
*/
infix fun Mtb.anonymizeContentWith(pseudonymizeService: PseudonymizeService) {
val prefix = pseudonymizeService.prefix()
fun anonymize(id: String): String {
val hash = DigestUtils.sha256Hex("$prefix-$id").substring(0, 41).lowercase()
return "$prefix$hash"
}
this.episodesOfCare?.forEach {
it?.apply {
id = id?.let {
anonymize(it)
}
}
}
// TODO all other properties
}

View File

@ -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>

View 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>
}

View File

@ -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()
}
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -21,13 +21,17 @@ package dev.dnpm.etl.processor.services
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.monitoring.Report import dev.dnpm.etl.processor.monitoring.Report
import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.*
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith
import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.apache.commons.codec.binary.Base32 import org.apache.commons.codec.binary.Base32
import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.codec.digest.DigestUtils
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
@ -42,32 +46,50 @@ class RequestProcessor(
private val sender: MtbFileSender, private val sender: MtbFileSender,
private val requestService: RequestService, private val requestService: RequestService,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val applicationEventPublisher: ApplicationEventPublisher private val applicationEventPublisher: ApplicationEventPublisher,
private val appConfigProperties: AppConfigProperties
) { ) {
fun processMtbFile(mtbFile: MtbFile) { fun processMtbFile(mtbFile: MtbFile) {
val requestId = UUID.randomUUID().toString() processMtbFile(mtbFile, randomRequestId())
val pid = mtbFile.patient.id }
fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) {
val pid = PatientId(mtbFile.patient.id)
mtbFile pseudonymizeWith pseudonymizeService mtbFile pseudonymizeWith pseudonymizeService
mtbFile anonymizeContentWith pseudonymizeService
val request = BwhcV1MtbFileRequest(requestId, transformationService.transform(mtbFile))
saveAndSend(request, pid)
}
val request = MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile)) fun processMtbFile(mtbFile: Mtb) {
processMtbFile(mtbFile, randomRequestId())
}
fun processMtbFile(mtbFile: Mtb, requestId: RequestId) {
val pid = PatientId(mtbFile.patient.id)
mtbFile pseudonymizeWith pseudonymizeService
mtbFile anonymizeContentWith pseudonymizeService
val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile))
saveAndSend(request, pid)
}
private fun <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) {
requestService.save( requestService.save(
Request( Request(
uuid = requestId, request.requestId,
patientId = request.mtbFile.patient.id, request.patientPseudonym(),
pid = pid, pid,
fingerprint = fingerprint(request.mtbFile), fingerprint(request),
status = RequestStatus.UNKNOWN, RequestType.MTB_FILE,
type = RequestType.MTB_FILE RequestStatus.UNKNOWN
) )
) )
if (isDuplication(mtbFile)) { if (appConfigProperties.duplicationDetection && isDuplication(request)) {
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
ResponseEvent( ResponseEvent(
requestId, request.requestId,
Instant.now(), Instant.now(),
RequestStatus.DUPLICATION RequestStatus.DUPLICATION
) )
@ -79,45 +101,52 @@ class RequestProcessor(
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
ResponseEvent( ResponseEvent(
requestId, request.requestId,
Instant.now(), Instant.now(),
responseStatus.status, responseStatus.status,
when (responseStatus.status) { when (responseStatus.status) {
RequestStatus.WARNING -> Optional.of(responseStatus.body) RequestStatus.ERROR, RequestStatus.WARNING -> Optional.of(responseStatus.body)
else -> Optional.empty() else -> Optional.empty()
} }
) )
) )
} }
private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean { private fun <T> isDuplication(pseudonymizedMtbFileRequest: MtbFileRequest<T>): Boolean {
val patientPseudonym = when (pseudonymizedMtbFileRequest) {
is BwhcV1MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
is DnpmV2MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
}
val lastMtbFileRequestForPatient = val lastMtbFileRequestForPatient =
requestService.lastMtbFileRequestForPatientPseudonym(pseudonymizedMtbFile.patient.id) requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym)
val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(pseudonymizedMtbFile.patient.id) val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym)
return null != lastMtbFileRequestForPatient return null != lastMtbFileRequestForPatient
&& !isLastRequestDeletion && !isLastRequestDeletion
&& lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile) && lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFileRequest)
} }
fun processDeletion(patientId: String) { fun processDeletion(patientId: PatientId) {
val requestId = UUID.randomUUID().toString() processDeletion(patientId, randomRequestId())
}
fun processDeletion(patientId: PatientId, requestId: RequestId) {
try { try {
val patientPseudonym = pseudonymizeService.patientPseudonym(patientId) val patientPseudonym = pseudonymizeService.patientPseudonym(patientId)
requestService.save( requestService.save(
Request( Request(
uuid = requestId, requestId,
patientId = patientPseudonym, patientPseudonym,
pid = patientId, patientId,
fingerprint = fingerprint(patientPseudonym), fingerprint(patientPseudonym.value),
status = RequestStatus.UNKNOWN, RequestType.DELETE,
type = RequestType.DELETE RequestStatus.UNKNOWN
) )
) )
val responseStatus = sender.send(MtbFileSender.DeleteRequest(requestId, patientPseudonym)) val responseStatus = sender.send(DeleteRequest(requestId, patientPseudonym))
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
ResponseEvent( ResponseEvent(
@ -135,9 +164,9 @@ class RequestProcessor(
requestService.save( requestService.save(
Request( Request(
uuid = requestId, uuid = requestId,
patientId = "???", patientPseudonym = emptyPatientPseudonym(),
pid = patientId, pid = patientId,
fingerprint = "", fingerprint = Fingerprint.empty(),
status = RequestStatus.ERROR, status = RequestStatus.ERROR,
type = RequestType.DELETE, type = RequestType.DELETE,
report = Report("Fehler bei der Pseudonymisierung") report = Report("Fehler bei der Pseudonymisierung")
@ -146,14 +175,19 @@ class RequestProcessor(
} }
} }
private fun fingerprint(mtbFile: MtbFile): String { private fun <T> fingerprint(request: MtbFileRequest<T>): Fingerprint {
return fingerprint(objectMapper.writeValueAsString(mtbFile)) return when (request) {
is BwhcV1MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content))
is DnpmV2MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content))
}
} }
private fun fingerprint(s: String): String { private fun fingerprint(s: String): Fingerprint {
return Base32().encodeAsString(DigestUtils.sha256(s)) return Fingerprint(
.replace("=", "") Base32().encodeAsString(DigestUtils.sha256(s))
.lowercase() .replace("=", "")
.lowercase()
)
} }
} }

View File

@ -19,11 +19,13 @@
package dev.dnpm.etl.processor.services package dev.dnpm.etl.processor.services
import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.*
import dev.dnpm.etl.processor.monitoring.RequestType import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.*
@Service @Service
class RequestService( class RequestService(
@ -32,15 +34,32 @@ class RequestService(
fun save(request: Request) = requestRepository.save(request) fun save(request: Request) = requestRepository.save(request)
fun allRequestsByPatientPseudonym(patientPseudonym: String) = requestRepository fun findAll(): Iterable<Request> = requestRepository.findAll()
.findAllByPatientIdOrderByProcessedAtDesc(patientPseudonym)
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)) Companion.lastMtbFileRequestForPatientPseudonym(allRequestsByPatientPseudonym(patientPseudonym))
fun isLastRequestWithKnownStatusDeletion(patientPseudonym: String) = fun isLastRequestWithKnownStatusDeletion(patientPseudonym: PatientPseudonym) =
Companion.isLastRequestWithKnownStatusDeletion(allRequestsByPatientPseudonym(patientPseudonym)) Companion.isLastRequestWithKnownStatusDeletion(allRequestsByPatientPseudonym(patientPseudonym))
fun countStates(): Iterable<CountedState> = requestRepository.countStates()
fun countDeleteStates(): Iterable<CountedState> = requestRepository.countDeleteStates()
fun findPatientUniqueStates(): List<CountedState> = requestRepository.findPatientUniqueStates()
fun findPatientUniqueDeleteStates(): List<CountedState> = requestRepository.findPatientUniqueDeleteStates()
companion object { companion object {
fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests

View File

@ -19,8 +19,8 @@
package dev.dnpm.etl.processor.services package dev.dnpm.etl.processor.services
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.Report import dev.dnpm.etl.processor.monitoring.Report
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
@ -31,7 +31,7 @@ import java.util.*
@Service @Service
class ResponseProcessor( class ResponseProcessor(
private val requestRepository: RequestRepository, private val requestService: RequestService,
private val statisticsUpdateProducer: Sinks.Many<Any> private val statisticsUpdateProducer: Sinks.Many<Any>
) { ) {
@ -39,7 +39,7 @@ class ResponseProcessor(
@EventListener(classes = [ResponseEvent::class]) @EventListener(classes = [ResponseEvent::class])
fun handleResponseEvent(event: ResponseEvent) { fun handleResponseEvent(event: ResponseEvent) {
requestRepository.findByUuidEquals(event.requestUuid).ifPresentOrElse({ requestService.findByUuid(event.requestUuid).ifPresentOrElse({
it.processedAt = event.timestamp it.processedAt = event.timestamp
it.status = event.status it.status = event.status
@ -76,7 +76,7 @@ class ResponseProcessor(
} }
} }
requestRepository.save(it) requestService.save(it)
statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST) statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST)
}, { }, {
@ -87,7 +87,7 @@ class ResponseProcessor(
} }
data class ResponseEvent( data class ResponseEvent(
val requestUuid: String, val requestUuid: RequestId,
val timestamp: Instant, val timestamp: Instant,
val status: RequestStatus, val status: RequestStatus,
val body: Optional<String> = Optional.empty() val body: Optional<String> = Optional.empty()

View File

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

View File

@ -22,6 +22,7 @@ package dev.dnpm.etl.processor.services.kafka
import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.output.asRequestStatus import dev.dnpm.etl.processor.output.asRequestStatus
import dev.dnpm.etl.processor.services.ResponseEvent import dev.dnpm.etl.processor.services.ResponseEvent
@ -47,7 +48,7 @@ class KafkaResponseProcessor(
Optional.empty() Optional.empty()
}.ifPresentOrElse({ responseBody -> }.ifPresentOrElse({ responseBody ->
val event = ResponseEvent( val event = ResponseEvent(
responseBody.requestId, RequestId(responseBody.requestId),
Instant.ofEpochMilli(data.timestamp()), Instant.ofEpochMilli(data.timestamp()),
responseBody.statusCode.asRequestStatus(), responseBody.statusCode.asRequestStatus(),
when (responseBody.statusCode.asRequestStatus()) { when (responseBody.statusCode.asRequestStatus()) {

View 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"
}

View File

@ -0,0 +1,201 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
import dev.dnpm.etl.processor.monitoring.OutputConnectionCheckService
import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.security.Role
import dev.dnpm.etl.processor.security.UserRole
import dev.dnpm.etl.processor.security.Token
import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.services.TransformationService
import dev.dnpm.etl.processor.security.UserRoleService
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType
import org.springframework.http.codec.ServerSentEvent
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
@Controller
@RequestMapping(path = ["configs"])
class ConfigController(
@Qualifier("connectionCheckUpdateProducer")
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>,
private val transformationService: TransformationService,
private val pseudonymGenerator: Generator,
private val mtbFileSender: MtbFileSender,
private val connectionCheckServices: List<ConnectionCheckService>,
private val tokenService: TokenService?,
private val userRoleService: UserRoleService?
) {
@GetMapping
fun index(model: Model): String {
val outputConnectionAvailable =
connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().firstOrNull()?.connectionAvailable()
val gPasConnectionAvailable =
connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable()
model.addAttribute("pseudonymGenerator", pseudonymGenerator.javaClass.simpleName)
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
model.addAttribute("outputConnectionAvailable", outputConnectionAvailable)
model.addAttribute("gPasConnectionAvailable", gPasConnectionAvailable)
model.addAttribute("tokensEnabled", tokenService != null)
if (tokenService != null) {
model.addAttribute("tokens", tokenService.findAll())
} else {
model.addAttribute("tokens", emptyList<Token>())
}
model.addAttribute("transformations", transformationService.getTransformations())
if (userRoleService != null) {
model.addAttribute("userRolesEnabled", true)
model.addAttribute("userRoles", userRoleService.findAll())
} else {
model.addAttribute("userRolesEnabled", false)
model.addAttribute("userRoles", emptyList<UserRole>())
}
return "configs"
}
@GetMapping(params = ["outputConnectionAvailable"])
fun outputConnectionAvailable(model: Model): String {
val outputConnectionAvailable =
connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().first().connectionAvailable()
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
model.addAttribute("outputConnectionAvailable", outputConnectionAvailable)
if (tokenService != null) {
model.addAttribute("tokensEnabled", true)
model.addAttribute("tokens", tokenService.findAll())
} else {
model.addAttribute("tokens", listOf<Token>())
}
return "configs/outputConnectionAvailable"
}
@GetMapping(params = ["gPasConnectionAvailable"])
fun gPasConnectionAvailable(model: Model): String {
val gPasConnectionAvailable =
connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable()
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
model.addAttribute("gPasConnectionAvailable", gPasConnectionAvailable)
if (tokenService != null) {
model.addAttribute("tokensEnabled", true)
model.addAttribute("tokens", tokenService.findAll())
} else {
model.addAttribute("tokens", listOf<Token>())
}
return "configs/gPasConnectionAvailable"
}
@PostMapping(path = ["tokens"])
fun addToken(@ModelAttribute("name") name: String, model: Model): String {
if (tokenService == null) {
model.addAttribute("tokensEnabled", false)
model.addAttribute("success", false)
} else {
model.addAttribute("tokensEnabled", true)
val result = tokenService.addToken(name)
result.onSuccess {
model.addAttribute("newTokenValue", it)
model.addAttribute("success", true)
}
result.onFailure {
model.addAttribute("success", false)
}
model.addAttribute("tokens", tokenService.findAll())
}
return "configs/tokens"
}
@DeleteMapping(path = ["tokens/{id}"])
fun deleteToken(@PathVariable id: Long, model: Model): String {
if (tokenService != null) {
tokenService.deleteToken(id)
model.addAttribute("tokensEnabled", true)
model.addAttribute("tokens", tokenService.findAll())
} else {
model.addAttribute("tokensEnabled", false)
model.addAttribute("tokens", listOf<Token>())
}
return "configs/tokens"
}
@DeleteMapping(path = ["userroles/{id}"])
fun deleteUserRole(@PathVariable id: Long, model: Model): String {
if (userRoleService != null) {
userRoleService.deleteUserRole(id)
model.addAttribute("userRolesEnabled", true)
model.addAttribute("userRoles", userRoleService.findAll())
} else {
model.addAttribute("userRolesEnabled", false)
model.addAttribute("userRoles", emptyList<UserRole>())
}
return "configs/userroles"
}
@PutMapping(path = ["userroles/{id}"])
fun updateUserRole(@PathVariable id: Long, @ModelAttribute("role") role: Role, model: Model): String {
if (userRoleService != null) {
userRoleService.updateUserRole(id, role)
model.addAttribute("userRolesEnabled", true)
model.addAttribute("userRoles", userRoleService.findAll())
} else {
model.addAttribute("userRolesEnabled", false)
model.addAttribute("userRoles", emptyList<UserRole>())
}
return "configs/userroles"
}
@GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
@ResponseBody
fun events(): Flux<ServerSentEvent<Any>> {
return connectionCheckUpdateProducer.asFlux().map {
val event = when (it) {
is ConnectionCheckResult.KafkaConnectionCheckResult -> "output-connection-check"
is ConnectionCheckResult.RestConnectionCheckResult -> "output-connection-check"
is ConnectionCheckResult.GPasConnectionCheckResult -> "gpas-connection-check"
}
ServerSentEvent.builder<Any>()
.event(event).id("none").data(it)
.build()
}
}
}

View File

@ -20,9 +20,13 @@
package dev.dnpm.etl.processor.web package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.NotFoundException import dev.dnpm.etl.processor.NotFoundException
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.monitoring.RequestId import dev.dnpm.etl.processor.services.RequestService
import dev.dnpm.etl.processor.monitoring.RequestRepository 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.stereotype.Controller
import org.springframework.ui.Model import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
@ -32,13 +36,29 @@ import org.springframework.web.bind.annotation.RequestMapping
@Controller @Controller
@RequestMapping(path = ["/"]) @RequestMapping(path = ["/"])
class HomeController( class HomeController(
private val requestRepository: RequestRepository, private val requestService: RequestService,
private val reportService: ReportService private val reportService: ReportService
) { ) {
@GetMapping @GetMapping
fun index(model: Model): String { fun index(
val requests = requestRepository.findAll().sortedByDescending { it.processedAt }.take(25) @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) model.addAttribute("requests", requests)
return "index" return "index"
@ -46,7 +66,7 @@ class HomeController(
@GetMapping(path = ["/report/{id}"]) @GetMapping(path = ["/report/{id}"])
fun report(@PathVariable id: RequestId, model: Model): String { fun report(@PathVariable id: RequestId, model: Model): String {
val request = requestRepository.findByUuidEquals(id.toString()).orElse(null) ?: throw NotFoundException() val request = requestService.findByUuid(id).orElse(null) ?: throw NotFoundException()
model.addAttribute("request", request) model.addAttribute("request", request)
model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport)) model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport))

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -19,23 +19,29 @@
package dev.dnpm.etl.processor.web 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.stereotype.Controller
import org.springframework.ui.Model import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@Controller @Controller
@RequestMapping(path = ["transformations"]) class LoginController(
class TransformationController( private val securityConfigProperties: SecurityConfigProperties?,
private val transformationService: TransformationService private val oAuth2ClientProperties: OAuth2ClientProperties?
) { ) {
@GetMapping @GetMapping(path = ["/login"])
fun index(model: Model): String { fun login(model: Model): String {
model.addAttribute("transformations", transformationService.getTransformations()) if (securityConfigProperties?.enableOidc == true) {
model.addAttribute(
return "transformations" "oidcLogins",
oAuth2ClientProperties?.registration?.map { (key, value) -> Pair(key, value.clientName) }.orEmpty()
)
} else {
model.addAttribute("oidcLogins", emptyList<Pair<String, String>>())
}
return "login"
} }
} }

View File

@ -19,9 +19,10 @@
package dev.dnpm.etl.processor.web package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.services.RequestService
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.codec.ServerSentEvent import org.springframework.http.codec.ServerSentEvent
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
@ -38,16 +39,17 @@ import java.time.temporal.ChronoUnit
@RestController @RestController
@RequestMapping(path = ["/statistics"]) @RequestMapping(path = ["/statistics"])
class StatisticsRestController( class StatisticsRestController(
@Qualifier("statisticsUpdateProducer")
private val statisticsUpdateProducer: Sinks.Many<Any>, private val statisticsUpdateProducer: Sinks.Many<Any>,
private val requestRepository: RequestRepository private val requestService: RequestService
) { ) {
@GetMapping(path = ["requeststates"]) @GetMapping(path = ["requeststates"])
fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> { fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
val states = if (delete) { val states = if (delete) {
requestRepository.countDeleteStates() requestService.countDeleteStates()
} else { } else {
requestRepository.countStates() requestService.countStates()
} }
return states return states
@ -77,7 +79,7 @@ class StatisticsRestController(
} }
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin")) val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin"))
val data = requestRepository.findAll() val data = requestService.findAll()
.filter { it.type == requestType } .filter { it.type == requestType }
.filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) } .filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) }
.groupBy { formatter.format(it.processedAt) } .groupBy { formatter.format(it.processedAt) }
@ -113,9 +115,9 @@ class StatisticsRestController(
@GetMapping(path = ["requestpatientstates"]) @GetMapping(path = ["requestpatientstates"])
fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> { fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
val states = if (delete) { val states = if (delete) {
requestRepository.findPatientUniqueDeleteStates() requestService.findPatientUniqueDeleteStates()
} else { } else {
requestRepository.findPatientUniqueStates() requestService.findPatientUniqueStates()
} }
return states.map { return states.map {
@ -152,6 +154,10 @@ class StatisticsRestController(
.build(), .build(),
ServerSentEvent.builder<Any>() ServerSentEvent.builder<Any>()
.event("deleterequestpatientstates").id("none").data(this.requestPatientStates(true)) .event("deleterequestpatientstates").id("none").data(this.requestPatientStates(true))
.build(),
ServerSentEvent.builder<Any>()
.event("newrequest").id("none").data("newrequest")
.build() .build()
) )
) )

View File

@ -3,14 +3,34 @@ spring:
compose: compose:
file: ./dev-compose.yml file: ./dev-compose.yml
security:
oauth2:
client:
registration:
custom:
client-name: App-Dev
client-id: app-dev
client-secret: very-secret-ae3f7a-5a9f-1190
scope:
- openid
provider:
custom:
issuer-uri: https://dnpm.dev/auth/realms/intern
user-name-attribute: name
app: app:
#rest: rest:
# uri: http://localhost:9000/bwhc/etl/api uri: http://localhost:9000/bwhc/etl/api
kafka: #kafka:
topic: test # topic: test
response-topic: test_response # response-topic: test_response
servers: localhost:9094 # servers: localhost:9094
security:
admin-user: admin
admin-password: "{noop}very-secret"
enable-oidc: "true"
server: server:
port: 8000 port: 8000

View File

@ -4,4 +4,18 @@ spring:
consumer: consumer:
group-id: ${app.kafka.group-id} group-id: ${app.kafka.group-id}
flyway: flyway:
locations: "classpath:db/migration/{vendor}" locations: "classpath:db/migration/{vendor}"
web:
resources:
cache:
cachecontrol:
max-age: 1d
chain:
strategy:
content:
enabled: true
paths: /**/*.js,/**/*.css,/**/*.svg,/**/*.jpeg
server:
forward-headers-strategy: framework

View File

@ -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
);

View File

@ -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
);

View File

@ -0,0 +1 @@
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;

View File

@ -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)
);

View File

@ -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)
);

View File

@ -0,0 +1 @@
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -4,7 +4,7 @@ const dateFormat = new Intl.DateTimeFormat('de-DE', dateFormatOptions);
const dateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: 'numeric', second: 'numeric' }; const dateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: 'numeric', second: 'numeric' };
const dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions); const dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions);
window.onload = () => { const formatTimeElements = () => {
Array.from(document.getElementsByTagName('time')).forEach((timeTag) => { Array.from(document.getElementsByTagName('time')).forEach((timeTag) => {
let date = Date.parse(timeTag.getAttribute('datetime')); let date = Date.parse(timeTag.getAttribute('datetime'));
if (! isNaN(date)) { if (! isNaN(date)) {
@ -13,6 +13,9 @@ window.onload = () => {
}); });
}; };
window.addEventListener('load', formatTimeElements);
window.addEventListener('htmx:afterRequest', formatTimeElements);
function drawPieChart(url, elemId, title, data) { function drawPieChart(url, elemId, title, data) {
if (data) { if (data) {
update(elemId, data); update(elemId, data);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,5 +1,8 @@
:root { :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: rgb(0, 74, 157);
--bg-blue-op: rgba(0, 74, 157, .35); --bg-blue-op: rgba(0, 74, 157, .35);
@ -19,47 +22,135 @@
--bg-gray-op: rgba(112, 128, 144, .35); --bg-gray-op: rgba(112, 128, 144, .35);
} }
body { * {
margin: 0;
font-family: sans-serif; 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; font-size: .8rem;
color: #333; color: var(--text);
min-height: 100vh;
background: url(bg.jpeg) no-repeat;
background-size: contain;
} }
nav { nav {
margin: 0 auto; margin: 0 auto;
background: #d5dad5; padding: 1em 0;
height: 3rem;
line-height: 1.5rem;
max-width: 1140px; max-width: 1140px;
border-bottom: 1px solid var(--table-border);
} }
nav a { nav a.nav-home {
color: #004a8f; float: left;
text-transform: uppercase;
color: var(--text);
line-height: 1.5em;
text-decoration: none; text-decoration: none;
line-height: 2rem;
font-weight: 700; font-size: 2em;
font-weight: bold;
} }
nav a:hover { nav a.nav-home > img {
text-decoration: underline; width: 1.5em;
vertical-align: middle;
} }
nav > ul { nav > ul {
margin: 0 3rem; margin: 0 0 0 auto;
padding: 0; padding: 0;
width: max-content;
} }
nav > ul > li { nav > ul > li {
background: #fbfbfb; display: inline-block;
display: block; padding: 0 1rem;
float: left;
padding: 2px 1rem;
border-left: 1px solid #d5dad5;
} }
nav > ul > li:first-of-type { nav > ul > li.login {
border-left: none; 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 { .breadcrumps {
@ -85,7 +176,7 @@ nav > ul > li:first-of-type {
} }
.breadcrumps ul li a { .breadcrumps ul li a {
color: #333333; color: var(--text);
text-decoration: none; text-decoration: none;
} }
@ -98,6 +189,10 @@ main {
max-width: 1140px; max-width: 1140px;
} }
section {
margin: 3em 0;
}
form { form {
margin: 1rem 0; margin: 1rem 0;
padding: 1rem; padding: 1rem;
@ -139,16 +234,139 @@ form.samplecode-input input:focus-visible {
background: lightgreen; background: lightgreen;
} }
table { .login-form {
border-top: 1px solid var(--table-border); width: fit-content;
border-left: 1px solid var(--table-border); margin: 3em auto;
border-spacing: 0; padding: 2em 5em;
border-radius: 3px;
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%; min-width: 100%;
font-family: sans-serif; font-family: sans-serif;
} }
table.config-table td:first-child {
width: 24em;
min-width: fit-content;
}
table.config-table td > button:last-of-type {
float: right;
}
.border > table {
padding: 0;
border: none;
background: transparent;
}
.page-control {
border-radius: .5em;
padding: 1em 2em;
text-align: center;
line-height: 1.75em;
}
.page-control a {
padding: 0 .25em;
font-size: 1.75em;
color: var(--bg-gray);
text-decoration: none;
}
.page-control a[href] {
color: var(--bg-blue);
}
.page-control span {
padding: 0 .5em;
vertical-align: text-bottom;
}
#samples-table.max { #samples-table.max {
width: 100vw; width: 100vw;
position: fixed; position: fixed;
@ -165,64 +383,78 @@ table.samples {
display: block; display: block;
} }
th { th, td {
background: #eee;
}
td, th {
padding: 0.4rem .2rem; padding: 0.4rem .2rem;
border-right: 1px solid var(--table-border); line-height: 2em;
border-bottom: 1px solid var(--table-border);
text-align: left; text-align: left;
white-space: nowrap; white-space: nowrap;
vertical-align: top; vertical-align: top;
} }
th {
border-bottom: 1px solid var(--bg-gray);
}
td { td {
font-family: monospace; font-family: monospace;
border-bottom: 1px solid var(--bg-gray-op);
} }
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); background: var(--bg-blue);
color: white; color: white;
border-radius: 0.4em;
} }
tr:has(td.bg-blue) { td.bg-green > small, th.bg-green > small {
background: var(--bg-blue-op);
}
td.bg-green, th.bg-green {
background: var(--bg-green); background: var(--bg-green);
color: white; color: white;
border-radius: 0.4em;
} }
tr:has(td.bg-green) { td.bg-yellow > small, th.bg-yellow > small {
background: var(--bg-green-op);
}
td.bg-yellow, th.bg-yellow {
background: var(--bg-yellow); background: var(--bg-yellow);
color: white; color: white;
border-radius: 0.4em;
} }
tr:has(td.bg-yellow) { td.bg-red > small, th.bg-red > small {
background: var(--bg-yellow-op);
}
td.bg-red, th.bg-red {
background: var(--bg-red); background: var(--bg-red);
color: white; color: white;
border-radius: 0.4em;
} }
tr:has(td.bg-red) { td.bg-gray > small, th.bg-gray > small {
background: var(--bg-red-op);
}
td.bg-gray, th.bg-gray {
background: var(--bg-gray); background: var(--bg-gray);
color: white; color: white;
border-radius: 0.4em;
} }
.bg-path { .bg-path {
@ -261,7 +493,6 @@ td.clipboard.clipped {
padding: 4px 8px; padding: 4px 8px;
line-height: 1.2rem; line-height: 1.2rem;
vertical-align: middle;
border: 0 solid transparent; border: 0 solid transparent;
border-radius: 3px; border-radius: 3px;
@ -273,38 +504,38 @@ td.clipboard.clipped {
cursor: pointer; cursor: pointer;
} }
.btn:active,
.btn:hover { .btn:hover {
filter: drop-shadow(1px 2px 2px gray); filter: drop-shadow(0px 1px 1px var(--bg-gray)) var(--dark);
} }
.btn:active { .btn:active {
filter: drop-shadow(1px 1px 2px gray);
translate: 0 1px; translate: 0 1px;
} }
.btn.btn-red { .btn.btn-red {
background: red; background: var(--bg-red);
color: white; color: white;
} }
.btn.btn-red:hover, .btn.btn-red:active {
background: darkred !important;
}
.btn.btn-blue { .btn.btn-blue {
background: slategray; background: var(--bg-blue);
color: white; color: white;
} }
.btn.btn-blue:hover, .btn.btn-blue:active {
background: darkslategray !important;
}
.btn.btn-delete:before { .btn.btn-delete:before {
content: '\1F5D1'; content: '\1F5D1';
padding: .2rem; padding: .2rem;
} }
button:disabled,
.btn:disabled {
background: slategray !important;
color: lightgray;
filter: none;
cursor: default;
}
input.inline { input.inline {
border: none; border: none;
font-size: 1.1rem; font-size: 1.1rem;
@ -340,19 +571,140 @@ input.inline:focus-visible {
font-weight: bold; font-weight: bold;
} }
.chart { .charts {
padding: 1rem; display: grid;
margin: .2rem; 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; border-radius: 3px;
text-align: center;
}
width: calc(100% - 2.4rem - 4px); .notification.info {
height: 320px; 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; display: inline-block;
} }
.chart-50pc { .no-token {
width: calc(50% - 2.4rem - 4px); padding: 1em;
background: var(--bg-red-op);
}
.issue-message {
font-family: monospace;
font-weight: bolder;
}
.issue-path {
font-family: monospace;
line-height: 1rem;
} }

View 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

View File

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ETL-Prozessor</title>
<link rel="stylesheet" th:href="@{/style.css}" />
</head>
<body>
<div th:replace="~{fragments.html :: nav}"></div>
<main>
<h1>Konfiguration</h1>
<div class="tabs">
<button class="tab active" onclick="selectTab(this, 'common');">Allgemeine Informationen</button>
<button class="tab" onclick="selectTab(this, 'security');">Sicherheit</button>
<button class="tab" onclick="selectTab(this, 'transformation');">Transformationen</button>
</div>
<div id="common" class="tabcontent active">
<section>
<h2>🔧 Allgemeine Konfiguration</h2>
<table class="config-table">
<thead>
<tr>
<th>Name</th>
<th>Wert</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pseudonym erzeugt über</td>
<td>[[ ${pseudonymGenerator} ]]</td>
</tr>
<tr>
<td>MTBFile-Sender</td>
<td>[[ ${mtbFileSender} ]]</td>
</tr>
<tr>
<td th:if="${mtbFileSender.startsWith('Rest')}">REST-Endpunkt</td>
<td th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker und Topics</td>
<td>[[ ${mtbFileEndpoint} ]]</td>
</tr>
</tbody>
</table>
</section>
<section hx-ext="sse" th:sse-connect="@{/configs/events}">
<div th:insert="~{configs/gPasConnectionAvailable.html}" th:hx-get="@{/configs?gPasConnectionAvailable}" hx-trigger="sse:gpas-connection-check">
</div>
</section>
<section hx-ext="sse" th:sse-connect="@{/configs/events}">
<div th:insert="~{configs/outputConnectionAvailable.html}" th:hx-get="@{/configs?outputConnectionAvailable}" hx-trigger="sse:output-connection-check">
</div>
</section>
</div>
<div id="security" class="tabcontent">
<section th:insert="~{configs/tokens.html}">
</section>
<section th:insert="~{configs/userroles.html}">
</section>
</div>
<div id="transformation" class="tabcontent">
<section>
<h2><span th:if="${not transformations.isEmpty()}"></span><span th:if="${transformations.isEmpty()}"></span> Transformationen</h2>
<h3>Syntax</h3>
Hier einige Beispiele zum Syntax des JSON-Path
<ul>
<li style="padding: 0.6rem 0;"><span class="bg-path">diagnoses[*].icdO3T.version</span>: Ersetze die ICD-O3T-Version in allen Diagnosen, z.B. zur Version der deutschen Übersetzung</li>
<li style="padding: 0.6rem 0;"><span class="bg-path">patient.gender</span>: Ersetze das Geschlecht des Patienten, z.B. in das von bwHC verlangte Format</li>
</ul>
<h3>Konfigurierte Transformationen</h3>
<th:block th:if="${transformations.isEmpty()}">
<p>
Keine konfigurierten Transformationen.
</p>
</th:block>
<th:block th:if="${not transformations.isEmpty()}">
<p>
Hier sehen Sie eine Übersicht der konfigurierten Transformationen.
</p>
<table class="config-table">
<thead>
<tr>
<th>JSON-Path</th>
<th>Transformation von &rArr; 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>&rArr;</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>

View File

@ -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>
&nbsp;|&nbsp;
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>

View File

@ -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>
&nbsp;|&nbsp;
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>

View File

@ -0,0 +1,40 @@
<div th:if="${not tokensEnabled}">
<h2><span></span> Tokens</h2>
<p>Die Verwendung von Tokens ist nicht aktiviert.</p>
</div>
<div id="tokens" th:if="${tokensEnabled}">
<h2><span></span> Tokens</h2>
<div class="border">
<div th:if="${tokens.isEmpty()}">Noch keine Tokens vorhanden.</div>
<table th:if="${not tokens.isEmpty()}" class="config-table">
<thead>
<tr>
<th>Name</th>
<th>Erstellt</th>
</tr>
</thead>
<tbody>
<tr th:each="token : ${tokens}">
<td>[[ ${token.name} ]]</td>
<td>
<time th:datetime="${token.createdAt}">[[ ${token.createdAt} ]]</time>
<button class="btn btn-red" th:hx-delete="@{/configs/tokens/{id}(id=${token.id})}" hx-target="#tokens">Löschen</button>
</td>
</tr>
</tbody>
</table>
<div th:if="${newTokenValue != null and success}" class="new-token">
Verwendung über HTTP-Basic. Bitte notieren, wird nicht erneut angezeigt: <pre>[[ ${newTokenValue} ]]</pre>
</div>
<div th:if="${success != null and not success}" class="no-token">
Das Token konnte nicht erzeugt werden. Versuchen Sie einen anderen Namen.
</div>
<div class="token-form">
<form th:hx-post="@{/configs/tokens}" hx-target="#tokens">
<input placeholder="Token-Name" name="name" required />
<button class="btn">Token Erstellen</button>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,37 @@
<div th:if="${not userRolesEnabled}">
<h2><span></span> Benutzerberechtigungen</h2>
<p>Die Verwendung von rollenbasierten Benutzerberechtigungen ist nicht aktiviert.</p>
</div>
<div id="userroles" th:if="${userRolesEnabled}">
<h2><span></span> Benutzerberechtigungen</h2>
<div class="border">
<div th:if="${userRoles.isEmpty()}">Noch keine Benutzerberechtigungen vorhanden.</div>
<table th:if="${not userRoles.isEmpty()}" class="config-table">
<thead>
<tr>
<th>Benutzername</th>
<th>Rolle</th>
</tr>
</thead>
<tbody>
<tr th:each="userRole : ${userRoles}">
<td>[[ ${userRole.username} ]]</td>
<td>
<div class="userrole-form">
<form th:hx-put="@{/configs/userroles/{id}(id=${userRole.id})}" hx-target="#userroles">
<select name="role" th:disabled="${#authorization.authentication.getName() == userRole.username}">
<option th:selected="${userRole.role.value == 'guest'}" value="GUEST">Gast</option>
<option th:selected="${userRole.role.value == 'user'}" value="USER">Benutzer</option>
<option th:selected="${userRole.role.value == 'admin'}" value="ADMIN">Administrator</option>
</select>
<button class="btn btn-blue" th:disabled="${#authorization.authentication.getName() == userRole.username}">Übernehmen</button>
</form>
</div>
<button class="btn btn-red" th:hx-delete="@{/configs/userroles/{id}(id=${userRole.id})}" hx-target="#userroles" th:disabled="${#authorization.authentication.getName() == userRole.username}">Löschen</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"> <html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="stylesheet" th:href="@{/style.css}" /> <link rel="stylesheet" th:href="@{/style.css}" />
@ -7,10 +7,33 @@
<body> <body>
<div th:fragment="nav"> <div th:fragment="nav">
<nav> <nav>
<span>
<a class="nav-home" th:href="@{/}">
<img th:src="@{/icon.svg}" alt="Icon" />
<span>ETL-Processor</span>
</a>
</span>
<ul> <ul>
<li><a th:href="@{/}">Übersicht</a></li> <li><a th:href="@{/}">Übersicht</a></li>
<li><a th:href="@{/statistics}">Statistiken</a></li> <li><a th:href="@{/statistics}">Statistiken</a></li>
<li><a th:href="@{/transformations}">Transformationen</a></li> <li sec:authorize="hasRole('ADMIN')">
<a th:href="@{/configs}">Konfiguration</a>
</li>
<li class="login" sec:authorize="not isAuthenticated()">
<a class="btn btn-blue" th:href="@{/login}">Login</a>
</li>
<li class="login" sec:authorize="isAuthenticated()">
<span>
<div class="user-icon">
<img th:src="@{/user.svg}" alt="User-Image">
<span sec:authorize="hasRole('ADMIN')" class="user-role admin">Admin</span>
<span sec:authorize="hasRole('USER')" class="user-role user">User</span>
<span sec:authorize="hasRole('GUEST')" class="user-role guest">Guest</span>
</div>
<span class="user-name" sec:authentication="name">?</span>
</span>
<a class="btn btn-red" th:href="@{/logout}">Abmelden</a>
</li>
</ul> </ul>
</nav> </nav>
</div> </div>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org"> <html lang="de" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>ETL-Prozessor</title> <title>ETL-Prozessor</title>
@ -9,37 +9,95 @@
<div th:replace="~{fragments.html :: nav}"></div> <div th:replace="~{fragments.html :: nav}"></div>
<main> <main>
<h1>Letzte Anfragen</h1> <h1>Alle Anfragen<a id="reload-notify" class="reload" title="Neue Anfragen" th:href="@{/}"></a></h1>
<table> <div>
<thead> <h2 th:if="${patientPseudonym != null}">
<tr> Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientPseudonym}">***</span>
<th>Status</th> <a class="btn btn-blue" th:if="${patientPseudonym != null}" th:href="@{/}">Alle anzeigen</a>
<th>Typ</th> </h2>
<th>ID</th> </div>
<th>Datum</th>
<th>Patienten-ID</th> <div class="border" th:if="${requests.totalElements == 0}">
</tr> <div class="notification info">Noch keine Anfragen eingegangen</div>
</thead> </div>
<tbody>
<tr th:each="request : ${requests}"> <div class="border" th:if="${requests.totalElements > 0}">
<td th:if="${request.status.value.contains('success')}" class="bg-green"><small>[[ ${request.status} ]]</small></td> <div th:if="${patientPseudonym == null}" class="page-control">
<td th:if="${request.status.value.contains('warning')}" class="bg-yellow"><small>[[ ${request.status} ]]</small></td> <a id="first-page-link" th:href="@{/(page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a>
<td th:if="${request.status.value.contains('error')}" class="bg-red"><small>[[ ${request.status} ]]</small></td> <a id="prev-page-link" th:href="@{/(page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</a>
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td> <span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td> <a id="next-page-link" th:href="@{/(page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a>
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td> <a id="last-page-link" th:href="@{/(page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</a>
<td th:if="not ${request.report}">[[ ${request.uuid} ]]</td> </div>
<td th:if="${request.report}"> <div th:if="${patientPseudonym != null}" class="page-control">
<a th:href="@{/report/{id}(id=${request.uuid})}">[[ ${request.uuid} ]]</a> <a id="first-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a>
</td> <a id="prev-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</a>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td> <span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
<td>[[ ${request.patientId} ]]</td> <a id="next-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a>
</tr> <a id="last-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</a>
</tbody> </div>
</table> <table class="paged">
<thead>
<tr>
<th>Status</th>
<th>Typ</th>
<th>ID</th>
<th>Datum</th>
<th>Patienten-ID</th>
</tr>
</thead>
<tbody>
<tr th:each="request : ${requests}">
<td th:if="${request.status.value.contains('success')}" class="bg-green"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value.contains('warning')}" class="bg-yellow"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value.contains('error')}" class="bg-red"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
<td th:if="not ${request.report}">[[ ${request.uuid} ]]</td>
<td th:if="${request.report}">
<a th:href="@{/report/{id}(id=${request.uuid})}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">[[ ${request.uuid} ]]</a>
<th:block sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">[[ ${request.uuid} ]]</th:block>
</td>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
<td class="patient-id" th:if="${patientPseudonym != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
[[ ${request.patientPseudonym} ]]
</td>
<td class="patient-id" th:if="${patientPseudonym == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
<a th:href="@{/patient/{pid}(pid=${request.patientPseudonym})}">[[ ${request.patientPseudonym} ]]</a>
</td>
<td class="patient-id" sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">***</td>
</tr>
</tbody>
</table>
</div>
</main> </main>
<script th:src="@{/scripts.js}"></script> <script th:src="@{/scripts.js}"></script>
<script>
window.addEventListener('load', () => {
let keyBindings = {
'w': 'first-page-link',
'a': 'prev-page-link',
'd': 'next-page-link',
's': 'last-page-link'
};
window.onkeydown = (event) => {
for (const [key, elemId] of Object.entries(keyBindings)) {
if (event.key === key && document.getElementById(elemId)) {
document.getElementById(elemId).style.background = 'yellow';
document.getElementById(elemId).click();
}
}
};
});
const eventSource = new EventSource('statistics/events');
eventSource.addEventListener('newrequest', event => {
console.log(event);
document.getElementById('reload-notify').style.display = 'inline-flex';
});
</script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ETL-Prozessor</title>
<link rel="stylesheet" th:href="@{/style.css}" />
</head>
<body>
<div th:replace="~{fragments.html :: nav}"></div>
<main>
<div class="login-form">
<img th:src="@{/user.svg}" alt="user-logo" />
<h2 class="centered">Anmelden</h2>
<div class="centered notification error" th:if="${param.error}">Anmeldung nicht erfolgreich</div>
<div class="centered notification notice" th:if="${param.expired}">Sitzung abgelaufen oder von einem Administrator beendet.</div>
<div class="centered notification success" th:if="${param.logout}">Sie haben sich abgemeldet</div>
<form method="post" th:action="@{/login}">
<input type="text" id="username" name="username" class="form-control" placeholder="Username" required="" autofocus="" />
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required="" />
<button class="btn" type="submit">Anmelden</button>
<hr th:if="${not oidcLogins.isEmpty()}" />
<a th:each="oidcLogin : ${oidcLogins}" class="btn" th:href="@{/oauth2/authorization/{provider}(provider=${oidcLogin.component1()})}">OIDC Login - [[ ${oidcLogin.component2()} ]]</a>
</form>
</div>
</main>
</body>
</html>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org"> <html lang="de" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>ETL-Prozessor</title> <title>ETL-Prozessor</title>
@ -15,6 +15,7 @@
<thead> <thead>
<tr> <tr>
<th>Status</th> <th>Status</th>
<th>Typ</th>
<th>ID</th> <th>ID</th>
<th>Datum</th> <th>Datum</th>
<th>Patienten-ID</th> <th>Patienten-ID</th>
@ -27,28 +28,39 @@
<td th:if="${request.status.value == 'error'}" class="bg-red"><small>[[ ${request.status} ]]</small></td> <td th:if="${request.status.value == 'error'}" class="bg-red"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td> <td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td> <td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
<td>[[ ${request.uuid} ]]</td> <td>[[ ${request.uuid} ]]</td>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td> <td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
<td>[[ ${request.patientId} ]]</td> <td class="patient-id" sec:authorize="authenticated">[[ ${request.patientPseudonym} ]]</td>
<td class="patient-id" sec:authorize="not authenticated">***</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<h2 th:text="${request.report.description}"></h2> <h2 th:text="${request.report.description}"></h2>
<table th:if="not ${issues.isEmpty()}"> <p th:if="${issues.isEmpty()}">
Keine weiteren Angaben.
</p>
<table th:if="${not issues.isEmpty()}">
<thead> <thead>
<tr> <tr>
<th>Schweregrad</th> <th>Schweregrad</th>
<th>Beschreibung</th> <th>Beschreibung und Pfad</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="issue : ${issues}"> <tr th:each="issue : ${issues}">
<td th:if="${issue.severity.value == 'info'}" class="bg-blue"><small>[[ ${issue.severity} ]]</small></td> <td th:if="${issue.severity.value == 'info'}" class="bg-blue"><small>[[ ${issue.severity} ]]</small></td>
<td th:if="${issue.severity.value == 'warning'}" class="bg-yellow"><small>[[ ${issue.severity} ]]</small></td> <td th:if="${issue.severity.value == 'warning'}" class="bg-yellow"><small>[[ ${issue.severity} ]]</small></td>
<td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td> <td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
<td>[[ ${issue.message} ]]</td> <td th:if="${issue.severity.value == 'fatal'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
<td>
<div class="issue-message">[[ ${issue.message} ]]</div>
<div class="issue-path" th:if="${issue.path.isPresent()}">[[ ${issue.path.get()} ]]</div>
<div class="issue-path" th:if="${issue.path.isEmpty()}"><i>Keine Angabe</i></div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -13,28 +13,32 @@
Hier sehen Sie eine Übersicht über eingegangene Anfragen. Hier sehen Sie eine Übersicht über eingegangene Anfragen.
</p> </p>
<h2>MTB-File-Anfragen</h2> <section>
<p> <h2>MTB-File-Anfragen</h2>
Anfragen zur Aktualisierung von Patientendaten durch Übermittlung eines MTB-Files. <p>
</p> Anfragen zur Aktualisierung von Patientendaten durch Übermittlung eines MTB-Files.
<div> </p>
<div id="piechart1" class="chart chart-50pc"></div> <div class="charts">
<div id="piechart2" class="chart chart-50pc"></div> <div id="piechart1" class="chart grid-left"></div>
</div> <div id="piechart2" class="chart grid-right"></div>
<div id="barchart" class="chart"></div> <div id="barchart" class="chart grid-full"></div>
</div>
</section>
<h2>Löschanfragen</h2> <section>
<p> <h2>Löschanfragen</h2>
Anfragen zur Löschung von Patientendaten, wenn kein Consent vorliegt. <p>
</p> Anfragen zur Löschung von Patientendaten, wenn kein Consent vorliegt.
<div> </p>
<div id="piechartdel1" class="chart chart-50pc"></div> <div class="charts">
<div id="piechartdel2" class="chart chart-50pc"></div> <div id="piechartdel1" class="chart grid-left"></div>
</div> <div id="piechartdel2" class="chart grid-right"></div>
<div id="barchartdel" class="chart"></div> <div id="barchartdel" class="chart grid-full"></div>
</div>
</section>
</main> </main>
<script th:src="@{/echarts.min.js}"></script> <script th:src="@{/webjars/echarts/dist/echarts.min.js}"></script>
<script th:src="@{/scripts.js}"></script> <script th:src="@{/scripts.js}"></script>
<script> <script>
window.onload = () => { window.onload = () => {

View File

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ETL-Prozessor</title>
<link rel="stylesheet" th:href="@{/style.css}" />
</head>
<body>
<div th:replace="~{fragments.html :: nav}"></div>
<main>
<h1>Transformationen</h1>
<h2>Syntax</h2>
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>
<h2>Konfigurierte Transformationen</h2>
<p>
Hier sehen Sie eine Übersicht der konfigurierten Transformationen.
</p>
<table>
<thead>
<tr>
<th>JSON-Path</th>
<th>Transformation von &rArr; 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>&rArr;</strong>
<span class="bg-to" title="Ersetze durch diesen Wert">[[ ${transformation.newValue} ]]</span>
</td>
</tr>
</tbody>
</table>
</main>
</body>
</html>

View File

@ -0,0 +1,20 @@
/*
* 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

View File

@ -0,0 +1,184 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile
import de.ukw.ccc.bwhc.dto.Patient
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.services.RequestProcessor
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.header.internals.RecordHeader
import org.apache.kafka.common.header.internals.RecordHeaders
import org.apache.kafka.common.record.TimestampType
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import java.util.*
@ExtendWith(MockitoExtension::class)
class KafkaInputListenerTest {
private lateinit var requestProcessor: RequestProcessor
private lateinit var objectMapper: ObjectMapper
private lateinit var kafkaInputListener: KafkaInputListener
@BeforeEach
fun setup(
@Mock requestProcessor: RequestProcessor
) {
this.requestProcessor = requestProcessor
this.objectMapper = ObjectMapper()
this.kafkaInputListener = KafkaInputListener(requestProcessor, objectMapper)
}
@Test
fun shouldProcessMtbFileRequest() {
val mtbFile = MtbFile.builder()
.withPatient(Patient.builder().withId("DUMMY_12345678").build())
.withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build())
.build()
kafkaInputListener.onMessage(
ConsumerRecord(
"testtopic",
0,
0,
"",
this.objectMapper.writeValueAsString(mtbFile)
)
)
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
}
@Test
fun shouldProcessDeleteRequest() {
val mtbFile = MtbFile.builder()
.withPatient(Patient.builder().withId("DUMMY_12345678").build())
.withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build())
.build()
kafkaInputListener.onMessage(
ConsumerRecord(
"testtopic",
0,
0,
"",
this.objectMapper.writeValueAsString(mtbFile)
)
)
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
@Test
fun shouldProcessMtbFileRequestWithExistingRequestId() {
val mtbFile = MtbFile.builder()
.withPatient(Patient.builder().withId("DUMMY_12345678").build())
.withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build())
.build()
val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray())))
kafkaInputListener.onMessage(
ConsumerRecord(
"testtopic",
0,
0,
-1L,
TimestampType.NO_TIMESTAMP_TYPE,
-1,
-1,
"",
this.objectMapper.writeValueAsString(mtbFile),
headers,
Optional.empty()
)
)
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>(), anyValueClass())
}
@Test
fun shouldProcessDeleteRequestWithExistingRequestId() {
val mtbFile = MtbFile.builder()
.withPatient(Patient.builder().withId("DUMMY_12345678").build())
.withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build())
.build()
val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray())))
kafkaInputListener.onMessage(
ConsumerRecord(
"testtopic",
0,
0,
-1L,
TimestampType.NO_TIMESTAMP_TYPE,
-1,
-1,
"",
this.objectMapper.writeValueAsString(mtbFile),
headers,
Optional.empty()
)
)
verify(requestProcessor, times(1)).processDeletion(anyValueClass(), anyValueClass())
}
@Test
fun shouldNotProcessDnpmV2Request() {
val mtbFile = MtbFile.builder()
.withPatient(Patient.builder().withId("DUMMY_12345678").build())
.withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build())
.build()
val headers = RecordHeaders(
listOf(
RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()),
RecordHeader("contentType", CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray())
)
)
kafkaInputListener.onMessage(
ConsumerRecord(
"testtopic",
0,
0,
-1L,
TimestampType.NO_TIMESTAMP_TYPE,
-1,
-1,
"",
this.objectMapper.writeValueAsString(mtbFile),
headers,
Optional.empty()
)
)
verify(requestProcessor, times(0)).processDeletion(anyValueClass(), anyValueClass())
}
}

View File

@ -0,0 +1,220 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
import org.mockito.kotlin.anyValueClass
import org.springframework.core.io.ClassPathResource
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
@ExtendWith(MockitoExtension::class)
class MtbFileRestControllerTest {
private val objectMapper = ObjectMapper()
@Nested
inner class BwhcRequests {
private lateinit var mockMvc: MockMvc
private lateinit var requestProcessor: RequestProcessor
@BeforeEach
fun setup(
@Mock requestProcessor: RequestProcessor
) {
this.requestProcessor = requestProcessor
val controller = MtbFileRestController(requestProcessor)
this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
}
@Test
fun shouldProcessPostRequest() {
mockMvc.post("/mtbfile") {
content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE))
contentType = MediaType.APPLICATION_JSON
}.andExpect {
status {
isAccepted()
}
}
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
}
@Test
fun shouldProcessPostRequestWithRejectedConsent() {
mockMvc.post("/mtbfile") {
content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED))
contentType = MediaType.APPLICATION_JSON
}.andExpect {
status {
isAccepted()
}
}
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
@Test
fun shouldProcessDeleteRequest() {
mockMvc.delete("/mtbfile/TEST_12345678").andExpect {
status {
isAccepted()
}
}
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
}
@Nested
inner class BwhcRequestsWithAlias {
private lateinit var mockMvc: MockMvc
private lateinit var requestProcessor: RequestProcessor
@BeforeEach
fun setup(
@Mock requestProcessor: RequestProcessor
) {
this.requestProcessor = requestProcessor
val controller = MtbFileRestController(requestProcessor)
this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
}
@Test
fun shouldProcessPostRequest() {
mockMvc.post("/mtb") {
content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE))
contentType = MediaType.APPLICATION_JSON
}.andExpect {
status {
isAccepted()
}
}
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
}
@Test
fun shouldProcessPostRequestWithRejectedConsent() {
mockMvc.post("/mtb") {
content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED))
contentType = MediaType.APPLICATION_JSON
}.andExpect {
status {
isAccepted()
}
}
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
@Test
fun shouldProcessDeleteRequest() {
mockMvc.delete("/mtb/TEST_12345678").andExpect {
status {
isAccepted()
}
}
verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
}
@Nested
inner class RequestsForDnpmDataModel21 {
private lateinit var mockMvc: MockMvc
private lateinit var requestProcessor: RequestProcessor
@BeforeEach
fun setup(
@Mock requestProcessor: RequestProcessor
) {
this.requestProcessor = requestProcessor
val controller = MtbFileRestController(requestProcessor)
this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
}
@Test
fun shouldRespondPostRequest() {
val mtbFileContent = ClassPathResource("mv64e-mtb-fake-patient.json").inputStream.readAllBytes().toString(Charsets.UTF_8)
mockMvc.post("/mtb") {
content = mtbFileContent
contentType = CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON
}.andExpect {
status {
isAccepted()
}
}
verify(requestProcessor, times(1)).processMtbFile(any<Mtb>())
}
}
companion object {
fun bwhcMtbFileContent(consentStatus: Consent.Status) = MtbFile.builder()
.withPatient(
Patient.builder()
.withId("TEST_12345678")
.withBirthDate("2000-08-08")
.withGender(Patient.Gender.MALE)
.build()
)
.withConsent(
Consent.builder()
.withId("1")
.withStatus(consentStatus)
.withPatient("TEST_12345678")
.build()
)
.withEpisode(
Episode.builder()
.withId("1")
.withPatient("TEST_12345678")
.withPeriod(PeriodStart("2023-08-08"))
.build()
)
.build()
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -21,105 +21,260 @@ package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.config.KafkaTargetProperties import de.ukw.ccc.bwhc.dto.Patient
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.KafkaProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.pcvolkmer.mv64e.mtb.*
import org.apache.kafka.clients.producer.ProducerRecord
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.* import org.mockito.kotlin.*
import org.springframework.http.MediaType
import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult import org.springframework.kafka.support.SendResult
import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplateBuilder
import java.util.concurrent.CompletableFuture.completedFuture import java.util.concurrent.CompletableFuture.completedFuture
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
@ExtendWith(MockitoExtension::class) @ExtendWith(MockitoExtension::class)
class KafkaMtbFileSenderTest { class KafkaMtbFileSenderTest {
private lateinit var kafkaTemplate: KafkaTemplate<String, String> @Nested
inner class BwhcV1Record {
private lateinit var kafkaMtbFileSender: KafkaMtbFileSender private lateinit var kafkaTemplate: KafkaTemplate<String, String>
private lateinit var objectMapper: ObjectMapper private lateinit var kafkaMtbFileSender: KafkaMtbFileSender
@BeforeEach private lateinit var objectMapper: ObjectMapper
fun setup(
@Mock kafkaTemplate: KafkaTemplate<String, String>
) {
val kafkaTargetProperties = KafkaTargetProperties("testtopic")
this.objectMapper = ObjectMapper()
this.kafkaTemplate = kafkaTemplate
this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaTargetProperties, objectMapper) @BeforeEach
} fun setup(
@Mock kafkaTemplate: KafkaTemplate<String, String>
) {
val kafkaProperties = KafkaProperties("testtopic")
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
@ParameterizedTest this.objectMapper = ObjectMapper()
@MethodSource("requestWithResponseSource") this.kafkaTemplate = kafkaTemplate
fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) {
doAnswer { this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper)
if (null != testData.exception) { }
throw testData.exception
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource")
fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) {
doAnswer {
if (null != testData.exception) {
throw testData.exception
}
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
val response = kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE)))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource")
fun shouldSendDeleteRequestAndReturnExpectedState(testData: TestData) {
doAnswer {
if (null != testData.exception) {
throw testData.exception
}
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
val response = kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@Test
fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE)))
val captor = argumentCaptor<ProducerRecord<String, String>>()
verify(kafkaTemplate, times(1)).send(captor.capture())
assertThat(captor.firstValue.key()).isNotNull
assertThat(captor.firstValue.key()).isEqualTo("{\"pid\": \"PID\"}")
assertThat(captor.firstValue.headers().headers("contentType")).isNotNull
assertThat(captor.firstValue.headers().headers("contentType")?.firstOrNull()?.value()).isEqualTo(MediaType.APPLICATION_JSON_VALUE.toByteArray())
assertThat(captor.firstValue.value()).isNotNull
assertThat(captor.firstValue.value()).isEqualTo(objectMapper.writeValueAsString(bwhcV1kafkaRecordData(TEST_REQUEST_ID, Consent.Status.ACTIVE)))
}
@Test
fun shouldSendDeleteRequestWithCorrectKeyAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
val captor = argumentCaptor<ProducerRecord<String, String>>()
verify(kafkaTemplate, times(1)).send(captor.capture())
assertThat(captor.firstValue.key()).isNotNull
assertThat(captor.firstValue.key()).isEqualTo("{\"pid\": \"PID\"}")
assertThat(captor.firstValue.value()).isNotNull
assertThat(captor.firstValue.value()).isEqualTo(objectMapper.writeValueAsString(bwhcV1kafkaRecordData(TEST_REQUEST_ID, Consent.Status.REJECTED)))
}
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource")
fun shouldRetryOnMtbFileKafkaSendError(testData: TestData) {
val kafkaProperties = KafkaProperties("testtopic")
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper)
doAnswer {
if (null != testData.exception) {
throw testData.exception
}
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE)))
val expectedCount = when (testData.exception) {
// OK - No Retry
null -> times(1)
// Request failed - Retry max 3 times
else -> times(3)
} }
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
val response = kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(Consent.Status.ACTIVE))) verify(kafkaTemplate, expectedCount).send(any<ProducerRecord<String, String>>())
assertThat(response.status).isEqualTo(testData.requestStatus) }
}
@ParameterizedTest @ParameterizedTest
@MethodSource("requestWithResponseSource") @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource")
fun shouldSendDeleteRequestAndReturnExpectedState(testData: TestData) { fun shouldRetryOnDeleteKafkaSendError(testData: TestData) {
doAnswer { val kafkaProperties = KafkaProperties("testtopic")
if (null != testData.exception) { val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
throw testData.exception this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper)
doAnswer {
if (null != testData.exception) {
throw testData.exception
}
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
val expectedCount = when (testData.exception) {
// OK - No Retry
null -> times(1)
// Request failed - Retry max 3 times
else -> times(3)
} }
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
val response = kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID")) verify(kafkaTemplate, expectedCount).send(any<ProducerRecord<String, String>>())
assertThat(response.status).isEqualTo(testData.requestStatus) }
} }
@Test @Nested
fun shouldSendMtbFileRequestWithCorrectKeyAndBody() { inner class DnpmV2Record {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(Consent.Status.ACTIVE))) private lateinit var kafkaTemplate: KafkaTemplate<String, String>
val captor = argumentCaptor<String>() private lateinit var kafkaMtbFileSender: KafkaMtbFileSender
verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture())
assertThat(captor.firstValue).isNotNull
assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\", \"eid\": \"1\"}")
assertThat(captor.secondValue).isNotNull
assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData("TestID", Consent.Status.ACTIVE)))
}
@Test private lateinit var objectMapper: ObjectMapper
fun shouldSendDeleteRequestWithCorrectKeyAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID")) @BeforeEach
fun setup(
@Mock kafkaTemplate: KafkaTemplate<String, String>
) {
val kafkaProperties = KafkaProperties("testtopic")
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
this.objectMapper = ObjectMapper()
this.kafkaTemplate = kafkaTemplate
this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper)
}
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource")
fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) {
doAnswer {
if (null != testData.exception) {
throw testData.exception
}
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
val response = kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile()))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@Test
fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile()))
val captor = argumentCaptor<ProducerRecord<String, String>>()
verify(kafkaTemplate, times(1)).send(captor.capture())
assertThat(captor.firstValue.key()).isNotNull
assertThat(captor.firstValue.key()).isEqualTo("{\"pid\": \"PID\"}")
assertThat(captor.firstValue.headers().headers("contentType")).isNotNull
assertThat(captor.firstValue.headers().headers("contentType")?.firstOrNull()?.value()).isEqualTo(CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray())
assertThat(captor.firstValue.value()).isNotNull
assertThat(captor.firstValue.value()).isEqualTo(objectMapper.writeValueAsString(dnmpV2kafkaRecordData(TEST_REQUEST_ID)))
}
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource")
fun shouldRetryOnMtbFileKafkaSendError(testData: TestData) {
val kafkaProperties = KafkaProperties("testtopic")
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper)
doAnswer {
if (null != testData.exception) {
throw testData.exception
}
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile()))
val expectedCount = when (testData.exception) {
// OK - No Retry
null -> times(1)
// Request failed - Retry max 3 times
else -> times(3)
}
verify(kafkaTemplate, expectedCount).send(any<ProducerRecord<String, String>>())
}
val captor = argumentCaptor<String>()
verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture())
assertThat(captor.firstValue).isNotNull
assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\"}")
assertThat(captor.secondValue).isNotNull
assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData("TestID", Consent.Status.REJECTED)))
} }
companion object { companion object {
fun mtbFile(consentStatus: Consent.Status): MtbFile { val TEST_REQUEST_ID = RequestId("TestId")
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
fun bwhcV1MtbFile(consentStatus: Consent.Status): MtbFile {
return if (consentStatus == Consent.Status.ACTIVE) { return if (consentStatus == Consent.Status.ACTIVE) {
MtbFile.builder() MtbFile.builder()
.withPatient( .withPatient(
@ -154,8 +309,31 @@ class KafkaMtbFileSenderTest {
}.build() }.build()
} }
fun kafkaRecordData(requestId: String, consentStatus: Consent.Status): KafkaMtbFileSender.Data { fun dnpmV2MtbFile(): Mtb = Mtb.builder()
return KafkaMtbFileSender.Data(requestId, mtbFile(consentStatus)) .withPatient(
dev.pcvolkmer.mv64e.mtb.Patient.builder()
.withId("PID")
.withBirthDate("2000-08-08")
.withGender(CodingGender.builder().withCode(CodingGender.Code.MALE).build())
.build()
)
.withEpisodesOfCare(
listOf(
MTBEpisodeOfCare.builder()
.withId("1")
.withPatient(Reference("PID"))
.withPeriod(PeriodDate.builder().withStart("2023-08-08").build())
.build()
)
)
.build()
fun bwhcV1kafkaRecordData(requestId: RequestId, consentStatus: Consent.Status): MtbRequest {
return BwhcV1MtbFileRequest(requestId, bwhcV1MtbFile(consentStatus))
}
fun dnmpV2kafkaRecordData(requestId: RequestId): MtbRequest {
return DnpmV2MtbFileRequest(requestId, dnpmV2MtbFile())
} }
data class TestData(val requestStatus: RequestStatus, val exception: Throwable? = null) data class TestData(val requestStatus: RequestStatus, val exception: Throwable? = null)
@ -170,4 +348,4 @@ class KafkaMtbFileSenderTest {
} }
} }
} }

View File

@ -0,0 +1,313 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.test.web.client.ExpectedCount
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.*
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestTemplate
class RestBwhcMtbFileSenderTest {
private lateinit var mockRestServiceServer: MockRestServiceServer
private lateinit var restMtbFileSender: RestMtbFileSender
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
@BeforeEach
fun setup() {
val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null)
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender =
RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
}
@ParameterizedTest
@MethodSource("deleteRequestWithResponseSource")
fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
this.mockRestServiceServer
.expect(method(HttpMethod.DELETE))
.andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}"))
.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@ParameterizedTest
@MethodSource("mtbFileRequestWithResponseSource")
fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) {
this.mockRestServiceServer
.expect(method(HttpMethod.POST))
.andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile"))
.andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))
.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@ParameterizedTest
@MethodSource("mtbFileRequestWithResponseSource")
fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) {
val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null)
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender =
RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
val expectedCount = when (requestWithResponse.httpStatus) {
// OK - No Retry
HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1)
// Request failed - Retry max 3 times
else -> ExpectedCount.max(3)
}
this.mockRestServiceServer
.expect(expectedCount, method(HttpMethod.POST))
.andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile"))
.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@ParameterizedTest
@MethodSource("deleteRequestWithResponseSource")
fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) {
val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null)
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender =
RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
val expectedCount = when (requestWithResponse.httpStatus) {
// OK - No Retry
HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1)
// Request failed - Retry max 3 times
else -> ExpectedCount.max(3)
}
this.mockRestServiceServer
.expect(expectedCount, method(HttpMethod.DELETE))
.andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}"))
.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
companion object {
data class RequestWithResponse(
val httpStatus: HttpStatus,
val body: String,
val response: MtbFileSender.Response
)
val TEST_REQUEST_ID = RequestId("TestId")
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
val mtbFile: MtbFile = MtbFile.builder()
.withPatient(
Patient.builder()
.withId("PID")
.withBirthDate("2000-08-08")
.withGender(Patient.Gender.MALE)
.build()
)
.withConsent(
Consent.builder()
.withId("1")
.withStatus(Consent.Status.ACTIVE)
.withPatient("PID")
.build()
)
.withEpisode(
Episode.builder()
.withId("1")
.withPatient("PID")
.withPeriod(PeriodStart("2023-08-08"))
.build()
)
.build()
private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung"
/**
* Synthetic http responses with related request status
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
*/
@JvmStatic
fun mtbFileRequestWithResponseSource(): Set<RequestWithResponse> {
return setOf(
RequestWithResponse(
HttpStatus.OK,
responseBodyWithMaxSeverity(ReportService.Severity.INFO),
MtbFileSender.Response(
RequestStatus.SUCCESS,
responseBodyWithMaxSeverity(ReportService.Severity.INFO)
)
),
RequestWithResponse(
HttpStatus.CREATED,
responseBodyWithMaxSeverity(ReportService.Severity.WARNING),
MtbFileSender.Response(
RequestStatus.WARNING,
responseBodyWithMaxSeverity(ReportService.Severity.WARNING)
)
),
RequestWithResponse(
HttpStatus.BAD_REQUEST,
responseBodyWithMaxSeverity(ReportService.Severity.ERROR),
MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR))
),
RequestWithResponse(
HttpStatus.UNPROCESSABLE_ENTITY,
responseBodyWithMaxSeverity(ReportService.Severity.FATAL),
MtbFileSender.Response(
RequestStatus.ERROR,
responseBodyWithMaxSeverity(ReportService.Severity.FATAL)
)
),
// Some more errors not mentioned in documentation
RequestWithResponse(
HttpStatus.NOT_FOUND,
ERROR_RESPONSE_BODY,
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
ERROR_RESPONSE_BODY,
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
)
)
}
/**
* Synthetic http responses with related request status
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
*/
@JvmStatic
fun deleteRequestWithResponseSource(): Set<RequestWithResponse> {
return setOf(
RequestWithResponse(HttpStatus.OK, "", MtbFileSender.Response(RequestStatus.SUCCESS)),
// Some more errors not mentioned in documentation
RequestWithResponse(
HttpStatus.NOT_FOUND,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
)
)
}
fun responseBodyWithMaxSeverity(severity: ReportService.Severity): String {
return when (severity) {
ReportService.Severity.INFO -> """
{
"patient": "PID",
"issues": [
{ "severity": "info", "message": "Info Message" }
]
}
"""
ReportService.Severity.WARNING -> """
{
"patient": "PID",
"issues": [
{ "severity": "info", "message": "Info Message" },
{ "severity": "warning", "message": "Warning Message" }
]
}
"""
ReportService.Severity.ERROR -> """
{
"patient": "PID",
"issues": [
{ "severity": "info", "message": "Info Message" },
{ "severity": "warning", "message": "Warning Message" },
{ "severity": "error", "message": "Error Message" }
]
}
"""
ReportService.Severity.FATAL -> """
{
"patient": "PID",
"issues": [
{ "severity": "info", "message": "Info Message" },
{ "severity": "warning", "message": "Warning Message" },
{ "severity": "error", "message": "Error Message" },
{ "severity": "fatal", "message": "Fatal Message" }
]
}
"""
}
}
}
}

View File

@ -0,0 +1,405 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import de.ukw.ccc.bwhc.dto.*
import de.ukw.ccc.bwhc.dto.Patient
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.config.AppConfiguration
import dev.dnpm.etl.processor.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.pcvolkmer.mv64e.mtb.*
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.retry.backoff.NoBackOffPolicy
import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.test.web.client.ExpectedCount
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.*
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestTemplate
class RestDipMtbFileSenderTest {
@Nested
inner class BwhcV1ContentRequest {
private lateinit var mockRestServiceServer: MockRestServiceServer
private lateinit var restMtbFileSender: RestMtbFileSender
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
@BeforeEach
fun setup() {
val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender =
RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
}
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource")
fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) {
this.mockRestServiceServer
.expect(method(HttpMethod.POST))
.andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record"))
.andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))
.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource")
fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) {
val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000"))
retryTemplate.setBackOffPolicy(NoBackOffPolicy())
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender =
RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
val expectedCount = when (requestWithResponse.httpStatus) {
// OK - No Retry
HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max(
1
)
// Request failed - Retry max 3 times
else -> ExpectedCount.max(3)
}
this.mockRestServiceServer
.expect(expectedCount, method(HttpMethod.POST))
.andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record"))
.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
}
@Nested
inner class DnpmV2ContentRequest {
private lateinit var mockRestServiceServer: MockRestServiceServer
private lateinit var restMtbFileSender: RestMtbFileSender
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
@BeforeEach
fun setup() {
val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
}
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource")
fun shouldReturnExpectedResponseForDnpmV2MtbFilePost(requestWithResponse: RequestWithResponse) {
this.mockRestServiceServer
.expect(method(HttpMethod.POST))
.andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record"))
.andExpect(header(HttpHeaders.CONTENT_TYPE, CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE))
.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
}
@Nested
inner class DeleteRequest {
private lateinit var mockRestServiceServer: MockRestServiceServer
private lateinit var restMtbFileSender: RestMtbFileSender
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
@BeforeEach
fun setup() {
val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender =
RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
}
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#deleteRequestWithResponseSource")
fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
this.mockRestServiceServer
.expect(method(HttpMethod.DELETE))
.andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}"))
.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@ParameterizedTest
@MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#deleteRequestWithResponseSource")
fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) {
val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000"))
retryTemplate.setBackOffPolicy(NoBackOffPolicy())
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender =
RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
val expectedCount = when (requestWithResponse.httpStatus) {
// OK - No Retry
HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> ExpectedCount.max(
1
)
// Request failed - Retry max 3 times
else -> ExpectedCount.max(3)
}
this.mockRestServiceServer
.expect(expectedCount, method(HttpMethod.DELETE))
.andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}"))
.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
}
companion object {
data class RequestWithResponse(
val httpStatus: HttpStatus,
val body: String,
val response: MtbFileSender.Response
)
val TEST_REQUEST_ID = RequestId("TestId")
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
val bwhcV1mtbFile: MtbFile = MtbFile.builder()
.withPatient(
Patient.builder()
.withId("PID")
.withBirthDate("2000-08-08")
.withGender(Patient.Gender.MALE)
.build()
)
.withConsent(
Consent.builder()
.withId("1")
.withStatus(Consent.Status.ACTIVE)
.withPatient("PID")
.build()
)
.withEpisode(
Episode.builder()
.withId("1")
.withPatient("PID")
.withPeriod(PeriodStart("2023-08-08"))
.build()
)
.build()
val dnpmV2MtbFile: Mtb = Mtb.builder()
.withPatient(
dev.pcvolkmer.mv64e.mtb.Patient.builder()
.withId("PID")
.withBirthDate("2000-08-08")
.withGender(CodingGender.builder().withCode(CodingGender.Code.MALE).build())
.build()
)
.withEpisodesOfCare(
listOf(
MTBEpisodeOfCare.builder()
.withId("1")
.withPatient(Reference("PID"))
.withPeriod(PeriodDate.builder().withStart("2023-08-08").build())
.build()
)
)
.build()
private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung"
/**
* Synthetic http responses with related request status
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
*/
@JvmStatic
fun mtbFileRequestWithResponseSource(): Set<RequestWithResponse> {
return setOf(
RequestWithResponse(
HttpStatus.OK,
responseBodyWithMaxSeverity(ReportService.Severity.INFO),
MtbFileSender.Response(
RequestStatus.SUCCESS,
responseBodyWithMaxSeverity(ReportService.Severity.INFO)
)
),
RequestWithResponse(
HttpStatus.CREATED,
responseBodyWithMaxSeverity(ReportService.Severity.WARNING),
MtbFileSender.Response(RequestStatus.WARNING, responseBodyWithMaxSeverity(ReportService.Severity.WARNING))
),
RequestWithResponse(
HttpStatus.BAD_REQUEST,
responseBodyWithMaxSeverity(ReportService.Severity.ERROR),
MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR))
),
RequestWithResponse(
HttpStatus.UNPROCESSABLE_ENTITY,
responseBodyWithMaxSeverity(ReportService.Severity.ERROR),
MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR))
),
// Some more errors not mentioned in documentation
RequestWithResponse(
HttpStatus.NOT_FOUND,
ERROR_RESPONSE_BODY,
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
ERROR_RESPONSE_BODY,
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
)
)
}
/**
* Synthetic http responses with related request status
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
*/
@JvmStatic
fun deleteRequestWithResponseSource(): Set<RequestWithResponse> {
return setOf(
RequestWithResponse(HttpStatus.OK, "", MtbFileSender.Response(RequestStatus.SUCCESS)),
// Some more errors not mentioned in documentation
RequestWithResponse(
HttpStatus.NOT_FOUND,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
)
)
}
fun responseBodyWithMaxSeverity(severity: ReportService.Severity): String {
return when (severity) {
ReportService.Severity.INFO -> """
{
"patient": "PID",
"issues": [
{ "severity": "info", "message": "Info Message" }
]
}
"""
ReportService.Severity.WARNING -> """
{
"patient": "PID",
"issues": [
{ "severity": "info", "message": "Info Message" },
{ "severity": "warning", "message": "Warning Message" }
]
}
"""
ReportService.Severity.ERROR -> """
{
"patient": "PID",
"issues": [
{ "severity": "info", "message": "Info Message" },
{ "severity": "warning", "message": "Warning Message" },
{ "severity": "error", "message": "Error Message" }
]
}
"""
ReportService.Severity.FATAL -> """
{
"patient": "PID",
"issues": [
{ "severity": "info", "message": "Info Message" },
{ "severity": "warning", "message": "Warning Message" },
{ "severity": "error", "message": "Error Message" },
{ "severity": "fatal", "message": "Fatal Message" }
]
}
"""
}
}
}
}

View File

@ -1,195 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.output
import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestTemplate
class RestMtbFileSenderTest {
private lateinit var mockRestServiceServer: MockRestServiceServer
private lateinit var restMtbFileSender: RestMtbFileSender
@BeforeEach
fun setup() {
val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile")
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties)
}
@ParameterizedTest
@MethodSource("deleteRequestWithResponseSource")
fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
this.mockRestServiceServer.expect {
method(HttpMethod.DELETE)
requestTo("/mtbfile")
}.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@ParameterizedTest
@MethodSource("mtbFileRequestWithResponseSource")
fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) {
this.mockRestServiceServer.expect {
method(HttpMethod.POST)
requestTo("/mtbfile")
}.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
}
val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
companion object {
data class RequestWithResponse(
val httpStatus: HttpStatus,
val body: String,
val response: MtbFileSender.Response
)
private val warningBody = """
{
"patient_id": "PID",
"issues": [
{ "severity": "warning", "message": "Something is not right" }
]
}
""".trimIndent()
private val errorBody = """
{
"patient_id": "PID",
"issues": [
{ "severity": "error", "message": "Something is very bad" }
]
}
""".trimIndent()
val mtbFile: MtbFile = MtbFile.builder()
.withPatient(
Patient.builder()
.withId("PID")
.withBirthDate("2000-08-08")
.withGender(Patient.Gender.MALE)
.build()
)
.withConsent(
Consent.builder()
.withId("1")
.withStatus(Consent.Status.ACTIVE)
.withPatient("PID")
.build()
)
.withEpisode(
Episode.builder()
.withId("1")
.withPatient("PID")
.withPeriod(PeriodStart("2023-08-08"))
.build()
)
.build()
private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung"
/**
* Synthetic http responses with related request status
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
*/
@JvmStatic
fun mtbFileRequestWithResponseSource(): Set<RequestWithResponse> {
return setOf(
RequestWithResponse(HttpStatus.OK, "{}", MtbFileSender.Response(RequestStatus.SUCCESS, "{}")),
RequestWithResponse(
HttpStatus.CREATED,
warningBody,
MtbFileSender.Response(RequestStatus.WARNING, warningBody)
),
RequestWithResponse(
HttpStatus.BAD_REQUEST,
"??",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.UNPROCESSABLE_ENTITY,
errorBody,
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
// Some more errors not mentioned in documentation
RequestWithResponse(
HttpStatus.NOT_FOUND,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
)
)
}
/**
* Synthetic http responses with related request status
* Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
*/
@JvmStatic
fun deleteRequestWithResponseSource(): Set<RequestWithResponse> {
return setOf(
RequestWithResponse(HttpStatus.OK, "", MtbFileSender.Response(RequestStatus.SUCCESS)),
// Some more errors not mentioned in documentation
RequestWithResponse(
HttpStatus.NOT_FOUND,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
)
)
}
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
@ -20,45 +20,262 @@
package dev.dnpm.etl.processor.pseudonym package dev.dnpm.etl.processor.pseudonym
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.*
import dev.pcvolkmer.mv64e.mtb.MTBEpisodeOfCare
import dev.pcvolkmer.mv64e.mtb.Mtb
import dev.pcvolkmer.mv64e.mtb.PeriodDate
import dev.pcvolkmer.mv64e.mtb.Reference
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers
import org.mockito.Mock import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.core.io.ClassPathResource import org.springframework.core.io.ClassPathResource
const val FAKE_MTB_FILE_PATH = "fake_MTBFile.json"
const val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549"
@ExtendWith(MockitoExtension::class) @ExtendWith(MockitoExtension::class)
class ExtensionsTest { class ExtensionsTest {
private fun fakeMtbFile(): MtbFile { @Nested
val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream inner class UsingBwhcDatamodel {
return ObjectMapper().readValue(mtbFile, MtbFile::class.java)
val FAKE_MTB_FILE_PATH = "fake_MTBFile.json"
val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549"
private fun fakeMtbFile(): MtbFile {
val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream
return ObjectMapper().readValue(mtbFile, MtbFile::class.java)
}
private fun MtbFile.serialized(): String {
return ObjectMapper().writeValueAsString(this)
}
@Test
fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
val mtbFile = fakeMtbFile()
mtbFile.pseudonymizeWith(pseudonymizeService)
assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID")
assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID)
}
@Test
fun shouldNotContainAnyUuidAfterRehashingOfIds(@Mock pseudonymizeService: PseudonymizeService) {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
"TESTDOMAIN"
}.whenever(pseudonymizeService).prefix()
val mtbFile = fakeMtbFile()
mtbFile.pseudonymizeWith(pseudonymizeService)
mtbFile.anonymizeContentWith(pseudonymizeService)
val pattern = "\"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\"".toRegex().toPattern()
val matcher = pattern.matcher(mtbFile.serialized())
assertThrows<IllegalStateException> {
matcher.find()
matcher.group()
}.also {
assertThat(it.message).isEqualTo("No match found")
}
}
@Test
fun shouldRehashIdsWithPrefix(@Mock pseudonymizeService: PseudonymizeService) {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
"TESTDOMAIN"
}.whenever(pseudonymizeService).prefix()
val mtbFile = MtbFile.builder()
.withPatient(
Patient.builder()
.withId("1")
.withBirthDate("2000-08-08")
.withGender(Patient.Gender.MALE)
.build()
)
.withConsent(
Consent.builder()
.withId("1")
.withStatus(Consent.Status.ACTIVE)
.withPatient("123")
.build()
)
.withEpisode(
Episode.builder()
.withId("1")
.withPatient("1")
.withPeriod(PeriodStart("2023-08-08"))
.build()
)
.build()
mtbFile.pseudonymizeWith(pseudonymizeService)
mtbFile.anonymizeContentWith(pseudonymizeService)
assertThat(mtbFile.episode.id)
// TESTDOMAIN<sha256(TESTDOMAIN-1)[0-41]>
.isEqualTo("TESTDOMAIN44e20a53bbbf9f3ae39626d05df7014dcd77d6098")
}
@Test
fun shouldNotThrowExceptionOnNullValues(@Mock pseudonymizeService: PseudonymizeService) {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
"TESTDOMAIN"
}.whenever(pseudonymizeService).prefix()
val mtbFile = MtbFile.builder()
.withPatient(
Patient.builder()
.withId("1")
.withBirthDate("2000-08-08")
.withGender(Patient.Gender.MALE)
.build()
)
.withConsent(
Consent.builder()
.withId("1")
.withStatus(Consent.Status.ACTIVE)
.withPatient("123")
.build()
)
.withEpisode(
Episode.builder()
.withId("1")
.withPatient("1")
.withPeriod(PeriodStart("2023-08-08"))
.build()
)
.withClaims(null)
.withDiagnoses(null)
.withCarePlans(null)
.withClaimResponses(null)
.withEcogStatus(null)
.withFamilyMemberDiagnoses(null)
.withGeneticCounsellingRequests(null)
.withHistologyReevaluationRequests(null)
.withHistologyReports(null)
.withLastGuidelineTherapies(null)
.withMolecularPathologyFindings(null)
.withMolecularTherapies(null)
.withNgsReports(null)
.withPreviousGuidelineTherapies(null)
.withRebiopsyRequests(null)
.withRecommendations(null)
.withResponses(null)
.withStudyInclusionRequests(null)
.withSpecimens(null)
.build()
mtbFile.pseudonymizeWith(pseudonymizeService)
mtbFile.anonymizeContentWith(pseudonymizeService)
assertThat(mtbFile.episode.id).isNotNull()
}
} }
private fun MtbFile.serialized(): String { @Nested
return ObjectMapper().writeValueAsString(this) inner class UsingDnpmV2Datamodel {
val FAKE_MTB_FILE_PATH = "mv64e-mtb-fake-patient.json"
val CLEAN_PATIENT_ID = "63f8fd7b-8127-4f3c-8843-aa9199e21c29"
private fun fakeMtbFile(): Mtb {
val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream
return ObjectMapper().readValue(mtbFile, Mtb::class.java)
}
private fun Mtb.serialized(): String {
return ObjectMapper().writeValueAsString(this)
}
@Test
fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
val mtbFile = fakeMtbFile()
mtbFile.pseudonymizeWith(pseudonymizeService)
assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID")
assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID)
}
@Test
fun shouldNotThrowExceptionOnNullValues(@Mock pseudonymizeService: PseudonymizeService) {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
"TESTDOMAIN"
}.whenever(pseudonymizeService).prefix()
val mtbFile = Mtb.builder()
.withPatient(
dev.pcvolkmer.mv64e.mtb.Patient.builder()
.withId("1")
.withBirthDate("2000-08-08")
.withGender(null)
.build()
)
.withEpisodesOfCare(
listOf(
MTBEpisodeOfCare.builder()
.withId("1")
.withPatient(Reference("1"))
.withPeriod(PeriodDate.builder().withStart("2023-08-08").build())
.build()
)
)
.withClaims(null)
.withDiagnoses(null)
.withCarePlans(null)
.withClaimResponses(null)
.withHistologyReports(null)
.withNgsReports(null)
.withResponses(null)
.withSpecimens(null)
.build()
mtbFile.pseudonymizeWith(pseudonymizeService)
mtbFile.anonymizeContentWith(pseudonymizeService)
assertThat(mtbFile.episodesOfCare).hasSize(1)
assertThat(mtbFile.episodesOfCare.map { it.id }).isNotNull
}
} }
}
@Test
fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
}.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString())
val mtbFile = fakeMtbFile()
mtbFile.pseudonymizeWith(pseudonymizeService)
assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID")
assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID)
}
}

View File

@ -0,0 +1,153 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.security
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.*
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import java.util.*
import java.util.function.Consumer
@ExtendWith(MockitoExtension::class)
class TokenServiceTest {
private lateinit var userDetailsManager: InMemoryUserDetailsManager
private lateinit var passwordEncoder: PasswordEncoder
private lateinit var tokenRepository: TokenRepository
private lateinit var tokenService: TokenService
@BeforeEach
fun setup(
@Mock userDetailsManager: InMemoryUserDetailsManager,
@Mock passwordEncoder: PasswordEncoder,
@Mock tokenRepository: TokenRepository
) {
this.userDetailsManager = userDetailsManager
this.passwordEncoder = passwordEncoder
this.tokenRepository = tokenRepository
this.tokenService = TokenService(userDetailsManager, passwordEncoder, tokenRepository)
}
@Test
fun shouldEncodePasswordForNewToken() {
doAnswer { "{test}verysecret" }.whenever(passwordEncoder).encode(anyString())
val actual = this.tokenService.addToken("Test Token")
assertThat(actual).satisfies(
Consumer { assertThat(it.isSuccess).isTrue() },
Consumer { assertThat(it.getOrNull()).matches("testtoken:[A-Za-z0-9]{48}$") }
)
}
@Test
fun shouldContainAlphanumTokenUserPart() {
doAnswer { "{test}verysecret" }.whenever(passwordEncoder).encode(anyString())
val actual = this.tokenService.addToken("Test Token")
assertThat(actual).satisfies(
Consumer { assertThat(it.isSuccess).isTrue() },
Consumer { assertThat(it.getOrDefault("")).startsWith("testtoken:") }
)
}
@Test
fun shouldNotAllowSameTokenUserPartTwice() {
doReturn(true).whenever(userDetailsManager).userExists(anyString())
val actual = this.tokenService.addToken("Test Token")
assertThat(actual).satisfies(Consumer { assertThat(it.isFailure).isTrue() })
verify(tokenRepository, never()).save(any())
}
@Test
fun shouldSaveNewToken() {
doAnswer { "{test}verysecret" }.whenever(passwordEncoder).encode(anyString())
val actual = this.tokenService.addToken("Test Token")
val captor = argumentCaptor<Token>()
verify(tokenRepository, times(1)).save(captor.capture())
assertThat(actual).satisfies(Consumer { assertThat(it.isSuccess).isTrue() })
assertThat(captor.firstValue).satisfies(
Consumer { assertThat(it.name).isEqualTo("Test Token") },
Consumer { assertThat(it.username).isEqualTo("testtoken") },
Consumer { assertThat(it.password).isEqualTo("{test}verysecret") }
)
}
@Test
fun shouldDeleteExistingToken() {
doAnswer {
val id = it.arguments[0] as Long
Optional.of(Token(id, "Test Token", "testtoken", "{test}hsdajfgadskjhfgsdkfjg"))
}.whenever(tokenRepository).findById(anyLong())
this.tokenService.deleteToken(42)
val stringCaptor = argumentCaptor<String>()
verify(userDetailsManager, times(1)).deleteUser(stringCaptor.capture())
assertThat(stringCaptor.firstValue).isEqualTo("testtoken")
val tokenCaptor = argumentCaptor<Token>()
verify(tokenRepository, times(1)).delete(tokenCaptor.capture())
assertThat(tokenCaptor.firstValue.id).isEqualTo(42)
}
@Test
fun shouldReturnAllTokensFromRepository() {
val expected = listOf(
Token(1, "Test Token 1", "testtoken1", "{test}hsdajfgadskjhfgsdkfjg"),
Token(2, "Test Token 2", "testtoken2", "{test}asdasdasdasdasdasdasd")
)
doReturn(expected).whenever(tokenRepository).findAll()
assertThat(tokenService.findAll()).isEqualTo(expected)
}
@Test
fun shouldAddAllTokensFromRepositoryToUserDataManager() {
val expected = listOf(
Token(1, "Test Token 1", "testtoken1", "{test}hsdajfgadskjhfgsdkfjg"),
Token(2, "Test Token 2", "testtoken2", "{test}asdasdasdasdasdasdasd")
)
doReturn(expected).whenever(tokenRepository).findAll()
tokenService.setup()
verify(userDetailsManager, times(expected.size)).createUser(any())
}
}

View File

@ -0,0 +1,202 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.security
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.*
import org.springframework.security.core.session.SessionInformation
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.oauth2.core.oidc.OidcIdToken
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser
import org.springframework.security.oauth2.core.oidc.user.OidcUser
import java.time.Instant
import java.util.*
@ExtendWith(MockitoExtension::class)
class UserRoleServiceTest {
private lateinit var userRoleRepository: UserRoleRepository
private lateinit var sessionRegistry: SessionRegistry
private lateinit var userRoleService: UserRoleService
@BeforeEach
fun setup(
@Mock userRoleRepository: UserRoleRepository,
@Mock sessionRegistry: SessionRegistry
) {
this.userRoleRepository = userRoleRepository
this.sessionRegistry = sessionRegistry
this.userRoleService = UserRoleService(userRoleRepository, sessionRegistry)
}
@Test
fun shouldDelegateFindAllToRepository() {
userRoleService.findAll()
verify(userRoleRepository, times(1)).findAll()
}
@Nested
inner class WithExistingUserRole {
@BeforeEach
fun setup() {
doAnswer { invocation ->
Optional.of(
UserRole(invocation.getArgument(0), "patrick.tester", Role.USER)
)
}.whenever(userRoleRepository).findById(any<Long>())
doAnswer { _ ->
listOf(
dummyPrincipal()
)
}.whenever(sessionRegistry).allPrincipals
}
@Test
fun shouldUpdateUserRole() {
userRoleService.updateUserRole(1, Role.ADMIN)
val userRoleCaptor = argumentCaptor<UserRole>()
verify(userRoleRepository, times(1)).save(userRoleCaptor.capture())
assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L)
assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.ADMIN)
}
@Test
fun shouldExpireSessionOnUpdate() {
val dummySessions = dummySessions()
whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn(
dummySessions
)
assertThat(dummySessions.filter { it.isExpired }).hasSize(0)
userRoleService.updateUserRole(1, Role.ADMIN)
verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>())
assertThat(dummySessions.filter { it.isExpired }).hasSize(2)
}
@Test
fun shouldDeleteUserRole() {
userRoleService.deleteUserRole(1)
val userRoleCaptor = argumentCaptor<UserRole>()
verify(userRoleRepository, times(1)).delete(userRoleCaptor.capture())
assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L)
assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.USER)
}
@Test
fun shouldExpireSessionOnDelete() {
val dummySessions = dummySessions()
whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn(
dummySessions
)
assertThat(dummySessions.filter { it.isExpired }).hasSize(0)
userRoleService.deleteUserRole(1)
verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>())
assertThat(dummySessions.filter { it.isExpired }).hasSize(2)
}
}
@Nested
inner class WithoutExistingUserRole {
@BeforeEach
fun setup() {
doAnswer { _ ->
Optional.empty<UserRole>()
}.whenever(userRoleRepository).findById(any<Long>())
}
@Test
fun shouldNotUpdateUserRole() {
userRoleService.updateUserRole(1, Role.ADMIN)
verify(userRoleRepository, never()).save(any<UserRole>())
}
@Test
fun shouldNotExpireSessionOnUpdate() {
userRoleService.updateUserRole(1, Role.ADMIN)
verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>())
}
@Test
fun shouldNotDeleteUserRole() {
userRoleService.deleteUserRole(1)
verify(userRoleRepository, never()).delete(any<UserRole>())
}
@Test
fun shouldNotExpireSessionOnDelete() {
userRoleService.deleteUserRole(1)
verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>())
}
}
companion object {
private fun dummyPrincipal() = DefaultOidcUser(
listOf(),
OidcIdToken(
"anytokenvalue",
Instant.now(),
Instant.now().plusSeconds(10),
mapOf("sub" to "testsub", "preferred_username" to "patrick.tester")
)
)
private fun dummySessions() = listOf(
SessionInformation(
dummyPrincipal(),
"SESSIONID1",
Date.from(Instant.now()),
),
SessionInformation(
dummyPrincipal(),
"SESSIONID2",
Date.from(Instant.now()),
)
)
}
}

Some files were not shown because too many files have changed in this diff Show More