diff options
Diffstat (limited to 'src/main')
21 files changed, 742 insertions, 567 deletions
diff --git a/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java b/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java index 51bfd50..e339ba4 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java @@ -1,13 +1,9 @@ package dev.dnpm.etl.processor.consent; public enum ConsentDomain { - /** - * MII Broad consent - */ - BROAD_CONSENT, + /** MII Broad consent */ + BROAD_CONSENT, - /** - * GenomDe Modellvorhaben §64e - */ - MODELLVORHABEN_64E + /** GenomDe Modellvorhaben §64e */ + MODELLVORHABEN_64E } diff --git a/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java b/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java index cc0491d..a69ba53 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java @@ -4,11 +4,17 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.parser.DataFormatException; import dev.dnpm.etl.processor.config.AppFhirConfig; import dev.dnpm.etl.processor.config.GIcsConfigProperties; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Date; +import kotlin.random.Random; +import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang3.StringUtils; import org.apache.hc.core5.net.URIBuilder; import org.hl7.fhir.r4.model.*; import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; -import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpEntity; @@ -20,10 +26,6 @@ import org.springframework.retry.support.RetryTemplate; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Date; - /** * Service to request Consent from remote gICS installation * @@ -31,294 +33,372 @@ import java.util.Date; */ public class GicsConsentService implements IConsentService { - private final Logger log = LoggerFactory.getLogger(GicsConsentService.class); - - public static final String IS_CONSENTED_ENDPOINT = "/$isConsented"; - public static final String IS_POLICY_STATES_FOR_PERSON_ENDPOINT = "/$currentPolicyStatesForPerson"; - - private final RetryTemplate retryTemplate; - private final RestTemplate restTemplate; - private final FhirContext fhirContext; - private final GIcsConfigProperties gIcsConfigProperties; - - public GicsConsentService( - GIcsConfigProperties gIcsConfigProperties, - RetryTemplate retryTemplate, - RestTemplate restTemplate, - AppFhirConfig appFhirConfig - ) { - this.retryTemplate = retryTemplate; - this.restTemplate = restTemplate; - this.fhirContext = appFhirConfig.fhirContext(); - this.gIcsConfigProperties = gIcsConfigProperties; - log.info("GicsConsentService initialized..."); - } - - protected Parameters getFhirRequestParameters( - String personIdentifierValue - ) { - var result = new Parameters(); - result.addParameter( - new ParametersParameterComponent() - .setName("personIdentifier") - .setValue( - new Identifier() - .setValue(personIdentifierValue) - .setSystem(this.gIcsConfigProperties.getPersonIdentifierSystem()) - ) - ); - result.addParameter( - new ParametersParameterComponent() - .setName("domain") - .setValue( - new StringType() - .setValue(this.gIcsConfigProperties.getBroadConsentDomainName()) - ) - ); - result.addParameter( - new ParametersParameterComponent() - .setName("policy") - .setValue( - new Coding() - .setCode(this.gIcsConfigProperties.getBroadConsentPolicyCode()) - .setSystem(this.gIcsConfigProperties.getBroadConsentPolicySystem()) - ) - ); - - /* - * is mandatory parameter, but we ignore it via additional configuration parameter - * 'ignoreVersionNumber'. - */ - result.addParameter( - new ParametersParameterComponent() - .setName("version") - .setValue(new StringType().setValue("1.1") - ) - ); - - /* add config parameter with: - * ignoreVersionNumber -> true ->> Reason is we cannot know which policy version each patient - * has possibly signed or not, therefore we are happy with any version found. - * unknownStateIsConsideredAsDecline -> true - */ - var config = new ParametersParameterComponent() + private final Logger log = LoggerFactory.getLogger(GicsConsentService.class); + + public static final String IS_CONSENTED_ENDPOINT = "/$isConsented"; + public static final String IS_POLICY_STATES_FOR_PERSON_ENDPOINT = + "/$currentPolicyStatesForPerson"; + private static final String BROAD_CONSENT_PROFILE_URI = + "https://www.medizininformatik-initiative.de/fhir/modul-consent/StructureDefinition/mii-pr-consent-einwilligung"; + private static final String BROAD_CONSENT_POLICY = + "urn:oid:2.16.840.1.113883.3.1937.777.24.2.1791"; + + private final RetryTemplate retryTemplate; + private final RestTemplate restTemplate; + private final FhirContext fhirContext; + private final GIcsConfigProperties gIcsConfigProperties; + + public GicsConsentService( + GIcsConfigProperties gIcsConfigProperties, + RetryTemplate retryTemplate, + RestTemplate restTemplate, + AppFhirConfig appFhirConfig) { + this.retryTemplate = retryTemplate; + this.restTemplate = restTemplate; + this.fhirContext = appFhirConfig.fhirContext(); + this.gIcsConfigProperties = gIcsConfigProperties; + log.info("GicsConsentService initialized..."); + } + + protected Parameters getFhirRequestParameters(String personIdentifierValue) { + var result = new Parameters(); + result.addParameter( + new ParametersParameterComponent() + .setName("personIdentifier") + .setValue( + new Identifier() + .setValue(personIdentifierValue) + .setSystem(this.gIcsConfigProperties.getPersonIdentifierSystem()))); + result.addParameter( + new ParametersParameterComponent() + .setName("domain") + .setValue( + new StringType().setValue(this.gIcsConfigProperties.getBroadConsentDomainName()))); + result.addParameter( + new ParametersParameterComponent() + .setName("policy") + .setValue( + new Coding() + .setCode(this.gIcsConfigProperties.getBroadConsentPolicyCode()) + .setSystem(this.gIcsConfigProperties.getBroadConsentPolicySystem()))); + + /* + * is mandatory parameter, but we ignore it via additional configuration parameter + * 'ignoreVersionNumber'. + */ + result.addParameter( + new ParametersParameterComponent() + .setName("version") + .setValue(new StringType().setValue("1.1"))); + + /* add config parameter with: + * ignoreVersionNumber -> true ->> Reason is we cannot know which policy version each patient + * has possibly signed or not, therefore we are happy with any version found. + * unknownStateIsConsideredAsDecline -> true + */ + var config = + new ParametersParameterComponent() .setName("config") .addPart( new ParametersParameterComponent() .setName("ignoreVersionNumber") - .setValue(new BooleanType().setValue(true)) - ) + .setValue(new BooleanType().setValue(true))) .addPart( new ParametersParameterComponent() .setName("unknownStateIsConsideredAsDecline") - .setValue(new BooleanType().setValue(false)) - ); + .setValue(new BooleanType().setValue(false))); - result.addParameter(config); + result.addParameter(config); - return result; - } - - private URI endpointUri(String endpoint) throws URISyntaxException { - if (null == this.gIcsConfigProperties.getUri()) { - throw new URISyntaxException("null", "URI must not be null"); - } - var gPasUrl1 = this.gIcsConfigProperties.getUri(); - if (this.gIcsConfigProperties.getUri().lastIndexOf("/") - == this.gIcsConfigProperties.getUri().length() - 1) { - gPasUrl1 = - this.gIcsConfigProperties - .getUri() - .substring(0, this.gIcsConfigProperties.getUri().length() - 1); - } - var urlBuilder = new URIBuilder(new URI(gPasUrl1)).appendPath(endpoint); + return result; + } - return urlBuilder.build(); + private URI endpointUri(String endpoint) throws URISyntaxException { + if (null == this.gIcsConfigProperties.getUri()) { + throw new URISyntaxException("null", "URI must not be null"); } + var gPasUrl1 = this.gIcsConfigProperties.getUri(); + if (this.gIcsConfigProperties.getUri().lastIndexOf("/") + == this.gIcsConfigProperties.getUri().length() - 1) { + gPasUrl1 = + this.gIcsConfigProperties + .getUri() + .substring(0, this.gIcsConfigProperties.getUri().length() - 1); + } + var urlBuilder = new URIBuilder(new URI(gPasUrl1)).appendPath(endpoint); - private HttpHeaders headersWithHttpBasicAuth() { - assert this.gIcsConfigProperties.getUri() != null; + return urlBuilder.build(); + } - var headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_XML); + private HttpHeaders headersWithHttpBasicAuth() { + assert this.gIcsConfigProperties.getUri() != null; - if ( - StringUtils.isBlank(this.gIcsConfigProperties.getUsername()) - || StringUtils.isBlank(this.gIcsConfigProperties.getPassword()) - ) { - return headers; - } + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_XML); - headers.setBasicAuth(this.gIcsConfigProperties.getUsername(), this.gIcsConfigProperties.getPassword()); - return headers; + if (StringUtils.isBlank(this.gIcsConfigProperties.getUsername()) + || StringUtils.isBlank(this.gIcsConfigProperties.getPassword())) { + return headers; } - protected String callGicsApi(Parameters parameter, String endpoint) { - var parameterAsXml = fhirContext.newXmlParser().encodeResourceToString(parameter); - HttpEntity<String> requestEntity = new HttpEntity<>(parameterAsXml, this.headersWithHttpBasicAuth()); - try { - var responseEntity = retryTemplate.execute( - ctx -> restTemplate.exchange(endpointUri(endpoint), HttpMethod.POST, requestEntity, String.class) - ); - - if (responseEntity.getStatusCode().is2xxSuccessful()) { - return responseEntity.getBody(); - } else { - var msg = String.format( - "Trusted party system reached but request failed! code: '%s' response: '%s'", - responseEntity.getStatusCode(), responseEntity.getBody()); - log.error(msg); - return null; - } - } catch (RestClientException e) { - var msg = String.format("Get consents status request failed reason: '%s", - e.getMessage()); - log.error(msg); - return null; - - } catch (TerminatedRetryException terminatedRetryException) { - var msg = String.format( - "Get consents status process has been terminated. termination reason: '%s", - terminatedRetryException.getMessage()); - log.error(msg); - return null; - } catch (URISyntaxException e) { + headers.setBasicAuth( + this.gIcsConfigProperties.getUsername(), this.gIcsConfigProperties.getPassword()); + return headers; + } + + @Nullable + protected String callGicsApi(Parameters parameter, String endpoint) { + var parameterAsXml = fhirContext.newXmlParser().encodeResourceToString(parameter); + HttpEntity<String> requestEntity = + new HttpEntity<>(parameterAsXml, this.headersWithHttpBasicAuth()); + try { + var responseEntity = + retryTemplate.execute( + ctx -> + restTemplate.exchange( + endpointUri(endpoint), HttpMethod.POST, requestEntity, String.class)); + + if (responseEntity.getStatusCode().is2xxSuccessful()) { + return responseEntity.getBody(); + } else { + var msg = + String.format( + "Trusted party system reached but request failed! code: '%s' response: '%s'", + responseEntity.getStatusCode(), responseEntity.getBody()); + log.error(msg); + return null; + } + } catch (RestClientException e) { + var msg = String.format("Get consents status request failed reason: '%s", e.getMessage()); + log.error(msg); + return null; + + } catch (TerminatedRetryException terminatedRetryException) { + var msg = + String.format( + "Get consents status process has been terminated. termination reason: '%s", + terminatedRetryException.getMessage()); + log.error(msg); + return null; + } catch (URISyntaxException e) { var msg = String.format("Invalid URI for consents status request: '%s", e.getMessage()); log.error(msg); return null; } - } + } - @Override - public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) { - var consentStatusResponse = callGicsApi( + @Override + @NonNull + public TtpConsentStatus getTtpBroadConsentStatus(@NonNull String personIdentifierValue) { + var consentStatusResponse = + callGicsApi( getFhirRequestParameters(personIdentifierValue), - GicsConsentService.IS_CONSENTED_ENDPOINT - ); - return evaluateConsentResponse(consentStatusResponse); + GicsConsentService.IS_CONSENTED_ENDPOINT); + return evaluateConsentResponse(consentStatusResponse); + } + + protected Bundle currentConsentForPersonAndTemplate( + String personIdentifierValue, ConsentDomain consentDomain, Date requestDate) { + + var requestParameter = + buildRequestParameterCurrentPolicyStatesForPerson( + personIdentifierValue, requestDate, consentDomain); + + var consentDataSerialized = + callGicsApi(requestParameter, GicsConsentService.IS_POLICY_STATES_FOR_PERSON_ENDPOINT); + + if (consentDataSerialized == null) { + // error occurred - should not process further! + throw new IllegalStateException( + "consent data request failed - stopping processing! - try again or fix other problems first."); + } + var iBaseResource = fhirContext.newJsonParser().parseResource(consentDataSerialized); + if (iBaseResource instanceof OperationOutcome) { + // log error - very likely a configuration error + String errorMessage = "Consent request failed! Check outcome:\n " + consentDataSerialized; + log.error(errorMessage); + throw new IllegalStateException(errorMessage); + } else if (iBaseResource instanceof Bundle bundle) { + return bundle; + } else { + String errorMessage = + "Consent request failed! Unexpected response received! -> " + consentDataSerialized; + log.error(errorMessage); + throw new IllegalStateException(errorMessage); + } + } + + @NonNull + private String getConsentDomainName(ConsentDomain targetConsentDomain) { + return switch (targetConsentDomain) { + case BROAD_CONSENT -> gIcsConfigProperties.getBroadConsentDomainName(); + case MODELLVORHABEN_64E -> gIcsConfigProperties.getGenomDeConsentDomainName(); + }; + } + + protected Parameters buildRequestParameterCurrentPolicyStatesForPerson( + String personIdentifierValue, Date requestDate, ConsentDomain consentDomain) { + var requestParameter = new Parameters(); + requestParameter.addParameter( + new ParametersParameterComponent() + .setName("personIdentifier") + .setValue( + new Identifier() + .setValue(personIdentifierValue) + .setSystem(this.gIcsConfigProperties.getPersonIdentifierSystem()))); + + requestParameter.addParameter( + new ParametersParameterComponent() + .setName("domain") + .setValue(new StringType().setValue(getConsentDomainName(consentDomain)))); + + Parameters nestedConfigParameters = new Parameters(); + nestedConfigParameters + .addParameter( + new ParametersParameterComponent() + .setName("idMatchingType") + .setValue( + new Coding() + .setSystem("https://ths-greifswald.de/fhir/CodeSystem/gics/IdMatchingType") + .setCode("AT_LEAST_ONE"))) + .addParameter("ignoreVersionNumber", true) + .addParameter("unknownStateIsConsideredAsDecline", false) + .addParameter("requestDate", new DateType().setValue(requestDate)); + + requestParameter.addParameter( + new ParametersParameterComponent() + .setName("config") + .addPart() + .setResource(nestedConfigParameters)); + + return requestParameter; + } + + private TtpConsentStatus evaluateConsentResponse(@Nullable String consentStatusResponse) { + if (consentStatusResponse == null) { + return TtpConsentStatus.FAILED_TO_ASK; } + try { + var response = fhirContext.newJsonParser().parseResource(consentStatusResponse); + + if (response instanceof Parameters responseParameters) { - protected Bundle currentConsentForPersonAndTemplate( - String personIdentifierValue, - ConsentDomain consentDomain, - Date requestDate - ) { - - var requestParameter = buildRequestParameterCurrentPolicyStatesForPerson( - personIdentifierValue, - requestDate, - consentDomain - ); - - var consentDataSerialized = callGicsApi(requestParameter, - GicsConsentService.IS_POLICY_STATES_FOR_PERSON_ENDPOINT); - - if (consentDataSerialized == null) { - // error occurred - should not process further! - throw new IllegalStateException( - "consent data request failed - stopping processing! - try again or fix other problems first."); + var responseValue = responseParameters.getParameter("consented").getValue(); + var isConsented = responseValue.castToBoolean(responseValue); + if (!isConsented.hasValue()) { + return TtpConsentStatus.FAILED_TO_ASK; } - var iBaseResource = fhirContext.newJsonParser() - .parseResource(consentDataSerialized); - if (iBaseResource instanceof OperationOutcome) { - // log error - very likely a configuration error - String errorMessage = - "Consent request failed! Check outcome:\n " + consentDataSerialized; - log.error(errorMessage); - throw new IllegalStateException(errorMessage); - } else if (iBaseResource instanceof Bundle bundle) { - return bundle; + if (isConsented.booleanValue()) { + return TtpConsentStatus.BROAD_CONSENT_GIVEN; } else { - String errorMessage = "Consent request failed! Unexpected response received! -> " - + consentDataSerialized; - log.error(errorMessage); - throw new IllegalStateException(errorMessage); + return TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED; } + } else if (response instanceof OperationOutcome outcome) { + log.error( + "failed to get consent status from ttp. probably configuration error. " + + "outcome: '{}'", + fhirContext.newJsonParser().encodeToString(outcome)); + } + } catch (DataFormatException dfe) { + log.error("failed to parse response to FHIR R4 resource.", dfe); } - - @NotNull - private String getConsentDomainName(ConsentDomain targetConsentDomain) { - return switch (targetConsentDomain) { - case BROAD_CONSENT -> gIcsConfigProperties.getBroadConsentDomainName(); - case MODELLVORHABEN_64E -> gIcsConfigProperties.getGenomDeConsentDomainName(); - }; + return TtpConsentStatus.FAILED_TO_ASK; + } + + @Override + @NonNull + public Bundle getConsent( + @NonNull String patientId, @NonNull Date requestDate, @NonNull ConsentDomain consentDomain) { + Bundle gIcsResultBundle = + currentConsentForPersonAndTemplate(patientId, consentDomain, requestDate); + if (ConsentDomain.BROAD_CONSENT == consentDomain) { + return anonymizeBroadConsent(convertGicsResultToMiiBroadConsent(gIcsResultBundle)); } + return gIcsResultBundle; + } - protected Parameters buildRequestParameterCurrentPolicyStatesForPerson( - String personIdentifierValue, - Date requestDate, - ConsentDomain consentDomain - ) { - var requestParameter = new Parameters(); - requestParameter.addParameter( - new ParametersParameterComponent() - .setName("personIdentifier") - .setValue( - new Identifier() - .setValue(personIdentifierValue) - .setSystem(this.gIcsConfigProperties.getPersonIdentifierSystem()) - ) - ); + protected Bundle convertGicsResultToMiiBroadConsent(Bundle gIcsResultBundle) { + if (gIcsResultBundle == null + || gIcsResultBundle.getEntry().isEmpty() + || !(gIcsResultBundle.getEntry().getFirst().getResource() instanceof Consent)) + return gIcsResultBundle; - requestParameter.addParameter( - new ParametersParameterComponent() - .setName("domain") - .setValue(new StringType().setValue(getConsentDomainName(consentDomain))) - ); + Bundle.BundleEntryComponent bundleEntryComponent = gIcsResultBundle.getEntry().getFirst(); - Parameters nestedConfigParameters = new Parameters(); - nestedConfigParameters - .addParameter( - new ParametersParameterComponent() - .setName("idMatchingType") - .setValue(new Coding() - .setSystem("https://ths-greifswald.de/fhir/CodeSystem/gics/IdMatchingType") - .setCode("AT_LEAST_ONE") - ) - ) - .addParameter("ignoreVersionNumber", false) - .addParameter("unknownStateIsConsideredAsDecline", false) - .addParameter("requestDate", new DateType().setValue(requestDate)); - - requestParameter.addParameter( - new ParametersParameterComponent().setName("config").addPart().setResource(nestedConfigParameters) - ); - - return requestParameter; + var consentAsOne = (Consent) bundleEntryComponent.getResource(); + + if (isMiiConsent(consentAsOne)) { + return gIcsResultBundle; } - private TtpConsentStatus evaluateConsentResponse(String consentStatusResponse) { - if (consentStatusResponse == null) { - return TtpConsentStatus.FAILED_TO_ASK; - } - try { - var response = fhirContext.newJsonParser().parseResource(consentStatusResponse); - - if (response instanceof Parameters responseParameters) { - - var responseValue = responseParameters.getParameter("consented").getValue(); - var isConsented = responseValue.castToBoolean(responseValue); - if (!isConsented.hasValue()) { - return TtpConsentStatus.FAILED_TO_ASK; - } - if (isConsented.booleanValue()) { - return TtpConsentStatus.BROAD_CONSENT_GIVEN; - } else { - return TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED; - } - } else if (response instanceof OperationOutcome outcome) { - log.error("failed to get consent status from ttp. probably configuration error. " - + "outcome: '{}'", fhirContext.newJsonParser().encodeToString(outcome)); - - } - } catch (DataFormatException dfe) { - log.error("failed to parse response to FHIR R4 resource.", dfe); - } - return TtpConsentStatus.FAILED_TO_ASK; + if (consentAsOne.getPolicy().stream().noneMatch(p -> p.getUri().equals(BROAD_CONSENT_POLICY))) { + consentAsOne.addPolicy(new Consent.ConsentPolicyComponent().setUri(BROAD_CONSENT_POLICY)); } - @Override - public Bundle getConsent(String patientId, Date requestDate, ConsentDomain consentDomain) { - return currentConsentForPersonAndTemplate(patientId, consentDomain, requestDate); + if (consentAsOne.getMeta().getProfile().stream() + .noneMatch(p -> p.getValue().equals(BROAD_CONSENT_PROFILE_URI))) { + consentAsOne.getMeta().addProfile(BROAD_CONSENT_PROFILE_URI); + } + + consentAsOne.setPolicyRule(null); + + consentAsOne + .getCategory() + .removeIf( + category -> + category.hasCoding( + "http://fhir.de/ConsentManagement/CodeSystem/ResultType", "policy")); + + final var miiConsentCategory = new CodeableConcept(); + miiConsentCategory.addCoding( + new Coding() + .setSystem( + "https://www.medizininformatik-initiative.de/fhir/modul-consent/CodeSystem/mii-cs-consent-consent_category") + .setCode("2.16.840.1.113883.3.1937.777.24.2.184")); + consentAsOne.addCategory(miiConsentCategory); + + gIcsResultBundle.getEntry().stream() + .skip(1) + .forEach( + c -> + consentAsOne + .getProvision() + .addProvision( + ((Consent) c.getResource()).getProvision().getProvisionFirstRep())); + + gIcsResultBundle.getEntry().clear(); + gIcsResultBundle.addEntry(bundleEntryComponent); + return gIcsResultBundle; + } + + private static boolean isMiiConsent(Consent consent) { + for (var category : consent.getCategory()) { + for (var categoryCoding : category.getCoding()) { + if ("https://www.medizininformatik-initiative.de/fhir/modul-consent/CodeSystem/mii-cs-consent-consent_category" + .equals(categoryCoding.getSystem()) + && "2.16.840.1.113883.3.1937.777.24.2.184".equals(categoryCoding.getCode())) { + return true; + } + } } + return false; + } + + protected Bundle anonymizeBroadConsent(Bundle bundle) { + Bundle.BundleEntryComponent bundleEntryComponent = bundle.getEntry().getFirst(); + hashBundleEntry(bundleEntryComponent); + return bundle; + } + + private static void hashBundleEntry(Bundle.BundleEntryComponent entry) { + String id = entry.getResource().getIdPart(); + var hash = DigestUtils.sha256Hex("%s_%s".formatted(Random.Default.toString(), id)); + + entry.getResource().setId(hash); + entry.setFullUrl(entry.getFullUrl().replace(id, hash)); + var consent = (Consent) entry.getResource(); + consent + .getSource() + .setProperty("reference", new StringType("QuestionnaireResponse/%s".formatted(hash))); + } } diff --git a/src/main/java/dev/dnpm/etl/processor/consent/IConsentService.java b/src/main/java/dev/dnpm/etl/processor/consent/IConsentService.java index ded3515..f9230ec 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/IConsentService.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/IConsentService.java @@ -2,26 +2,27 @@ package dev.dnpm.etl.processor.consent; import java.util.Date; import org.hl7.fhir.r4.model.Bundle; +import org.jspecify.annotations.NullMarked; +@NullMarked public interface IConsentService { - /** - * Get broad consent status for a patient identifier - * - * @param personIdentifierValue patient identifier used for consent data - * @return status of broad consent - * @apiNote cannot not differ between not asked and rejected - * - */ - TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue); - - /** - * Get broad consent policies with respect to a request date - * - * @param personIdentifierValue patient identifier used for consent data - * @param requestDate target date until consent data should be considered - * @return consent policies as bundle; <p>if empty patient has not been asked, yet.</p> - */ - Bundle getConsent(String personIdentifierValue, Date requestDate, ConsentDomain consentDomain); + /** + * Get broad consent status for a patient identifier + * + * @param personIdentifierValue patient identifier used for consent data + * @return status of broad consent + * @apiNote cannot not differ between not asked and rejected + */ + TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue); + /** + * Get broad consent policies with respect to a request date + * + * @param personIdentifierValue patient identifier used for consent data + * @param requestDate target date until consent data should be considered + * @return consent policies as bundle; + * <p>if empty patient has not been asked, yet. + */ + Bundle getConsent(String personIdentifierValue, Date requestDate, ConsentDomain consentDomain); } diff --git a/src/main/java/dev/dnpm/etl/processor/consent/MtbFileConsentService.java b/src/main/java/dev/dnpm/etl/processor/consent/MtbFileConsentService.java index 24cb8f7..cc8107f 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/MtbFileConsentService.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/MtbFileConsentService.java @@ -2,30 +2,35 @@ package dev.dnpm.etl.processor.consent; import java.util.Date; import org.hl7.fhir.r4.model.Bundle; +import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class MtbFileConsentService implements IConsentService { - private static final Logger log = LoggerFactory.getLogger(MtbFileConsentService.class); + private static final Logger log = LoggerFactory.getLogger(MtbFileConsentService.class); - public MtbFileConsentService() { - log.info("ConsentCheckFileBased initialized..."); - } + public MtbFileConsentService() { + log.info("ConsentCheckFileBased initialized..."); + } - @Override - public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) { - return TtpConsentStatus.UNKNOWN_CHECK_FILE; - } + @Override + @NonNull + public TtpConsentStatus getTtpBroadConsentStatus(@NonNull String personIdentifierValue) { + return TtpConsentStatus.UNKNOWN_CHECK_FILE; + } - /** - * EMPTY METHOD: NOT IMPLEMENTED - * - * @return empty bundle - */ - @Override - public Bundle getConsent(String personIdentifierValue, Date requestDate, - ConsentDomain consentDomain) { - return new Bundle(); - } + /** + * EMPTY METHOD: NOT IMPLEMENTED + * + * @return empty bundle + */ + @Override + @NonNull + public Bundle getConsent( + @NonNull String personIdentifierValue, + @NonNull Date requestDate, + @NonNull ConsentDomain consentDomain) { + return new Bundle(); + } } diff --git a/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java b/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java index 2af1683..b92f58d 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java @@ -1,38 +1,22 @@ package dev.dnpm.etl.processor.consent; public enum TtpConsentStatus { - /** - * Valid consent found - */ - BROAD_CONSENT_GIVEN, - /** - * Missing or rejected...actually unknown - */ - BROAD_CONSENT_MISSING_OR_REJECTED, - /** - * No Broad consent policy found - */ - BROAD_CONSENT_MISSING, - /** - * Research policy has been rejected - */ - BROAD_CONSENT_REJECTED, + /** Valid consent found */ + BROAD_CONSENT_GIVEN, + /** Missing or rejected...actually unknown */ + BROAD_CONSENT_MISSING_OR_REJECTED, + /** No Broad consent policy found */ + BROAD_CONSENT_MISSING, + /** Research policy has been rejected */ + BROAD_CONSENT_REJECTED, - GENOM_DE_CONSENT_SEQUENCING_PERMIT, - /** - * No GenomDE consent policy found - */ - GENOM_DE_CONSENT_MISSING, - /** - * GenomDE consent policy found, but has been rejected - */ - GENOM_DE_SEQUENCING_REJECTED, - /** - * Consent status is validate via file property 'consent.status' - */ - UNKNOWN_CHECK_FILE, - /** - * Due technical problems consent status is unknown - */ - FAILED_TO_ASK + GENOM_DE_CONSENT_SEQUENCING_PERMIT, + /** No GenomDE consent policy found */ + GENOM_DE_CONSENT_MISSING, + /** GenomDE consent policy found, but has been rejected */ + GENOM_DE_SEQUENCING_REJECTED, + /** Consent status is validate via file property 'consent.status' */ + UNKNOWN_CHECK_FILE, + /** Due technical problems consent status is unknown */ + FAILED_TO_ASK } diff --git a/src/main/java/dev/dnpm/etl/processor/pseudonym/Generator.java b/src/main/java/dev/dnpm/etl/processor/pseudonym/Generator.java index 8d0d0c1..e72a8f8 100644 --- a/src/main/java/dev/dnpm/etl/processor/pseudonym/Generator.java +++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/Generator.java @@ -19,10 +19,12 @@ package dev.dnpm.etl.processor.pseudonym; -public interface Generator { +import org.jspecify.annotations.NullMarked; - String generate(String id); +@NullMarked +public interface Generator { - String generateGenomDeTan(String id); + String generate(String id); + String generateGenomDeTan(String id); } diff --git a/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java b/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java index 6a2b947..478c5c9 100644 --- a/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java +++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java @@ -32,194 +32,202 @@ import org.hl7.fhir.r4.model.Identifier; import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; import org.hl7.fhir.r4.model.StringType; -import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.*; import org.springframework.retry.support.RetryTemplate; import org.springframework.web.client.HttpClientErrorException.BadRequest; import org.springframework.web.client.HttpClientErrorException.Unauthorized; -import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; public class GpasPseudonymGenerator implements Generator { - private final FhirContext r4Context; - private final String gPasUrl; - private final HttpHeaders httpHeader; - private final RetryTemplate retryTemplate; - private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class); - private final RestTemplate restTemplate; - private final @NotNull String genomDeTanDomain; - private final @NotNull String pidPsnDomain; - protected final static String createOrGetPsn = "$pseudonymizeAllowCreate"; - protected final static String createMultiDomainPsn = "$pseudonymize-secondary"; - private final static String SINGLE_PSN_PART_NAME = "pseudonym"; - private final static String MULTI_PSN_PART_NAME = "value"; - - public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, - RestTemplate restTemplate, AppFhirConfig appFhirConfig) { - this.retryTemplate = retryTemplate; - this.restTemplate = restTemplate; - this.gPasUrl = gpasCfg.getUri(); - this.pidPsnDomain = gpasCfg.getPatientDomain(); - this.genomDeTanDomain = gpasCfg.getGenomDeTanDomain(); - this.r4Context = appFhirConfig.fhirContext(); - httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword()); - - log.debug("{} has been initialized", this.getClass().getName()); - + private final FhirContext r4Context; + private final @Nullable String gPasUrl; + private final HttpHeaders httpHeader; + private final RetryTemplate retryTemplate; + private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class); + private final RestTemplate restTemplate; + private final @NonNull String genomDeTanDomain; + private final @NonNull String pidPsnDomain; + protected static final String CREATE_OR_GET_PSN = "$pseudonymizeAllowCreate"; + protected static final String CREATE_MULTI_DOMAIN_PSN = "$pseudonymize-secondary"; + private static final String SINGLE_PSN_PART_NAME = "pseudonym"; + private static final String MULTI_PSN_PART_NAME = "value"; + + public GpasPseudonymGenerator( + GPasConfigProperties gpasCfg, + RetryTemplate retryTemplate, + RestTemplate restTemplate, + AppFhirConfig appFhirConfig) { + this.retryTemplate = retryTemplate; + this.restTemplate = restTemplate; + this.gPasUrl = gpasCfg.getUri(); + this.pidPsnDomain = gpasCfg.getPatientDomain(); + this.genomDeTanDomain = gpasCfg.getGenomDeTanDomain(); + this.r4Context = appFhirConfig.fhirContext(); + httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword()); + + log.debug("{} has been initialized", this.getClass().getName()); + } + + @Override + @NonNull + public String generate(@NonNull String id) { + return generate(id, PsnDomainType.SINGLE_PSN_DOMAIN); + } + + @Override + @NonNull + public String generateGenomDeTan(@NonNull String id) { + return generate(id, PsnDomainType.MULTI_PSN_DOMAIN); + } + + protected String generate(String id, PsnDomainType domainType) { + switch (domainType) { + case SINGLE_PSN_DOMAIN -> { + final var requestBody = createSinglePsnRequestBody(id, pidPsnDomain); + final var responseEntity = getGpasPseudonym(requestBody, CREATE_OR_GET_PSN); + final var gPasPseudonymResult = + (Parameters) r4Context.newJsonParser().parseResource(responseEntity.getBody()); + + return unwrapPseudonym(gPasPseudonymResult, SINGLE_PSN_PART_NAME); + } + case MULTI_PSN_DOMAIN -> { + final var requestBody = createMultiPsnRequestBody(id, genomDeTanDomain); + final var responseEntity = getGpasPseudonym(requestBody, CREATE_MULTI_DOMAIN_PSN); + final var gPasPseudonymResult = + (Parameters) r4Context.newJsonParser().parseResource(responseEntity.getBody()); + + return unwrapPseudonym(gPasPseudonymResult, MULTI_PSN_PART_NAME); + } } + throw new NotImplementedException( + "give domain type '%s' is unexpected and is currently not supported!" + .formatted(domainType)); + } - @Override - public String generate(String id) { - return generate(id, PsnDomainType.SINGLE_PSN_DOMAIN); - } + @NonNull + public static String unwrapPseudonym(Parameters gPasPseudonymResult, String targetPartName) { + final var parameters = gPasPseudonymResult.getParameter().stream().findFirst(); - @Override - public String generateGenomDeTan(String id) { - return generate(id, PsnDomainType.MULTI_PSN_DOMAIN); + if (parameters.isEmpty()) { + throw new PseudonymRequestFailed("Empty HL7 parameters, cannot find first one"); } - protected String generate(String id, PsnDomainType domainType) { - switch (domainType) { - case SINGLE_PSN_DOMAIN -> { - final var requestBody = createSinglePsnRequestBody(id, pidPsnDomain); - final var responseEntity = getGpasPseudonym(requestBody, createOrGetPsn); - final var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() - .parseResource(responseEntity.getBody()); - - return unwrapPseudonym(gPasPseudonymResult, SINGLE_PSN_PART_NAME); - } - case MULTI_PSN_DOMAIN -> { - final var requestBody = createMultiPsnRequestBody(id, genomDeTanDomain); - final var responseEntity = getGpasPseudonym(requestBody, createMultiDomainPsn); - final var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() - .parseResource(responseEntity.getBody()); - - return unwrapPseudonym(gPasPseudonymResult, MULTI_PSN_PART_NAME); - } - } - throw new NotImplementedException( - "give domain type '%s' is unexpected and is currently not supported!".formatted( - domainType)); + final var identifier = + (Identifier) + parameters.get().getPart().stream() + .filter(a -> a.getName().equals(targetPartName)) + .findFirst() + .orElseGet(ParametersParameterComponent::new) + .getValue(); + + // pseudonym + return sanitizeValue(identifier.getValue()); + } + + /** + * Allow only filename friendly values + * + * @param psnValue GAPS pseudonym value + * @return cleaned up value + */ + public static String sanitizeValue(String psnValue) { + // pattern to match forbidden characters + String forbiddenCharsRegex = "[\\\\/:*?\"<>|;]"; + + // Replace all forbidden characters with underscores + return psnValue.replaceAll(forbiddenCharsRegex, "_"); + } + + @NonNull + protected ResponseEntity<String> getGpasPseudonym(String gPasRequestBody, String apiEndpoint) { + + HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader); + + try { + var targetUrl = buildRequestUrl(apiEndpoint); + ResponseEntity<String> responseEntity = + retryTemplate.execute( + ctx -> + restTemplate.exchange(targetUrl, HttpMethod.POST, requestEntity, String.class)); + if (responseEntity.getStatusCode().is2xxSuccessful()) { + log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode()); + return responseEntity; + } + } catch (BadRequest e) { + String msg = + "gPas or request configuration is incorrect. Please check both." + e.getMessage(); + log.error(msg); + throw new PseudonymRequestFailed(msg, e); + } catch (Unauthorized e) { + var msg = + "gPas access credentials are invalid check your configuration. msg: '%s" + .formatted(e.getMessage()); + log.error(msg); + throw new PseudonymRequestFailed(msg, e); + } catch (Exception unexpected) { + throw new PseudonymRequestFailed( + "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected); } + throw new PseudonymRequestFailed( + "API request due unexpected error unsuccessful gPas unsuccessful."); + } - @NotNull - public static String unwrapPseudonym(Parameters gPasPseudonymResult, String targetPartName) { - final var parameters = gPasPseudonymResult.getParameter().stream().findFirst(); - - if (parameters.isEmpty()) { - throw new PseudonymRequestFailed("Empty HL7 parameters, cannot find first one"); - } - - final var identifier = (Identifier) parameters.get().getPart().stream() - .filter(a -> a.getName().equals(targetPartName)) - .findFirst() - .orElseGet(ParametersParameterComponent::new).getValue(); - - // pseudonym - return sanitizeValue(identifier.getValue()); + protected URI buildRequestUrl(String apiEndpoint) throws URISyntaxException { + if (null == gPasUrl) { + throw new URISyntaxException("null", "URI must not be null"); } - - /** - * Allow only filename friendly values - * - * @param psnValue GAPS pseudonym value - * @return cleaned up value - */ - public static String sanitizeValue(String psnValue) { - // pattern to match forbidden characters - String forbiddenCharsRegex = "[\\\\/:*?\"<>|;]"; - - // Replace all forbidden characters with underscores - return psnValue.replaceAll(forbiddenCharsRegex, "_"); + var gPasUrl1 = gPasUrl; + if (gPasUrl.lastIndexOf("/") == gPasUrl.length() - 1) { + gPasUrl1 = gPasUrl.substring(0, gPasUrl.length() - 1); } - - @NotNull - protected ResponseEntity<String> getGpasPseudonym(String gPasRequestBody, String apiEndpoint) { - - HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader); - - try { - var targetUrl = buildRequestUrl(apiEndpoint); - ResponseEntity<String> responseEntity = retryTemplate.execute( - ctx -> restTemplate.exchange(targetUrl, HttpMethod.POST, requestEntity, - String.class)); - if (responseEntity.getStatusCode().is2xxSuccessful()) { - log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode()); - return responseEntity; - } - } catch (RestClientException rce) { - if (rce instanceof BadRequest) { - String msg = "gPas or request configuration is incorrect. Please check both." - + rce.getMessage(); - log.debug( - msg); - throw new PseudonymRequestFailed(msg, rce); - } - if (rce instanceof Unauthorized) { - var msg = "gPas access credentials are invalid check your configuration. msg: '%s".formatted( - rce.getMessage()); - log.error(msg); - throw new PseudonymRequestFailed(msg, rce); - } - } catch (Exception unexpected) { - throw new PseudonymRequestFailed( - "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected); - } - throw new PseudonymRequestFailed( - "API request due unexpected error unsuccessful gPas unsuccessful."); - + var urlBuilder = new URIBuilder(new URI(gPasUrl1)).appendPath(apiEndpoint); + + return urlBuilder.build(); + } + + protected String createSinglePsnRequestBody(String id, String targetDomain) { + final var requestParameters = new Parameters(); + requestParameters + .addParameter() + .setName("target") + .setValue(new StringType().setValue(targetDomain)); + requestParameters.addParameter().setName("original").setValue(new StringType().setValue(id)); + final IParser iParser = r4Context.newJsonParser(); + return iParser.encodeResourceToString(requestParameters); + } + + protected String createMultiPsnRequestBody(String id, String targetDomain) { + final var param = new Parameters(); + ParametersParameterComponent targetParam = param.addParameter().setName("original"); + targetParam.addPart( + new ParametersParameterComponent() + .setName("target") + .setValue(new StringType(targetDomain))); + targetParam.addPart( + new ParametersParameterComponent().setName("value").setValue(new StringType(id))); + targetParam.addPart( + new ParametersParameterComponent().setName("count").setValue(new StringType("1"))); + + final IParser iParser = r4Context.newJsonParser(); + return iParser.encodeResourceToString(param); + } + + @NonNull + protected HttpHeaders getHttpHeaders( + @Nullable String gPasUserName, @Nullable String gPasPassword) { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + if (StringUtils.isBlank(gPasUserName) || StringUtils.isBlank(gPasPassword)) { + return headers; } - protected URI buildRequestUrl(String apiEndpoint) throws URISyntaxException { - var gPasUrl1 = gPasUrl; - if (gPasUrl.lastIndexOf("/") == gPasUrl.length() - 1) { - gPasUrl1 = gPasUrl.substring(0, gPasUrl.length() - 1); - } - var urlBuilder = new URIBuilder(new URI(gPasUrl1)).appendPath(apiEndpoint); - - return urlBuilder.build(); - } - - protected String createSinglePsnRequestBody(String id, String targetDomain) { - final var requestParameters = new Parameters(); - requestParameters.addParameter().setName("target") - .setValue(new StringType().setValue(targetDomain)); - requestParameters.addParameter().setName("original") - .setValue(new StringType().setValue(id)); - final IParser iParser = r4Context.newJsonParser(); - return iParser.encodeResourceToString(requestParameters); - } - - protected String createMultiPsnRequestBody(String id, String targetDomain) { - final var param = new Parameters(); - ParametersParameterComponent targetParam = param.addParameter().setName("original"); - targetParam.addPart( - new ParametersParameterComponent().setName("target") - .setValue(new StringType(targetDomain))); - targetParam.addPart( - new ParametersParameterComponent().setName("value").setValue(new StringType(id))); - targetParam - .addPart(new ParametersParameterComponent().setName("count").setValue( - new StringType("1"))); - - final IParser iParser = r4Context.newJsonParser(); - return iParser.encodeResourceToString(param); - } - - - @NotNull - protected HttpHeaders getHttpHeaders(String gPasUserName, String gPasPassword) { - var headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - if (StringUtils.isBlank(gPasUserName) || StringUtils.isBlank(gPasPassword)) { - return headers; - } - - headers.setBasicAuth(gPasUserName, gPasPassword); - return headers; - } + headers.setBasicAuth(gPasUserName, gPasPassword); + return headers; + } } diff --git a/src/main/java/dev/dnpm/etl/processor/pseudonym/PseudonymRequestFailed.java b/src/main/java/dev/dnpm/etl/processor/pseudonym/PseudonymRequestFailed.java index 79b4ba6..397455a 100644 --- a/src/main/java/dev/dnpm/etl/processor/pseudonym/PseudonymRequestFailed.java +++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/PseudonymRequestFailed.java @@ -2,11 +2,11 @@ package dev.dnpm.etl.processor.pseudonym; public class PseudonymRequestFailed extends RuntimeException { - public PseudonymRequestFailed(String message) { - super(message); - } + public PseudonymRequestFailed(String message) { + super(message); + } - public PseudonymRequestFailed(String message, Throwable cause) { - super(message, cause); - } + public PseudonymRequestFailed(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/dev/dnpm/etl/processor/pseudonym/PsnDomainType.java b/src/main/java/dev/dnpm/etl/processor/pseudonym/PsnDomainType.java index a0fbc93..55cb212 100644 --- a/src/main/java/dev/dnpm/etl/processor/pseudonym/PsnDomainType.java +++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/PsnDomainType.java @@ -1,12 +1,8 @@ package dev.dnpm.etl.processor.pseudonym; public enum PsnDomainType { - /** - * one pseudonym per original value - */ - SINGLE_PSN_DOMAIN, - /** - * multiple pseudonymes for one original value - */ - MULTI_PSN_DOMAIN + /** one pseudonym per original value */ + SINGLE_PSN_DOMAIN, + /** multiple pseudonymes for one original value */ + MULTI_PSN_DOMAIN } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt index e5db63e..ee33114 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -48,6 +48,7 @@ data class PseudonymizeConfigProperties( @ConfigurationProperties(GPasConfigProperties.NAME) data class GPasConfigProperties( val uri: String?, + val soapEndpoint: String?, @get:DeprecatedConfigurationProperty(since = "0.12") val pidDomain: String?, val patientDomain: String = pidDomain ?: "etl-processor", diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt index b4fad3e..de302fd 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt @@ -20,19 +20,17 @@ package dev.dnpm.etl.processor.config import com.fasterxml.jackson.databind.ObjectMapper -import dev.dnpm.etl.processor.consent.MtbFileConsentService import dev.dnpm.etl.processor.consent.GicsConsentService import dev.dnpm.etl.processor.consent.IConsentService +import dev.dnpm.etl.processor.consent.MtbFileConsentService import dev.dnpm.etl.processor.monitoring.* -import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator -import dev.dnpm.etl.processor.pseudonym.Generator -import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator -import dev.dnpm.etl.processor.pseudonym.PseudonymizeService +import dev.dnpm.etl.processor.pseudonym.* import dev.dnpm.etl.processor.security.TokenRepository import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.services.ConsentProcessor import dev.dnpm.etl.processor.services.Transformation import dev.dnpm.etl.processor.services.TransformationService +import org.apache.cxf.jaxws.JaxWsProxyFactoryBean import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.AnyNestedCondition import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean @@ -85,6 +83,37 @@ class AppConfiguration { } @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS") + @ConditionalOnProperty(value = ["app.pseudonymize.gpas.soap-endpoint"]) + @Bean + fun gpasSoapProxyFactoryBean(gpasConfigProperties: GPasConfigProperties): JaxWsProxyFactoryBean { + val proxyFactory = JaxWsProxyFactoryBean() + proxyFactory.serviceClass = GpasSoapService::class.java + proxyFactory.address = gpasConfigProperties.soapEndpoint + return proxyFactory + } + + @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS") + @ConditionalOnProperty(value = ["app.pseudonymize.gpas.soap-endpoint"]) + @Bean + fun gpasSoapProxy(gpasConfigProperties: GPasConfigProperties): GpasSoapService { + return gpasSoapProxyFactoryBean(gpasConfigProperties).create() as GpasSoapService + } + + @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS") + @ConditionalOnProperty(value = ["app.pseudonymize.gpas.soap-endpoint"]) + @Bean + fun gpasSoapPseudonymGenerator( + configProperties: GPasConfigProperties, + retryTemplate: RetryTemplate, + gpasSoapService: GpasSoapService, + appFhirConfig: AppFhirConfig + ): Generator { + logger.info("Selected 'GpasSoapPseudonym Generator'") + return GpasSoapPseudonymGenerator(configProperties, retryTemplate, gpasSoapService, appFhirConfig) + } + + @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS") + @ConditionalOnProperty(value = ["app.pseudonymize.gpas.uri"]) @Bean fun gpasPseudonymGenerator( configProperties: GPasConfigProperties, diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt index bf8b8bd..6e97865 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt @@ -80,7 +80,7 @@ sealed class ConnectionCheckResult { class KafkaConnectionCheckService( private val consumer: Consumer<String, String>, - @Qualifier("connectionCheckUpdateProducer") + @param:Qualifier("connectionCheckUpdateProducer") private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> ) : OutputConnectionCheckService { @@ -120,7 +120,7 @@ class KafkaConnectionCheckService( class RestConnectionCheckService( private val restTemplate: RestTemplate, private val restTargetProperties: RestTargetProperties, - @Qualifier("connectionCheckUpdateProducer") + @param:Qualifier("connectionCheckUpdateProducer") private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> ) : OutputConnectionCheckService { @@ -171,7 +171,7 @@ class RestConnectionCheckService( class GPasConnectionCheckService( private val restTemplate: RestTemplate, private val gPasConfigProperties: GPasConfigProperties, - @Qualifier("connectionCheckUpdateProducer") + @param:Qualifier("connectionCheckUpdateProducer") private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> ) : ConnectionCheckService { @@ -229,7 +229,7 @@ class GPasConnectionCheckService( class GIcsConnectionCheckService( private val restTemplate: RestTemplate, private val gIcsConfigProperties: GIcsConfigProperties, - @Qualifier("connectionCheckUpdateProducer") + @param:Qualifier("connectionCheckUpdateProducer") private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> ) : ConnectionCheckService { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt index 36c9705..f2509dd 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt @@ -30,6 +30,7 @@ import org.springframework.data.relational.core.mapping.Table import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.PagingAndSortingRepository import java.time.Instant +import java.time.temporal.ChronoUnit import java.util.* @Table("request") @@ -65,6 +66,12 @@ data class Request( processedAt: Instant ) : this(null, uuid, patientPseudonym, pid, fingerprint, type, status, processedAt) + + fun isPendingUnknown(): Boolean { + return this.status == RequestStatus.UNKNOWN && this.processedAt.isBefore( + Instant.now().minus(10, ChronoUnit.MINUTES) + ) + } } @JvmRecord @@ -90,19 +97,23 @@ interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRep @Query("SELECT count(*) AS count, status FROM request WHERE type = 'MTB_FILE' GROUP BY status ORDER BY status, count DESC;") fun countStates(): List<CountedState> - @Query("SELECT count(*) AS count, status FROM (" + - "SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " + - "WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " + - ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;") + @Query( + "SELECT count(*) AS count, status FROM (" + + "SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " + + "WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " + + ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;" + ) fun findPatientUniqueStates(): List<CountedState> @Query("SELECT count(*) AS count, status FROM request WHERE type = 'DELETE' GROUP BY status ORDER BY status, count DESC;") fun countDeleteStates(): List<CountedState> - @Query("SELECT count(*) AS count, status FROM (" + - "SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " + - "WHERE type = 'DELETE'" + - ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;") + @Query( + "SELECT count(*) AS count, status FROM (" + + "SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " + + "WHERE type = 'DELETE'" + + ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;" + ) fun findPatientUniqueDeleteStates(): List<CountedState> -}
\ No newline at end of file +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt index 0537cbb..dcb438f 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt @@ -35,7 +35,7 @@ class AnonymizingGenerator : Generator { } @OptIn(ExperimentalStdlibApi::class) - override fun generateGenomDeTan(id: String?): String { + override fun generateGenomDeTan(id: String): String { val bytes = ByteArray(64 / 2) getSecureRandom().nextBytes(bytes) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/GpasSoapPseudonymGenerator.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/GpasSoapPseudonymGenerator.kt new file mode 100644 index 0000000..8215d23 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/GpasSoapPseudonymGenerator.kt @@ -0,0 +1,26 @@ +package dev.dnpm.etl.processor.pseudonym + +import dev.dnpm.etl.processor.config.AppFhirConfig +import dev.dnpm.etl.processor.config.GPasConfigProperties +import org.springframework.retry.support.RetryTemplate + +class GpasSoapPseudonymGenerator( + private val gpasCfg: GPasConfigProperties, + private val retryTemplate: RetryTemplate, + private val gpasSoapService: GpasSoapService, + private val appFhirConfig: AppFhirConfig +) : Generator { + + override fun generate(id: String): String { + return retryTemplate.execute<String, Exception> { + gpasSoapService.getOrCreatePseudonymFor(id, gpasCfg.patientDomain) + } + } + + override fun generateGenomDeTan(id: String): String { + return retryTemplate.execute<String, Exception> { + gpasSoapService.createPseudonymsFor(id, gpasCfg.genomDeTanDomain, 1).first() + } + } +} + diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/GpasSoapService.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/GpasSoapService.kt new file mode 100644 index 0000000..0909924 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/GpasSoapService.kt @@ -0,0 +1,31 @@ +package dev.dnpm.etl.processor.pseudonym + +import jakarta.jws.WebMethod +import jakarta.jws.WebParam +import jakarta.jws.WebResult +import jakarta.jws.WebService +import jakarta.xml.bind.annotation.XmlElementWrapper + +@WebService( + name = "PSNManagerBeanService", + targetNamespace ="http://psn.ttp.ganimed.icmvc.emau.org/" +) +interface GpasSoapService { + + @WebMethod(operationName = "getOrCreatePseudonymFor") + @WebResult(name = "psn") + fun getOrCreatePseudonymFor( + @WebParam(name = "value") value: String, + @WebParam(name = "domainName") domainName: String + ): String + + @WebMethod(operationName = "createPseudonymsFor") + @WebResult(name = "psn") + @XmlElementWrapper(name = "return") + fun createPseudonymsFor( + @WebParam(name = "value") value: String, + @WebParam(name = "domainName") domainName: String, + @WebParam(name = "number") minNumber: Int + ): List<String> + +}
\ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/ConsentProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/ConsentProcessor.kt index 2ed21eb..b420d1f 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/ConsentProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/ConsentProcessor.kt @@ -14,7 +14,6 @@ import dev.pcvolkmer.mv64e.mtb.* import org.apache.commons.lang3.NotImplementedException import org.hl7.fhir.r4.model.* import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent -import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Consent.ConsentState import org.hl7.fhir.r4.model.Consent.ProvisionComponent import org.slf4j.Logger @@ -137,15 +136,7 @@ class ConsentProcessor( } val provisionComponent: ProvisionComponent = provisions.first() - - var provisionCode: String? = null - if (provisionComponent.code != null && provisionComponent.code.isNotEmpty()) { - val codableConcept: CodeableConcept = provisionComponent.code.first() - if (codableConcept.coding != null && codableConcept.coding.isNotEmpty()) { - provisionCode = codableConcept.coding.first().code - } - } - + val provisionCode = getProvisionCode(provisionComponent) if (provisionCode != null) { try { val modelProjectConsentPurpose = @@ -177,6 +168,17 @@ class ConsentProcessor( } } + private fun getProvisionCode(provisionComponent: ProvisionComponent): String? { + var provisionCode: String? = null + if (provisionComponent.code != null && provisionComponent.code.isNotEmpty()) { + val codableConcept: CodeableConcept = provisionComponent.code.first() + if (codableConcept.coding != null && codableConcept.coding.isNotEmpty()) { + provisionCode = codableConcept.coding.first().code + } + } + return provisionCode + } + private fun setGenomDeSubmissionType(mtbFile: Mtb) { if (appConfigProperties.genomDeTestSubmission) { mtbFile.metadata.type = MvhSubmissionType.TEST @@ -230,17 +232,21 @@ class ConsentProcessor( entry.resource.isResource && entry.resource.resourceType == ResourceType.Consent val consentIsActive = (entry.resource as Consent).status == ConsentState.ACTIVE - isConsentResource && consentIsActive && checkCoding( - targetCode, targetSystem, (entry.resource as Consent).policyRule.coding - ) && isRequestDateInRange(requestDate, (entry.resource as Consent).provision.period) + val provisions = (entry.resource as Consent).provision.provision + + val isValidCoding = checkProvisionExist( + targetCode, targetSystem, provisions + ) + + isConsentResource && consentIsActive && isValidCoding && isRequestDateInRange(requestDate, (entry.resource as Consent).provision.period) }.map { entry: BundleEntryComponent -> val consent = (entry.getResource() as Consent) consent.provision.provision.filter { subProvision -> isRequestDateInRange(requestDate, subProvision.period) // search coding entries of current provision for code and system - subProvision.code.map { c -> c.coding }.flatten().firstOrNull { code -> + subProvision.code.map { c -> c.coding }.flatten().any { code -> targetCode.equals(code.code) && targetSystem.equals(code.system) - } != null + } }.map { subProvision -> subProvision } @@ -252,16 +258,14 @@ class ConsentProcessor( return Consent.ConsentProvisionType.NULL } - fun checkCoding( + fun checkProvisionExist( researchAllowedPolicyOid: String?, researchAllowedPolicySystem: String?, - policyRules: Collection<Coding> + provisions: Collection<ProvisionComponent> ): Boolean { - return policyRules.find { code -> - researchAllowedPolicySystem.equals(code.getSystem()) && (researchAllowedPolicyOid.equals( - code.getCode() - )) - } != null + return provisions.any { provision -> + provision.code.any { codeableConcept -> codeableConcept.coding.any { it.system == researchAllowedPolicySystem && it.code == researchAllowedPolicyOid } } + } } fun isRequestDateInRange(requestDate: Date?, provPeriod: Period): Boolean { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt index 12e824d..fd6a9b4 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt @@ -73,9 +73,9 @@ class KafkaResponseProcessor( } data class ResponseBody( - @JsonProperty("request_id") @JsonAlias("requestId") val requestId: String, - @JsonProperty("status_code") @JsonAlias("statusCode") val statusCode: Int, - @JsonProperty("status_body") @JsonAlias("statusBody") val statusBody: Map<String, Any> + @param:JsonProperty("request_id") @param:JsonAlias("requestId") val requestId: String, + @param:JsonProperty("status_code") @param:JsonAlias("statusCode") val statusCode: Int, + @param:JsonProperty("status_body") @param:JsonAlias("statusBody") val statusBody: Map<String, Any> ) }
\ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt index ea89e98..44571d4 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt @@ -40,7 +40,7 @@ import reactor.core.publisher.Sinks @Controller @RequestMapping(path = ["configs"]) class ConfigController( - @Qualifier("connectionCheckUpdateProducer") + @param:Qualifier("connectionCheckUpdateProducer") private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>, private val transformationService: TransformationService, private val pseudonymGenerator: Generator, diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt index c034cb4..8372274 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt @@ -39,7 +39,7 @@ import java.time.temporal.ChronoUnit @RestController @RequestMapping(path = ["/statistics"]) class StatisticsRestController( - @Qualifier("statisticsUpdateProducer") + @param:Qualifier("statisticsUpdateProducer") private val statisticsUpdateProducer: Sinks.Many<Any>, private val requestService: RequestService ) { diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index f48e3dc..a419dda 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -52,7 +52,8 @@ <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 == 'unknown' and not request.isPendingUnknown()}" class="bg-gray"><small>[[ ${request.status} ]]</small></td> + <td th:if="${request.status.value == 'unknown' and request.isPendingUnknown()}" class="bg-yellow"><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 == 'no-consent'}" class="bg-blue"><small>[[ ${request.status} ]]</small></td> <td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td> |
