diff options
| author | Paul-Christian Volkmer | 2025-12-03 12:07:42 +0100 |
|---|---|---|
| committer | GitHub | 2025-12-03 12:07:42 +0100 |
| commit | b56b8c1b6cc9a3e8bcd19adde2b832af15d3a526 (patch) | |
| tree | a086d03bd187ea2401a4c31343a629f69167fdc6 /src | |
| parent | f3b062725fca472bff95e157ba75d973865da6ff (diff) | |
feat: simple HTTP GET based consent fetch (#208)
Diffstat (limited to 'src')
7 files changed, 385 insertions, 39 deletions
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt index f9fe2d4..2d1808a 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt @@ -22,6 +22,7 @@ package dev.dnpm.etl.processor.config import com.fasterxml.jackson.databind.ObjectMapper import dev.dnpm.etl.processor.consent.ConsentEvaluator import dev.dnpm.etl.processor.consent.GicsConsentService +import dev.dnpm.etl.processor.consent.GicsGetBroadConsentService import dev.dnpm.etl.processor.consent.MtbFileConsentService import dev.dnpm.etl.processor.input.KafkaInputListener import dev.dnpm.etl.processor.monitoring.RequestRepository @@ -302,6 +303,23 @@ class AppConfigurationTest { } @Nested + @TestPropertySource( + properties = + [ + "app.consent.service=GICS_GET_BC", + "app.consent.gics.uri=http://localhost:9000", + ], + ) + inner class AppConfigurationConsentGicsGetBcTest( + private val context: ApplicationContext, + ) { + @Test + fun shouldUseConfiguredGenerator() { + assertThat(context.getBean(GicsGetBroadConsentService::class.java)).isNotNull + } + } + + @Nested inner class AppConfigurationConsentBuildinTest( private val context: ApplicationContext, ) { diff --git a/src/main/java/dev/dnpm/etl/processor/consent/AbstractConsentService.java b/src/main/java/dev/dnpm/etl/processor/consent/AbstractConsentService.java new file mode 100644 index 0000000..10330a4 --- /dev/null +++ b/src/main/java/dev/dnpm/etl/processor/consent/AbstractConsentService.java @@ -0,0 +1,50 @@ +package dev.dnpm.etl.processor.consent; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.parser.DataFormatException; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Parameters; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +public abstract class AbstractConsentService implements IConsentService { + + protected final Logger log; + protected final FhirContext fhirContext; + + protected AbstractConsentService(FhirContext fhirContext, Logger log) { + this.fhirContext = fhirContext; + this.log = log; + } + + protected 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) { + + 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; + } +} 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 72238a3..fbc61a4 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java @@ -1,7 +1,5 @@ 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.net.URI; @@ -15,7 +13,6 @@ 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.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -31,9 +28,7 @@ import org.springframework.web.client.RestTemplate; * * @since 0.11 */ -public class GicsConsentService implements IConsentService { - - private final Logger log = LoggerFactory.getLogger(GicsConsentService.class); +public class GicsConsentService extends AbstractConsentService { public static final String IS_CONSENTED_ENDPOINT = "/$isConsented"; public static final String IS_POLICY_STATES_FOR_PERSON_ENDPOINT = @@ -43,7 +38,6 @@ public class GicsConsentService implements IConsentService { private final RetryTemplate retryTemplate; private final RestTemplate restTemplate; - private final FhirContext fhirContext; private final GIcsConfigProperties gIcsConfigProperties; public GicsConsentService( @@ -51,9 +45,10 @@ public class GicsConsentService implements IConsentService { RetryTemplate retryTemplate, RestTemplate restTemplate, AppFhirConfig appFhirConfig) { + super(appFhirConfig.fhirContext(), LoggerFactory.getLogger(GicsConsentService.class)); + this.retryTemplate = retryTemplate; this.restTemplate = restTemplate; - this.fhirContext = appFhirConfig.fhirContext(); this.gIcsConfigProperties = gIcsConfigProperties; log.info("GicsConsentService initialized..."); } @@ -272,37 +267,6 @@ public class GicsConsentService implements IConsentService { 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) { - - 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 @NonNull public Bundle getConsent( diff --git a/src/main/java/dev/dnpm/etl/processor/consent/GicsGetBroadConsentService.java b/src/main/java/dev/dnpm/etl/processor/consent/GicsGetBroadConsentService.java new file mode 100644 index 0000000..246d84c --- /dev/null +++ b/src/main/java/dev/dnpm/etl/processor/consent/GicsGetBroadConsentService.java @@ -0,0 +1,134 @@ +package dev.dnpm.etl.processor.consent; + +import dev.dnpm.etl.processor.config.AppFhirConfig; +import dev.dnpm.etl.processor.config.GIcsConfigProperties; +import java.net.URISyntaxException; +import java.util.Date; +import org.apache.hc.core5.net.URIBuilder; +import org.hl7.fhir.r4.model.Bundle; +import org.jspecify.annotations.NullMarked; +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.retry.TerminatedRetryException; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +/** + * Service to request Broad Consent only from remote gICS installation using REST/HTTP GET request + * + * @since 0.12 + */ +@NullMarked +public class GicsGetBroadConsentService extends AbstractConsentService { + + private final RetryTemplate retryTemplate; + private final RestTemplate restTemplate; + private final GIcsConfigProperties gIcsConfigProperties; + + public GicsGetBroadConsentService( + GIcsConfigProperties gIcsConfigProperties, + RetryTemplate retryTemplate, + RestTemplate restTemplate, + AppFhirConfig appFhirConfig) { + super(appFhirConfig.fhirContext(), LoggerFactory.getLogger(GicsGetBroadConsentService.class)); + + this.retryTemplate = retryTemplate; + this.restTemplate = restTemplate; + this.gIcsConfigProperties = gIcsConfigProperties; + + if (null == this.gIcsConfigProperties.getUri()) { + throw new IllegalStateException("Missing gICS URI configuration"); + } + + log.info("GicsGetBroadConsentService initialized..."); + } + + @Override + public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) { + var consentStatusResponse = + requestResponse( + personIdentifierValue, this.gIcsConfigProperties.getBroadConsentDomainName()); + return evaluateConsentResponse(consentStatusResponse); + } + + @Override + public Bundle getConsent( + String personIdentifierValue, Date requestDate, ConsentDomain consentDomain) { + return fhirContext + .newJsonParser() + .parseResource( + Bundle.class, + requestResponse( + personIdentifierValue, gIcsConfigProperties.getBroadConsentDomainName())); + } + + @Nullable + private String requestResponse(String personIdentifierValue, String consentDomain) { + if (null == this.gIcsConfigProperties.getUri()) { + throw new IllegalStateException("Missing gICS URI configuration"); + } + + final var patientIdentifierQueryValue = + "%s|%s" + .formatted( + this.gIcsConfigProperties.getPersonIdentifierSystem(), personIdentifierValue); + + try { + final var uri = + new URIBuilder(gIcsConfigProperties.getUri()) + .appendPathSegments("Consent") + .addParameter("domain:identifier", consentDomain) + .addParameter( + "category", + "http://fhir.de/ConsentManagement/CodeSystem/ResultType|consent-status") + .addParameter("patient.identifier", patientIdentifierQueryValue) + .build(); + + final var requestHeaders = new HttpHeaders(); + + if (null != gIcsConfigProperties.getUsername() + && null != gIcsConfigProperties.getPassword() + && !gIcsConfigProperties.getUsername().isBlank() + && !gIcsConfigProperties.getPassword().isBlank()) { + requestHeaders.setBasicAuth( + gIcsConfigProperties.getUsername(), gIcsConfigProperties.getPassword()); + } + + final var response = + this.retryTemplate.execute( + retryContext -> + this.restTemplate.exchange( + uri, HttpMethod.GET, new HttpEntity<>(requestHeaders), String.class)); + if (response.getStatusCode().is2xxSuccessful()) { + return response.getBody(); + } else { + var msg = + String.format( + "Trusted party system reached but request failed! code: '%s' response: '%s'", + response.getStatusCode(), response.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; + } + } +} 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 8786c34..7d795c8 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -149,6 +149,7 @@ enum class PseudonymGenerator { enum class ConsentService { NONE, GICS, + GICS_GET_BC, } data class TransformationProperties( 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 36b0a75..35585cb 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.config import com.fasterxml.jackson.databind.ObjectMapper import dev.dnpm.etl.processor.consent.GicsConsentService +import dev.dnpm.etl.processor.consent.GicsGetBroadConsentService import dev.dnpm.etl.processor.consent.IConsentService import dev.dnpm.etl.processor.consent.MtbFileConsentService import dev.dnpm.etl.processor.monitoring.* @@ -34,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration import org.apache.cxf.jaxws.JaxWsProxyFactoryBean import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.AllNestedConditions import org.springframework.boot.autoconfigure.condition.AnyNestedCondition import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -43,6 +45,7 @@ import org.springframework.context.annotation.Conditional import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.ConfigurationCondition import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration +import org.springframework.data.relational.core.sql.NestedCondition import org.springframework.retry.RetryCallback import org.springframework.retry.RetryContext import org.springframework.retry.RetryListener @@ -255,6 +258,17 @@ class AppConfiguration { return GicsConsentService(gIcsConfigProperties, retryTemplate, restTemplate, appFhirConfig) } + @Conditional(GicsGetBroadConsentEnabledCondition::class) + @Bean + fun gicsGetBroadConsentService( + gIcsConfigProperties: GIcsConfigProperties, + retryTemplate: RetryTemplate, + restTemplate: RestTemplate, + appFhirConfig: AppFhirConfig, + ): IConsentService { + return GicsGetBroadConsentService(gIcsConfigProperties, retryTemplate, restTemplate, appFhirConfig) + } + @Conditional(GicsEnabledCondition::class) @Bean fun consentProcessor( @@ -303,3 +317,13 @@ class GicsEnabledCondition : // Just for Condition } } + +class GicsGetBroadConsentEnabledCondition : + AnyNestedCondition(ConfigurationCondition.ConfigurationPhase.REGISTER_BEAN) { + + @ConditionalOnProperty(name = ["app.consent.service"], havingValue = "gics_get_bc") + @ConditionalOnProperty(name = ["app.consent.gics.uri"]) + class OnGicsGetBroadConsentServiceSelected { + // Just for Condition + } +} diff --git a/src/test/java/dev/dnpm/etl/processor/consent/GicsGetBroadConsentServiceTest.java b/src/test/java/dev/dnpm/etl/processor/consent/GicsGetBroadConsentServiceTest.java new file mode 100644 index 0000000..b3ebc08 --- /dev/null +++ b/src/test/java/dev/dnpm/etl/processor/consent/GicsGetBroadConsentServiceTest.java @@ -0,0 +1,155 @@ +package dev.dnpm.etl.processor.consent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.dnpm.etl.processor.config.AppConfiguration; +import dev.dnpm.etl.processor.config.AppFhirConfig; +import dev.dnpm.etl.processor.config.GIcsConfigProperties; +import java.net.URI; +import org.apache.hc.core5.net.URIBuilder; +import org.hl7.fhir.r4.model.BooleanType; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity; +import org.hl7.fhir.r4.model.OperationOutcome.IssueType; +import org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.MediaType; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +@ContextConfiguration(classes = {AppConfiguration.class, ObjectMapper.class}) +@TestPropertySource( + properties = { + "app.consent.service=gics", + "app.consent.gics.uri=http://localhost:8090/ttp-fhir/fhir/gics" + }) +@RestClientTest +class GicsGetBroadConsentServiceTest { + + static final String GICS_BASE_URI = "http://localhost:8090/ttp-fhir/fhir/gics"; + + MockRestServiceServer mockRestServiceServer; + AppFhirConfig appFhirConfig; + GIcsConfigProperties gIcsConfigProperties; + + GicsGetBroadConsentService service; + + static URI expectedGicsConsentedEndpoint() throws Exception { + return new URIBuilder(URI.create(GICS_BASE_URI)) + .appendPath("/Consent") + .addParameter("domain:identifier", "MII") + .addParameter( + "category", "http://fhir.de/ConsentManagement/CodeSystem/ResultType|consent-status") + .addParameter( + "patient.identifier", + "https://ths-greifswald.de/fhir/gics/identifiers/Patienten-ID|123456") + .build(); + } + + @BeforeEach + void setUp( + @Autowired AppFhirConfig appFhirConfig, + @Autowired GIcsConfigProperties gIcsConfigProperties) { + this.appFhirConfig = appFhirConfig; + this.gIcsConfigProperties = gIcsConfigProperties; + + var restTemplate = new RestTemplate(); + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate); + this.service = + new GicsGetBroadConsentService( + this.gIcsConfigProperties, + RetryTemplate.builder().maxAttempts(1).build(), + restTemplate, + this.appFhirConfig); + } + + @Test + void shouldReturnTtpBroadConsentStatus() throws Exception { + final Parameters consentedResponse = + new Parameters() + .addParameter( + new ParametersParameterComponent() + .setName("consented") + .setValue(new BooleanType().setValue(true))); + + mockRestServiceServer + .expect(requestTo(expectedGicsConsentedEndpoint())) + .andRespond( + withSuccess( + appFhirConfig + .fhirContext() + .newJsonParser() + .encodeResourceToString(consentedResponse), + MediaType.APPLICATION_JSON)); + + var consentStatus = service.getTtpBroadConsentStatus("123456"); + assertThat(consentStatus).isEqualTo(TtpConsentStatus.BROAD_CONSENT_GIVEN); + } + + @Test + void shouldReturnRevokedConsent() throws Exception { + final Parameters revokedResponse = + new Parameters() + .addParameter( + new ParametersParameterComponent() + .setName("consented") + .setValue(new BooleanType().setValue(false))); + + mockRestServiceServer + .expect(requestTo(expectedGicsConsentedEndpoint())) + .andRespond( + withSuccess( + appFhirConfig.fhirContext().newJsonParser().encodeResourceToString(revokedResponse), + MediaType.APPLICATION_JSON)); + + var consentStatus = service.getTtpBroadConsentStatus("123456"); + assertThat(consentStatus).isEqualTo(TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED); + } + + @Test + void shouldReturnInvalidParameterResponse() throws Exception { + final OperationOutcome responseWithErrorOutcome = + new OperationOutcome() + .addIssue( + new OperationOutcomeIssueComponent() + .setSeverity(IssueSeverity.ERROR) + .setCode(IssueType.PROCESSING) + .setDiagnostics("Invalid policy parameter...")); + + mockRestServiceServer + .expect(requestTo(expectedGicsConsentedEndpoint())) + .andRespond( + withSuccess( + appFhirConfig + .fhirContext() + .newJsonParser() + .encodeResourceToString(responseWithErrorOutcome), + MediaType.APPLICATION_JSON)); + + var consentStatus = service.getTtpBroadConsentStatus("123456"); + assertThat(consentStatus).isEqualTo(TtpConsentStatus.FAILED_TO_ASK); + } + + @Test + void shouldReturnRequestError() throws Exception { + mockRestServiceServer + .expect(requestTo(expectedGicsConsentedEndpoint())) + .andRespond(withServerError()); + + var consentStatus = service.getTtpBroadConsentStatus("123456"); + assertThat(consentStatus).isEqualTo(TtpConsentStatus.FAILED_TO_ASK); + } +} |
