/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken
* Copyright (c) 2026 Paul-Christian Volkmer, 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 .
*/
package dev.dnpm.etl.processor.consent;
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.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.retry.TerminatedRetryException;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
/**
* Service to request Consent from remote gICS installation
*
* @since 0.11
*/
public class GicsConsentService extends AbstractConsentService {
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 final RetryTemplate retryTemplate;
private final RestTemplate restTemplate;
private final GIcsConfigProperties gIcsConfigProperties;
public GicsConsentService(
GIcsConfigProperties gIcsConfigProperties,
RetryTemplate retryTemplate,
RestTemplate restTemplate,
AppFhirConfig appFhirConfig) {
super(appFhirConfig.fhirContext(), LoggerFactory.getLogger(GicsConsentService.class));
this.retryTemplate = retryTemplate;
this.restTemplate = restTemplate;
this.gIcsConfigProperties = gIcsConfigProperties;
log.info("GicsConsentService initialized...");
}
protected Parameters getFhirRequestParameters(String personIdentifierValue) {
var result = new Parameters();
result.addParameter(
new ParametersParameterComponent()
.setName("personIdentifier")
.setValue(
new Identifier()
.setValue(personIdentifierValue)
.setSystem(this.gIcsConfigProperties.getPersonIdentifierSystem())));
result.addParameter(
new ParametersParameterComponent()
.setName("domain")
.setValue(
new StringType().setValue(this.gIcsConfigProperties.getBroadConsentDomainName())));
result.addParameter(
new ParametersParameterComponent()
.setName("policy")
.setValue(
new Coding()
.setCode(this.gIcsConfigProperties.getBroadConsentPolicyCode())
.setSystem(this.gIcsConfigProperties.getBroadConsentPolicySystem())));
/*
* is mandatory parameter, but we ignore it via additional configuration parameter
* 'ignoreVersionNumber'.
*/
result.addParameter(
new ParametersParameterComponent()
.setName("version")
.setValue(new StringType().setValue("1.1")));
/* add config parameter with:
* ignoreVersionNumber -> true ->> Reason is we cannot know which policy version each patient
* has possibly signed or not, therefore we are happy with any version found.
* unknownStateIsConsideredAsDecline -> true
*/
var config =
new ParametersParameterComponent()
.setName("config")
.addPart(
new ParametersParameterComponent()
.setName("ignoreVersionNumber")
.setValue(new BooleanType().setValue(true)))
.addPart(
new ParametersParameterComponent()
.setName("unknownStateIsConsideredAsDecline")
.setValue(new BooleanType().setValue(false)));
result.addParameter(config);
return result;
}
private URI endpointUri(String endpoint) 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 urlBuilder.build();
}
private HttpHeaders headersWithHttpBasicAuth() {
assert this.gIcsConfigProperties.getUri() != null;
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
if (StringUtils.isBlank(this.gIcsConfigProperties.getUsername())
|| StringUtils.isBlank(this.gIcsConfigProperties.getPassword())) {
return headers;
}
headers.setBasicAuth(
this.gIcsConfigProperties.getUsername(), this.gIcsConfigProperties.getPassword());
return headers;
}
@Nullable
protected String callGicsApi(Parameters parameter, String endpoint) {
var parameterAsXml = fhirContext.newXmlParser().encodeResourceToString(parameter);
HttpEntity 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
@NonNull
public TtpConsentStatus getTtpBroadConsentStatus(@NonNull String personIdentifierValue) {
var consentStatusResponse =
callGicsApi(
getFhirRequestParameters(personIdentifierValue),
GicsConsentService.IS_CONSENTED_ENDPOINT);
return evaluateConsentResponse(consentStatusResponse);
}
protected Bundle currentConsentForPersonAndTemplate(
String personIdentifierValue, ConsentDomain consentDomain, Date requestDate) {
var requestParameter =
buildRequestParameterCurrentPolicyStatesForPerson(
personIdentifierValue, requestDate, consentDomain);
var consentDataSerialized =
callGicsApi(requestParameter, GicsConsentService.IS_POLICY_STATES_FOR_PERSON_ENDPOINT);
if (consentDataSerialized == null) {
// error occurred - should not process further!
throw new IllegalStateException(
"consent data request failed - stopping processing! - try again or fix other problems first.");
}
var iBaseResource = fhirContext.newJsonParser().parseResource(consentDataSerialized);
if (iBaseResource instanceof OperationOutcome) {
// log error - very likely a configuration error
String errorMessage = "Consent request failed! Check outcome:\n " + consentDataSerialized;
log.error(errorMessage);
throw new IllegalStateException(errorMessage);
} else if (iBaseResource instanceof Bundle bundle) {
return bundle;
} else {
String errorMessage =
"Consent request failed! Unexpected response received! -> " + consentDataSerialized;
log.error(errorMessage);
throw new IllegalStateException(errorMessage);
}
}
@Nullable
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;
}
@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 Bundle convertGicsResultToMiiBroadConsent(Bundle gIcsResultBundle) {
if (gIcsResultBundle == null
|| gIcsResultBundle.getEntry().isEmpty()
|| !(gIcsResultBundle.getEntry().getFirst().getResource() instanceof Consent))
return gIcsResultBundle;
Bundle.BundleEntryComponent bundleEntryComponent = gIcsResultBundle.getEntry().getFirst();
var consentAsOne = (Consent) bundleEntryComponent.getResource();
if (isMiiConsent(consentAsOne)) {
return gIcsResultBundle;
}
if (consentAsOne.getPolicy().stream()
.noneMatch(p -> p.getUri().equals(gIcsConfigProperties.getBroadConsentPolicyUri()))) {
consentAsOne.addPolicy(
new Consent.ConsentPolicyComponent()
.setUri(gIcsConfigProperties.getBroadConsentPolicyUri()));
}
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)));
}
}