From 199511e567884bb703277c276b782e54e528f744 Mon Sep 17 00:00:00 2001 From: jlidke Date: Tue, 22 Jul 2025 20:02:15 +0200 Subject: 63 check consent status (#120) Co-authored-by: Paul-Christian Volkmer --- .../etl/processor/consent/ConsentByMtbFile.java | 31 +++ .../dnpm/etl/processor/consent/ConsentDomain.java | 13 + .../etl/processor/consent/GicsConsentService.java | 281 +++++++++++++++++++++ .../dnpm/etl/processor/consent/IGetConsent.java | 27 ++ .../etl/processor/consent/TtpConsentStatus.java | 38 +++ .../pseudonym/GpasPseudonymGenerator.java | 56 ++-- 6 files changed, 426 insertions(+), 20 deletions(-) create mode 100644 src/main/java/dev/dnpm/etl/processor/consent/ConsentByMtbFile.java create mode 100644 src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java create mode 100644 src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java create mode 100644 src/main/java/dev/dnpm/etl/processor/consent/IGetConsent.java create mode 100644 src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java (limited to 'src/main/java/dev/dnpm') diff --git a/src/main/java/dev/dnpm/etl/processor/consent/ConsentByMtbFile.java b/src/main/java/dev/dnpm/etl/processor/consent/ConsentByMtbFile.java new file mode 100644 index 0000000..f7ce39e --- /dev/null +++ b/src/main/java/dev/dnpm/etl/processor/consent/ConsentByMtbFile.java @@ -0,0 +1,31 @@ +package dev.dnpm.etl.processor.consent; + +import java.util.Date; +import org.hl7.fhir.r4.model.Bundle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ConsentByMtbFile implements IGetConsent { + + private static final Logger log = LoggerFactory.getLogger(ConsentByMtbFile.class); + + public ConsentByMtbFile() { + log.info("ConsentCheckFileBased initialized..."); + } + + @Override + public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) { + return TtpConsentStatus.UNKNOWN_CHECK_FILE; + } + + /** + * EMPTY METHOD: NOT IMPLEMENTED + * + * @return empty bundle + */ + @Override + public Bundle getConsent(String personIdentifierValue, Date requestDate, + ConsentDomain consentDomain) { + return new Bundle(); + } +} diff --git a/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java b/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java new file mode 100644 index 0000000..6d0b160 --- /dev/null +++ b/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java @@ -0,0 +1,13 @@ +package dev.dnpm.etl.processor.consent; + +public enum ConsentDomain { + /** + * MII Broad consent + */ + BroadConsent, + + /** + * GenomDe Modelvohaben ยง64e + */ + Modelvorhaben64e +} diff --git a/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java b/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java new file mode 100644 index 0000000..6f3c987 --- /dev/null +++ b/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java @@ -0,0 +1,281 @@ +package dev.dnpm.etl.processor.consent; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.parser.DataFormatException; +import dev.dnpm.etl.processor.config.AppFhirConfig; +import dev.dnpm.etl.processor.config.GIcsConfigProperties; +import java.util.Date; +import org.apache.commons.lang3.StringUtils; +import org.hl7.fhir.r4.model.BooleanType; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Coding; +import org.hl7.fhir.r4.model.DateType; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.OperationOutcome; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.TerminatedRetryException; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + + +public class GicsConsentService implements IGetConsent { + + 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 HttpHeaders httpHeader; + private final GIcsConfigProperties gIcsConfigProperties; + private String url; + + public GicsConsentService(GIcsConfigProperties gIcsConfigProperties, + RetryTemplate retryTemplate, RestTemplate restTemplate, AppFhirConfig appFhirConfig) { + + this.retryTemplate = retryTemplate; + this.restTemplate = restTemplate; + this.fhirContext = appFhirConfig.fhirContext(); + httpHeader = buildHeader(gIcsConfigProperties.getUsername(), + gIcsConfigProperties.getPassword()); + this.gIcsConfigProperties = gIcsConfigProperties; + log.info("GicsConsentService initialized..."); + } + + public String getGicsUri(String endpoint) { + if (url == null) { + final String gIcsBaseUri = gIcsConfigProperties.getUri(); + if (StringUtils.isBlank(gIcsBaseUri)) { + throw new IllegalArgumentException( + "gICS base URL is empty - should call gICS with false configuration."); + } + url = UriComponentsBuilder.fromUriString(gIcsBaseUri).path(endpoint) + .toUriString(); + } + return url; + } + + @NotNull + private static HttpHeaders buildHeader(String gPasUserName, String gPasPassword) { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_XML); + + if (StringUtils.isBlank(gPasUserName) || StringUtils.isBlank(gPasPassword)) { + return headers; + } + + headers.setBasicAuth(gPasUserName, gPasPassword); + return headers; + } + + protected static Parameters getIsConsentedRequestParam(GIcsConfigProperties configProperties, + String personIdentifierValue) { + var result = new Parameters(); + result.addParameter(new ParametersParameterComponent().setName("personIdentifier").setValue( + new Identifier().setValue(personIdentifierValue) + .setSystem(configProperties.getPersonIdentifierSystem()))); + result.addParameter(new ParametersParameterComponent().setName("domain") + .setValue(new StringType().setValue(configProperties.getBroadConsentDomainName()))); + result.addParameter(new ParametersParameterComponent().setName("policy").setValue( + new Coding().setCode(configProperties.getBroadConsentPolicyCode()) + .setSystem(configProperties.getBroadConsentPolicySystem()))); + + /* + * is mandatory parameter, but we ignore it via additional configuration parameter + * 'ignoreVersionNumber'. + */ + result.addParameter(new ParametersParameterComponent().setName("version") + .setValue(new StringType().setValue("1.1"))); + + /* add config parameter with: + * ignoreVersionNumber -> true ->> Reason is we cannot know which policy version each patient + * has possibly signed or not, therefore we are happy with any version found. + * unknownStateIsConsideredAsDecline -> true + */ + var config = new ParametersParameterComponent().setName("config").addPart( + new ParametersParameterComponent().setName("ignoreVersionNumber") + .setValue(new BooleanType().setValue(true))).addPart( + new ParametersParameterComponent().setName("unknownStateIsConsideredAsDecline") + .setValue(new BooleanType().setValue(false))); + result.addParameter(config); + + return result; + } + + protected String callGicsApi(Parameters parameter, String endpoint) { + var parameterAsXml = fhirContext.newXmlParser().encodeResourceToString(parameter); + + HttpEntity requestEntity = new HttpEntity<>(parameterAsXml, this.httpHeader); + ResponseEntity responseEntity; + try { + var url = getGicsUri(endpoint); + + responseEntity = retryTemplate.execute( + ctx -> restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class)); + } 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; + + } + 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; + } + } + + @Override + public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) { + var parameter = GicsConsentService.getIsConsentedRequestParam(gIcsConfigProperties, + personIdentifierValue); + + var consentStatusResponse = callGicsApi(parameter, + GicsConsentService.IS_CONSENTED_ENDPOINT); + return evaluateConsentResponse(consentStatusResponse); + } + + protected Bundle currentConsentForPersonAndTemplate(String personIdentifierValue, + ConsentDomain targetConsentDomain, Date requestDate) { + + String consentDomain = getConsentDomain(targetConsentDomain); + + var requestParameter = GicsConsentService.buildRequestParameterCurrentPolicyStatesForPerson( + gIcsConfigProperties, 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) { + return (Bundle) iBaseResource; + } else { + String errorMessage = "Consent request failed! Unexpected response received! -> " + + consentDataSerialized; + log.error(errorMessage); + throw new IllegalStateException(errorMessage); + } + } + + @NotNull + private String getConsentDomain(ConsentDomain targetConsentDomain) { + String consentDomain; + switch (targetConsentDomain) { + case BroadConsent -> consentDomain = gIcsConfigProperties.getBroadConsentDomainName(); + case Modelvorhaben64e -> + consentDomain = gIcsConfigProperties.getGenomDeConsentDomainName(); + default -> throw new IllegalArgumentException( + "target ConsentDomain is missing but must be provided!"); + } + return consentDomain; + } + + protected static Parameters buildRequestParameterCurrentPolicyStatesForPerson( + GIcsConfigProperties gIcsConfigProperties, String personIdentifierValue, Date requestDate, + String targetDomain) { + var requestParameter = new Parameters(); + requestParameter.addParameter(new ParametersParameterComponent().setName("personIdentifier") + .setValue(new Identifier().setValue(personIdentifierValue) + .setSystem(gIcsConfigProperties.getPersonIdentifierSystem()))); + + requestParameter.addParameter(new ParametersParameterComponent().setName("domain") + .setValue(new StringType().setValue(targetDomain))); + + Parameters nestedConfigParameters = new Parameters(); + nestedConfigParameters.addParameter( + new ParametersParameterComponent().setName("idMatchingType").setValue( + new Coding().setSystem( + "https://ths-greifswald.de/fhir/CodeSystem/gics/IdMatchingType") + .setCode("AT_LEAST_ONE"))).addParameter("ignoreVersionNumber", false) + .addParameter("unknownStateIsConsideredAsDecline", false) + .addParameter("requestDate", new DateType().setValue(requestDate)); + + requestParameter.addParameter(new ParametersParameterComponent().setName("config").addPart() + .setResource(nestedConfigParameters)); + + return requestParameter; + } + + private TtpConsentStatus evaluateConsentResponse(String consentStatusResponse) { + if (consentStatusResponse == null) { + return TtpConsentStatus.FAILED_TO_ASK; + } + try { + var response = fhirContext.newJsonParser().parseResource(consentStatusResponse); + + if (response instanceof Parameters responseParameters) { + + var responseValue = responseParameters.getParameter("consented").getValue(); + var isConsented = responseValue.castToBoolean(responseValue); + if (!isConsented.hasValue()) { + return TtpConsentStatus.FAILED_TO_ASK; + } + if (isConsented.booleanValue()) { + return TtpConsentStatus.BROAD_CONSENT_GIVEN; + } else { + return TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED; + } + } else if (response instanceof OperationOutcome outcome) { + log.error("failed to get consent status from ttp. probably configuration error. " + + "outcome: '{}'", fhirContext.newJsonParser().encodeToString(outcome)); + + } + } catch (DataFormatException dfe) { + log.error("failed to parse response to FHIR R4 resource.", dfe); + } + return TtpConsentStatus.FAILED_TO_ASK; + } + + @Override + public Bundle getConsent(String patientId, Date requestDate, ConsentDomain consentDomain) { + switch (consentDomain) { + case BroadConsent -> { + return currentConsentForPersonAndTemplate(patientId, ConsentDomain.BroadConsent, + requestDate); + } + case Modelvorhaben64e -> { + return currentConsentForPersonAndTemplate(patientId, + ConsentDomain.Modelvorhaben64e, requestDate); + } + } + + return new Bundle(); + } +} diff --git a/src/main/java/dev/dnpm/etl/processor/consent/IGetConsent.java b/src/main/java/dev/dnpm/etl/processor/consent/IGetConsent.java new file mode 100644 index 0000000..3482b9a --- /dev/null +++ b/src/main/java/dev/dnpm/etl/processor/consent/IGetConsent.java @@ -0,0 +1,27 @@ +package dev.dnpm.etl.processor.consent; + +import java.util.Date; +import org.hl7.fhir.r4.model.Bundle; + +public interface IGetConsent { + + /** + * 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;

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/TtpConsentStatus.java b/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java new file mode 100644 index 0000000..2af1683 --- /dev/null +++ b/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java @@ -0,0 +1,38 @@ +package dev.dnpm.etl.processor.consent; + +public enum TtpConsentStatus { + /** + * Valid consent found + */ + BROAD_CONSENT_GIVEN, + /** + * Missing or rejected...actually unknown + */ + BROAD_CONSENT_MISSING_OR_REJECTED, + /** + * No Broad consent policy found + */ + BROAD_CONSENT_MISSING, + /** + * Research policy has been rejected + */ + BROAD_CONSENT_REJECTED, + + GENOM_DE_CONSENT_SEQUENCING_PERMIT, + /** + * No GenomDE consent policy found + */ + GENOM_DE_CONSENT_MISSING, + /** + * GenomDE consent policy found, but has been rejected + */ + GENOM_DE_SEQUENCING_REJECTED, + /** + * Consent status is validate via file property 'consent.status' + */ + UNKNOWN_CHECK_FILE, + /** + * Due technical problems consent status is unknown + */ + FAILED_TO_ASK +} 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 77caa77..a22100b 100644 --- a/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java +++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.pseudonym; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.parser.IParser; +import dev.dnpm.etl.processor.config.AppFhirConfig; import dev.dnpm.etl.processor.config.GPasConfigProperties; import org.apache.commons.lang3.StringUtils; import org.hl7.fhir.r4.model.Identifier; @@ -32,11 +33,14 @@ 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 static FhirContext r4Context = FhirContext.forR4(); + private final FhirContext r4Context; private final String gPasUrl; private final String psnTargetDomain; private final HttpHeaders httpHeader; @@ -45,11 +49,13 @@ public class GpasPseudonymGenerator implements Generator { private final RestTemplate restTemplate; - public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, RestTemplate restTemplate) { + public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, + RestTemplate restTemplate, AppFhirConfig appFhirConfig) { this.retryTemplate = retryTemplate; this.restTemplate = restTemplate; this.gPasUrl = gpasCfg.getUri(); this.psnTargetDomain = gpasCfg.getTarget(); + this.r4Context = appFhirConfig.fhirContext(); httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword()); log.debug(String.format("%s has been initialized", this.getClass().getName())); @@ -61,7 +67,7 @@ public class GpasPseudonymGenerator implements Generator { var gPasRequestBody = getGpasRequestBody(id); var responseEntity = getGpasPseudonym(gPasRequestBody); var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() - .parseResource(responseEntity.getBody()); + .parseResource(responseEntity.getBody()); return unwrapPseudonym(gPasPseudonymResult); } @@ -75,9 +81,9 @@ public class GpasPseudonymGenerator implements Generator { } final var identifier = (Identifier) parameters.get().getPart().stream() - .filter(a -> a.getName().equals("pseudonym")) - .findFirst() - .orElseGet(ParametersParameterComponent::new).getValue(); + .filter(a -> a.getName().equals("pseudonym")) + .findFirst() + .orElseGet(ParametersParameterComponent::new).getValue(); // pseudonym return sanitizeValue(identifier.getValue()); @@ -97,38 +103,48 @@ public class GpasPseudonymGenerator implements Generator { return psnValue.replaceAll(forbiddenCharsRegex, "_"); } - @NotNull protected ResponseEntity getGpasPseudonym(String gPasRequestBody) { HttpEntity requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader); - ResponseEntity responseEntity; try { - responseEntity = retryTemplate.execute( - ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity, - String.class)); - + ResponseEntity responseEntity = retryTemplate.execute( + ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity, + String.class)); if (responseEntity.getStatusCode().is2xxSuccessful()) { log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode()); - } else { - log.warn("API request unsuccessful. Response: {}", requestEntity.getBody()); - throw new PseudonymRequestFailed("API request unsuccessful gPas unsuccessful."); + return responseEntity; + } + } catch (RestClientException rce) { + if (rce instanceof BadRequest) { + String msg = "gPas or request configuration is incorrect. Please check both." + + rce.getMessage(); + log.debug( + msg); + throw new PseudonymRequestFailed(msg, rce); + } + if (rce instanceof Unauthorized) { + var msg = "gPas access credentials are invalid check your configuration. msg: '%s".formatted( + rce.getMessage()); + log.error(msg); + throw new PseudonymRequestFailed(msg, rce); } - - return responseEntity; } catch (Exception unexpected) { throw new PseudonymRequestFailed( - "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected); + "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected); } + throw new PseudonymRequestFailed( + "API request due unexpected error unsuccessful gPas unsuccessful."); + } protected String getGpasRequestBody(String id) { var requestParameters = new Parameters(); requestParameters.addParameter().setName("target") - .setValue(new StringType().setValue(psnTargetDomain)); + .setValue(new StringType().setValue(psnTargetDomain)); requestParameters.addParameter().setName("original") - .setValue(new StringType().setValue(id)); + .setValue(new StringType().setValue(id)); final IParser iParser = r4Context.newJsonParser(); return iParser.encodeResourceToString(requestParameters); } -- cgit v1.2.3