Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package care.smith.fts.cda.impl;

import care.smith.fts.util.HttpClientConfig;
import jakarta.validation.constraints.NotNull;
import java.time.Duration;
import java.util.Optional;

/**
* Configuration for FHIR Pseudonymizer service integration in Clinical Domain Agent.
*
* <p>This configuration enables deidentification via an external FHIR Pseudonymizer service, which
* delegates pseudonym generation to the Trust Center Agent's Vfps-compatible FHIR operations.
*
* <p>Configuration example:
*
* <pre>{@code
* deidentificator:
* fhir-pseudonymizer:
* server:
* baseUrl: "https://fhir-pseudonymizer.clinical.example.com"
* auth:
* type: oauth2
* clientId: "cda-client"
* clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
* timeout: 60s
* maxRetries: 3
* }</pre>
*
* @param server HTTP client configuration for the FHIR Pseudonymizer service
* @param timeout Request timeout (default: 60 seconds)
* @param maxRetries Maximum retry attempts (default: 3)
*/
public record FhirPseudonymizerConfig(
@NotNull HttpClientConfig server, Duration timeout, Integer maxRetries) {

public FhirPseudonymizerConfig(HttpClientConfig server, Duration timeout, Integer maxRetries) {
this.server = server;
this.timeout = Optional.ofNullable(timeout).orElse(Duration.ofSeconds(60));
this.maxRetries = Optional.ofNullable(maxRetries).orElse(3);

if (this.timeout.isNegative() || this.timeout.isZero()) {
throw new IllegalArgumentException("Timeout must be positive");
}
if (this.maxRetries < 0) {
throw new IllegalArgumentException("Max retries must be non-negative");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package care.smith.fts.cda.impl;

import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON;
import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy;

import ca.uhn.fhir.context.FhirContext;
import care.smith.fts.api.ConsentedPatientBundle;
import care.smith.fts.api.TransportBundle;
import care.smith.fts.api.cda.Deidentificator;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.hl7.fhir.r4.model.Bundle;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

/**
* Deidentificator implementation that delegates deidentification to an external FHIR Pseudonymizer
* service.
*
* <p>This implementation provides an alternative to DeidentifhirStep, enabling deidentification via
* a configurable external service. The FHIR Pseudonymizer service internally calls the TCA's
* Vfps-compatible FHIR operations for transport ID generation.
*
* <p>Architecture:
*
* <pre>
* ConsentedPatientBundle
* ↓
* FhirPseudonymizerStep (this class)
* ↓ [HTTP POST /fhir with FHIR Bundle]
* FHIR Pseudonymizer Service (external, clinical domain)
* ↓ [POST /$create-pseudonym]
* TCA CdAgentFhirPseudonymizerController
* ↓ [transport ID generation, sID stored in Redis]
* TransportBundle (with transport IDs)
* </pre>
*
* <p>The transport IDs in the returned bundle are temporary identifiers that will be resolved to
* real pseudonyms by the RDA via TCA's /rd-agent/fhir endpoint.
*/
@Slf4j
public class FhirPseudonymizerStep implements Deidentificator {

private static final String FHIR_ENDPOINT = "/fhir";

private final WebClient fhirPseudonymizerClient;
private final FhirPseudonymizerConfig config;
private final MeterRegistry meterRegistry;
private final FhirContext fhirContext;

public FhirPseudonymizerStep(
WebClient fhirPseudonymizerClient,
FhirPseudonymizerConfig config,
MeterRegistry meterRegistry,
FhirContext fhirContext) {
this.fhirPseudonymizerClient = fhirPseudonymizerClient;
this.config = config;
this.meterRegistry = meterRegistry;
this.fhirContext = fhirContext;
}

/**
* Deidentifies a FHIR Bundle via external FHIR Pseudonymizer service.
*
* <p>Sends the ConsentedPatientBundle to the FHIR Pseudonymizer service, which processes the
* bundle and returns a deidentified version containing transport IDs instead of real pseudonyms.
*
* @param bundle ConsentedPatientBundle containing patient data to deidentify
* @return Mono of TransportBundle with deidentified bundle and transfer ID
*/
@Override
public Mono<TransportBundle> deidentify(ConsentedPatientBundle bundle) {
log.debug(
"Deidentifying bundle for patient {} via FHIR Pseudonymizer",
bundle.consentedPatient().id());

String bundleJson = fhirContext.newJsonParser().encodeResourceToString(bundle.bundle());

return fhirPseudonymizerClient
.post()
.uri(FHIR_ENDPOINT)
.contentType(APPLICATION_FHIR_JSON)
.accept(APPLICATION_FHIR_JSON)
.bodyValue(bundleJson)
.retrieve()
.bodyToMono(String.class)
.map(this::parseDeidentifiedBundle)
.map(this::createTransportBundle)
.timeout(config.timeout())
.retryWhen(defaultRetryStrategy(meterRegistry, "fhirPseudonymizerDeidentification"))
.doOnSuccess(
result ->
log.debug(
"Successfully deidentified bundle for patient {}, transfer ID: {}",
bundle.consentedPatient().id(),
result.transferId()))
.doOnError(
error ->
log.error(
"Failed to deidentify bundle for patient {}: {}",
bundle.consentedPatient().id(),
error.getMessage()));
}

private Bundle parseDeidentifiedBundle(String bundleJson) {
return fhirContext.newJsonParser().parseResource(Bundle.class, bundleJson);
}

private TransportBundle createTransportBundle(Bundle deidentifiedBundle) {
var transferId = deidentifiedBundle.getIdPart();

// Note: HAPI FHIR's getIdPart() returns null for empty/missing IDs, never empty string
if (transferId == null) {
throw new IllegalStateException(
"FHIR Pseudonymizer returned bundle without transfer ID (bundle.id is null)");
}

log.trace("Extracted transfer ID from deidentified bundle: {}", transferId);
return new TransportBundle(deidentifiedBundle, transferId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package care.smith.fts.cda.impl;

import ca.uhn.fhir.context.FhirContext;
import care.smith.fts.api.cda.Deidentificator;
import care.smith.fts.util.WebClientFactory;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
* Factory for creating FhirPseudonymizerStep instances in the Clinical Domain Agent.
*
* <p>This factory implements the transfer process step factory pattern, enabling
* configuration-based instantiation of FHIR Pseudonymizer deidentificators.
*
* <p>Configuration example:
*
* <pre>{@code
* deidentificator:
* fhir-pseudonymizer:
* server:
* baseUrl: "https://fhir-pseudonymizer.clinical.example.com"
* auth:
* type: oauth2
* clientId: "cda-client"
* clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
* timeout: 60s
* maxRetries: 3
* }</pre>
*
* <p>The factory is registered as a Spring bean with name "fhir-pseudonymizerDeidentificator" to
* enable configuration-based selection between deidentification methods (deidentifhir vs
* fhir-pseudonymizer).
*/
@Slf4j
@Component("fhir-pseudonymizerDeidentificator")
public class FhirPseudonymizerStepFactory
implements Deidentificator.Factory<FhirPseudonymizerConfig> {

private final WebClientFactory clientFactory;
private final MeterRegistry meterRegistry;
private final FhirContext fhirContext;

public FhirPseudonymizerStepFactory(
WebClientFactory clientFactory, MeterRegistry meterRegistry, FhirContext fhirContext) {
this.clientFactory = clientFactory;
this.meterRegistry = meterRegistry;
this.fhirContext = fhirContext;
}

@Override
public Class<FhirPseudonymizerConfig> getConfigType() {
return FhirPseudonymizerConfig.class;
}

@Override
public Deidentificator create(
Deidentificator.Config commonConfig, FhirPseudonymizerConfig implConfig) {
var httpClient = clientFactory.create(implConfig.server());

log.info("Created FhirPseudonymizerStep with service URL: {}", implConfig.server().baseUrl());

return new FhirPseudonymizerStep(httpClient, implConfig, meterRegistry, fhirContext);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package care.smith.fts.cda.impl;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import care.smith.fts.util.HttpClientConfig;
import java.time.Duration;
import org.junit.jupiter.api.Test;

class FhirPseudonymizerConfigTest {

@Test
void createWithAllParametersExplicit() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
var timeout = Duration.ofSeconds(30);
var maxRetries = 5;

var config = new FhirPseudonymizerConfig(server, timeout, maxRetries);

assertThat(config.server()).isEqualTo(server);
assertThat(config.timeout()).isEqualTo(timeout);
assertThat(config.maxRetries()).isEqualTo(5);
}

@Test
void createWithDefaultTimeout() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");

var config = new FhirPseudonymizerConfig(server, null, 5);

assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60));
}

@Test
void createWithDefaultMaxRetries() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");

var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), null);

assertThat(config.maxRetries()).isEqualTo(3);
}

@Test
void createWithAllDefaults() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");

var config = new FhirPseudonymizerConfig(server, null, null);

assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60));
assertThat(config.maxRetries()).isEqualTo(3);
}

@Test
void negativeTimeoutThrowsException() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");

assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(-1), 3))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Timeout must be positive");
}

@Test
void zeroTimeoutThrowsException() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");

assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ZERO, 3))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Timeout must be positive");
}

@Test
void negativeMaxRetriesThrowsException() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");

assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), -1))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Max retries must be non-negative");
}

@Test
void zeroMaxRetriesIsAllowed() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");

var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 0);

assertThat(config.maxRetries()).isZero();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package care.smith.fts.cda.impl;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

import ca.uhn.fhir.context.FhirContext;
import care.smith.fts.api.cda.Deidentificator;
import care.smith.fts.util.HttpClientConfig;
import care.smith.fts.util.WebClientFactory;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.reactive.function.client.WebClient;

@ExtendWith(MockitoExtension.class)
class FhirPseudonymizerStepFactoryTest {

@Mock private WebClientFactory clientFactory;
@Mock private WebClient webClient;

private MeterRegistry meterRegistry;
private FhirContext fhirContext;
private FhirPseudonymizerStepFactory factory;

@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
fhirContext = FhirContext.forR4();
factory = new FhirPseudonymizerStepFactory(clientFactory, meterRegistry, fhirContext);
}

@Test
void getConfigTypeReturnsFhirPseudonymizerConfigClass() {
assertThat(factory.getConfigType()).isEqualTo(FhirPseudonymizerConfig.class);
}

@Test
void createReturnsDeidentificator() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
var implConfig = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 3);
var commonConfig = new Deidentificator.Config();

when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient);

var result = factory.create(commonConfig, implConfig);

assertThat(result).isNotNull();
assertThat(result).isInstanceOf(FhirPseudonymizerStep.class);
}

@Test
void createWithDefaultConfigValues() {
var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
var implConfig = new FhirPseudonymizerConfig(server, null, null);
var commonConfig = new Deidentificator.Config();

when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient);

var result = factory.create(commonConfig, implConfig);

assertThat(result).isNotNull();
assertThat(result).isInstanceOf(FhirPseudonymizerStep.class);
}
}
Loading
Loading