6. Add an Operation and an Interceptor
So far, you have implemented the two most common FHIR interactions, read and search. On this page, you add an operation, a named function that goes beyond the standard interactions, and an interceptor, code that runs for every request. Along the way, you see how the Mobile Framework sets up the HAPI FHIR server, and what FhirConfig is for.
How the FHIR Server Is Set Up
The Mobile Framework creates the HAPI FHIR server for you, and adds these Spring beans to it automatically:
-
every bean that implements
IResourceProvider, such as yourPatientProvider -
every bean that implements HAPI’s
IServerInterceptorinterface -
every
FhirRestfulServerCustomizerbean, which can change anything about the server
Anything else has to be registered by a customizer. That’s what config/FhirConfig.java is for:
@Bean
public FhirRestfulServerCustomizer myFhirServerCustomizer(final SystemOperationsProvider systemOperationsProvider) {
return fhirServer -> {
fhirServer.registerProvider(systemOperationsProvider);
// fhirServer.registerInterceptor(new MyInterceptor());
// fhirServer.setTenantIdentificationStrategy(new UrlBaseTenantIdentificationStrategy());
};
}
SystemOperationsProvider only has a system-level operation, $validate-icn. It isn’t a resource provider, so the framework doesn’t register it, and FhirConfig does.
See What Happens Without Registration (Optional)
| This section is optional. Nothing later in the guide depends on it. |
To see why the registration matters, start skaffold dev if it isn’t running. Then comment out the fhirServer.registerProvider(systemOperationsProvider); line, and let skaffold dev rebuild and redeploy your service. Then call $validate-icn again:
curl -s -H "x-vamf-jwt: $STAFF_JWT" 'http://localhost:8080/fhirstarter/v1/fhir/$validate-icn?icn=10115V737384' | jq
Response (HTTP 400):
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "not-supported",
"diagnostics": "Invalid request: The FHIR endpoint on this server does not know how to handle GET operation[$validate-icn] with parameters [[icn]]"
}
]
}
The operation is also gone from the CapabilityStatement at /metadata. The Spring bean still exists, but the FHIR server doesn’t know about it. Restore the line before you continue.
Operation Levels
FHIR operations can be invoked at three levels:
-
System level, on the FHIR base URL:
[base]/$validate-icn -
Type level, on a resource type:
[base]/Patient/$name -
Instance level, on one resource:
[base]/Patient/10115V737384/$name
System-level operations live in a plain provider like SystemOperationsProvider. Type and instance-level operations live in the resource provider of their type, and an instance-level operation receives the id of the resource as an @IdParam, like a read.
You will now add an instance-level operation to PatientProvider: GET [base]/Patient/{icn}/$identifiers returns the identifiers that MVI correlates with the patient, for example the patient’s identifier (DFN) at each VistA site. The Mobile MVI Service returns them from GET /mvi/v1/patients/ICN/{icn}/mvi/identifiers.
Add the Identifiers Stubs
Add two stubs for the identifiers endpoint to kubernetes/components/dev/wiremock-data/mvi. identifiers-10115V737384.json:
{
"request": {
"method": "GET",
"urlPath": "/mvi/v1/patients/ICN/10115V737384/mvi/identifiers"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"jsonBody": [
{
"uniqueId": "10115V737384",
"assigningAuthority": "ICN",
"assigningCode": "200M",
"idType": "NI",
"idStatus": "PERMANENT",
"identifierStatus": "PERMANENT"
},
{
"uniqueId": "69",
"assigningAuthority": "dfn-500",
"assigningCode": "500",
"idType": "PI",
"idStatus": "ACTIVE",
"identifierStatus": "ACTIVE"
}
]
}
}
For identifiers-10132V467385.json, copy the file and change 10115V737384 to 10132V467385 (twice) and the dfn-500 identifier 69 to 140. Then add both files to the mvi-wiremock-mappings ConfigMap in kubernetes/components/dev/kustomization.yaml:
- wiremock-data/mvi/identifiers-10115V737384.json
- wiremock-data/mvi/identifiers-10132V467385.json
Get the Identifiers from MVI
Add a method for the identifiers endpoint to MviClient. It uses the send method from the previous page, so it handles failures the same way as getPerson:
-
Add a constant after
SERVICE_NAME:/** * Type of the response body of the identifiers endpoint, a JSON array of identifiers. */ private static final ParameterizedTypeReference<List<PatientIdentifier>> IDENTIFIERS = new ParameterizedTypeReference<>() { }; -
Add the method after
getPerson:/** * Returns the identifiers that MVI correlates with the patient, for example the patient's identifier at each VistA * site. * * @param icn the ICN of the patient * @param jwt the JWT of the caller * @return the identifiers, or an empty list if MVI has no record of the patient * @throws DownstreamServiceException if the Mobile MVI Service fails or can't be reached (HTTP 503) */ public List<PatientIdentifier> getIdentifiers(final String icn, final String jwt) { return send(() -> restClient.get() .uri("/patients/ICN/{icn}/mvi/identifiers", icn) .header(HeaderNames.VAMF_JWT_HEADER, jwt) .retrieve() .body(IDENTIFIERS)) .orElseGet(List::of); } -
Add the imports:
import gov.va.mobile.mvi.v1.model.PatientIdentifier; import org.springframework.core.ParameterizedTypeReference; import java.util.List;
The response is a JSON array, and Java erases the type arguments of generic types at runtime. The anonymous ParameterizedTypeReference subclass keeps the full type, List<PatientIdentifier>, so the RestClient knows what to create.
Map the Identifiers to the Operation Output
Operations take their input and return their output as a Parameters resource: a list of named parameters, where each parameter has a value or nested parts. Make these changes to PatientMapper:
-
Add a constant after
FEMALE:/** * Name of the output parameter of the {@code $identifiers} operation, one for each identifier. */ public static final String IDENTIFIER_PARAM = "identifier"; -
Add the two methods after
toPatient:/** * Maps the identifiers that MVI correlates with a patient to the output of the {@code $identifiers} operation. * * <p>The {@link Parameters} resource has one {@code identifier} parameter for each identifier, with the parts * {@code id}, {@code assigningAuthority}, and {@code status}. * * @param correlatedIdentifiers the identifiers that the Mobile MVI Service returned * @return the output of the operation */ public Parameters toParameters(final List<PatientIdentifier> correlatedIdentifiers) { final Parameters parameters = new Parameters(); correlatedIdentifiers.forEach(identifier -> addIdentifier(parameters, identifier)); return parameters; } /** * Adds an {@code identifier} parameter with the parts of one MVI identifier. * * @param parameters the output of the {@code $identifiers} operation * @param identifier an identifier from the Mobile MVI Service */ private static void addIdentifier(final Parameters parameters, final PatientIdentifier identifier) { final Parameters.ParametersParameterComponent parameter = parameters.addParameter().setName(IDENTIFIER_PARAM); parameter.addPart().setName("id").setValue(new StringType(identifier.getUniqueId())); parameter.addPart().setName("assigningAuthority").setValue(new StringType(identifier.getAssigningAuthority())); if (isPresent(identifier.getIdentifierStatus())) { parameter.addPart().setName("status").setValue(new StringType(identifier.getIdentifierStatus())); } } -
Add the imports:
import gov.va.mobile.mvi.v1.model.PatientIdentifier; import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.StringType; import java.util.List;
|
|
Add the Operation
Add the $identifiers operation to PatientProvider, after the read:
/**
* Returns the identifiers that MVI correlates with the patient, for example
* {@code GET [base]/Patient/10115V737384/$identifiers}.
*
* <p>This is an instance level operation: it is invoked on one Patient, identified by its logical id.
*
* @param requestDetails request details provided by HAPI FHIR
* @param id the logical id of the Patient, which is the ICN of the patient
* @return a Parameters resource with one {@code identifier} parameter for each identifier
* @throws ResourceNotFoundException if MVI has no record of the patient (HTTP 404)
*/
@Operation(name = "$identifiers", idempotent = true)
@JwtAudited(JwtAudited.AuditOptions.REQUIRED)
public Parameters identifiers(final ServletRequestDetails requestDetails, @IdParam final IdType id) {
final String icn = PatientAccess.validateIcn(id.getIdPart());
PatientAccess.verifyPatientAccess(requestDetails, icn);
final List<PatientIdentifier> identifiers = mviClient.getIdentifiers(icn, PatientAccess.getJwt(requestDetails));
if (identifiers.isEmpty()) {
throw new ResourceNotFoundException(ErrorMessage.PATIENT_NOT_FOUND.getMessage());
}
return patientMapper.toParameters(identifiers);
}
Add the imports:
import ca.uhn.fhir.rest.annotation.Operation;
import gov.va.mobile.mvi.v1.model.PatientIdentifier;
import org.hl7.fhir.r4.model.Parameters;
-
@Operation(name = "$identifiers", idempotent = true)declares the operation.idempotent = truelets clients invoke it withGET, because it doesn’t change anything. -
The
@IdParammakes it an instance-level operation. The operation uses the samePatientAccesschecks as the read, so a veteran can only get their own identifiers. -
The operation returns patient data, so it is audited like the read and search.
Try It
Stop skaffold dev, run ./scripts/localize.sh force, and start skaffold dev --rpc-port 50051 --port-forward=true again, so WireMock loads the new stubs, and get the identifiers of PATIENT FIFTEEN:
curl -s -H "x-vamf-jwt: $STAFF_JWT" 'http://localhost:8080/fhirstarter/v1/fhir/Patient/10115V737384/$identifiers' | jq
Response:
{
"resourceType": "Parameters",
"parameter": [
{
"name": "identifier",
"part": [
{
"name": "id",
"valueString": "10115V737384"
},
{
"name": "assigningAuthority",
"valueString": "ICN"
},
{
"name": "status",
"valueString": "PERMANENT"
}
]
},
{
"name": "identifier",
"part": [
{
"name": "id",
"valueString": "69"
},
{
"name": "assigningAuthority",
"valueString": "dfn-500"
},
{
"name": "status",
"valueString": "ACTIVE"
}
]
}
]
}
Look at the Patient resource in the CapabilityStatement at /metadata: next to the read and search-type interactions, it now lists the identifiers operation.
Add an Interceptor
An interceptor runs code at defined points of every request, without changing each provider. HAPI calls these points pointcuts. You will add an interceptor that tells browsers and proxies not to store your responses, because they contain patient data.
Create a package named interceptor, and in it a class named CacheControlInterceptor:
package gov.va.mobile.fhirstarter.v1.interceptor;
import ca.uhn.fhir.interceptor.api.Hook;
import ca.uhn.fhir.interceptor.api.Interceptor;
import ca.uhn.fhir.interceptor.api.Pointcut;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
/**
* FHIR server interceptor that tells clients and proxies not to store responses, because they may contain PHI.
*
* <p>This is a HAPI FHIR annotation interceptor, not an {@code IServerInterceptor}, so the Mobile Framework doesn't
* register it with the FHIR server automatically; {@code FhirConfig} registers it.
*
* @since 1.0
*/
@Component
@Interceptor
public class CacheControlInterceptor {
/**
* Value of the {@code Cache-Control} header added to every successful response.
*/
public static final String NO_STORE = "no-store";
/**
* Adds the {@code Cache-Control: no-store} header before HAPI FHIR writes a successful response.
*
* @param response the servlet response
*/
@Hook(Pointcut.SERVER_OUTGOING_RESPONSE)
public void addCacheControl(final HttpServletResponse response) {
response.setHeader(HttpHeaders.CACHE_CONTROL, NO_STORE);
}
}
-
@Interceptormarks the class as a HAPI interceptor, and@Hook(Pointcut.SERVER_OUTGOING_RESPONSE)makes HAPI call the method before it writes a successful response. A hook method declares only the parameters it needs; this one only needs the servlet response. -
Error responses go through other pointcuts, so they don’t get the header. That’s fine: an
OperationOutcomecontains no patient data. -
The class is a Spring bean, but it doesn’t implement
IServerInterceptor, so the framework doesn’t register it. Register it inFhirConfig.
Update config/FhirConfig.java to resemble the code below, and add the import for CacheControlInterceptor:
/**
* Customizes the Mobile Framework FHIR server.
*
* @param systemOperationsProvider the provider of the system level FHIR operations
* @param cacheControlInterceptor the interceptor that adds the {@code Cache-Control} header to responses
* @return a {@link FhirRestfulServerCustomizer} that registers additional providers and interceptors with the FHIR
* server
*/
@Bean
public FhirRestfulServerCustomizer myFhirServerCustomizer(final SystemOperationsProvider systemOperationsProvider,
final CacheControlInterceptor cacheControlInterceptor) {
return fhirServer -> {
fhirServer.registerProvider(systemOperationsProvider);
fhirServer.registerInterceptor(cacheControlInterceptor);
// fhirServer.setTenantIdentificationStrategy(new UrlBaseTenantIdentificationStrategy());
};
}
The framework registers some HAPI interceptors for you through properties. For example, va.mobile.fhir.server.logging.enabled=true adds HAPI’s LoggingInterceptor, which logs a line for every request. Try it in application-dev.properties when you debug.
|
After skaffold dev redeploys, look at the response headers of a read:
curl -s -i -H "x-vamf-jwt: $STAFF_JWT" 'http://localhost:8080/fhirstarter/v1/fhir/Patient/10115V737384'
HTTP/1.1 200 ... Cache-Control: no-store Content-Type: application/fhir+json;charset=UTF-8 ...
Update the Tests
| To check your tests, compare them with the test classes in the "FHIR starter page 6" commit of the reference branch; see When You Get Stuck. |
Test the Operation and the Interceptor
PatientResourceITCase already has many tests, and the build’s PMD check fails a class with more than 20 methods. Test the operation and the interceptor in a new class instead. In src/test/java/gov/va/mobile/fhirstarter/v1, create a class named PatientOperationsITCase. The first two tests are complete; implement the other two:
package gov.va.mobile.fhirstarter.v1;
import ca.uhn.fhir.rest.client.api.IClientInterceptor;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.api.IHttpRequest;
import ca.uhn.fhir.rest.client.api.IHttpResponse;
import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
import gov.va.mobile.fhirstarter.v1.interceptor.CacheControlInterceptor;
import gov.va.mobile.security.jwt.test.JwtTestUtils;
import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.Patient;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for the Patient {@code $identifiers} operation and the response headers of the Patient resource.
*
* <p>The identifiers come from the WireMock stubs of the Mobile MVI Service in
* {@code kubernetes/components/dev/wiremock-data/mvi}.
*/
class PatientOperationsITCase extends BaseITCase {
/**
* ICN of the veteran user.
*/
private static final String VETERAN_ICN = "10115V737384";
/**
* ICN of another patient, whose records the veteran user must not be able to access.
*/
private static final String OTHER_ICN = "10132V467385";
/**
* ICN of a patient that MVI has no record of.
*/
private static final String UNKNOWN_ICN = "1012845331V153043";
/**
* Verify a staff user gets the identifiers that MVI correlates with a patient.
*/
@Test
void identifiers_staff() {
final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());
final Parameters result = identifiers(client, VETERAN_ICN).execute();
assertThat(result.getParameters("identifier"))
.extracting(parameter -> parameter.getPart().getFirst().getValue().primitiveValue())
.containsExactly(VETERAN_ICN, "69");
}
/**
* Verify responses with patient data tell clients and proxies not to store them.
*/
@Test
void readById_isNotCached() {
final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());
final List<String> cacheControl = new ArrayList<>();
client.registerInterceptor(new IClientInterceptor() {
@Override
public void interceptRequest(final IHttpRequest request) {
// Nothing to do
}
@Override
public void interceptResponse(final IHttpResponse response) {
cacheControl.addAll(response.getHeaders(HttpHeaders.CACHE_CONTROL));
}
});
client.read().resource(Patient.class).withId(VETERAN_ICN).execute();
assertThat(cacheControl).containsExactly(CacheControlInterceptor.NO_STORE);
}
/**
* Verify a veteran user may not get the identifiers of another patient.
*/
@Test
void identifiers_veteranOther() {
// TODO: implement
}
/**
* Verify the identifiers of a patient that MVI has no record of result in HTTP 404.
*/
@Test
void identifiers_notFound() {
// TODO: implement
}
/**
* Build a {@code GET [base]/Patient/<icn>/$identifiers} request.
*
* @param client the FHIR client to invoke the operation with
* @param icn the ICN of the patient
* @return the operation, ready to execute
*/
private static IOperationUntypedWithInput<Parameters> identifiers(final IGenericClient client, final String icn) {
return client.operation()
.onInstance(new IdType("Patient", icn))
.named("$identifiers")
.withNoParameters(Parameters.class)
.useHttpGet();
}
}
-
client.operation().onInstance(…)invokes an instance-level operation with the HAPI client. Compare it withvalidateIcninSystemOperationsITCase, which usesonServer(). -
readById_isNotCachedregisters anIClientInterceptorwith the HAPI client, to look at the response headers. Client interceptors run for every request a client sends; you will write one for real on page 8. -
For
identifiers_veteranOther, invoke the operation forOTHER_ICNwith a veteran JWT forVETERAN_ICNand expect aForbiddenOperationException. -
For
identifiers_notFound, invoke it forUNKNOWN_ICNand expect aResourceNotFoundException. -
Add the imports of the exception classes, and of
assertThatThrownBy, when you use them.
Check the CapabilityStatement
In CapabilityStatementITCase, check that the Patient resource declares the operation. Add the following assertion after the one for validate-icn:
assertThat(rest.getResource())
.filteredOn(resource -> "Patient".equals(resource.getType()))
.singleElement()
.satisfies(patient -> assertThat(patient.getOperation())
.as("Patient should declare the identifiers instance operation")
.anyMatch(operation -> operation.getName().endsWith("identifiers")));
Test the Mapper
Add a test named toParameters to PatientMapperTest and implement it:
-
Create two identifiers, for example
new PatientIdentifier().uniqueId(ICN).assigningAuthority("ICN").identifierStatus("PERMANENT"), and one without a status. -
Check that
mapper.toParameters(…)returns oneidentifierparameter for each (parameters.getParameters(PatientMapper.IDENTIFIER_PARAM)), with the expected parts. The second one has nostatuspart.
Commit Your Work
git add .
git commit -m "Add the \$identifiers operation and a Cache-Control interceptor"
git push
See It in a Real Service
-
clinical-encounter-service:
src/main/java/gov/va/mobile/ces/v1/config/FhirConfig.javaandsrc/main/java/gov/va/mobile/ces/v1/resources/SystemOperationsProvider.java, a server customizer and system operations -
chapi-proxy-service:
src/main/java/gov/va/mobile/chapi/v1/service/config/FhirConfig.java, which registers interceptors and a tenant identification strategy
You’re Done When
-
GET [base]/Patient/{icn}/$identifiersreturns the identifiers, with the same access rules as the read -
The CapabilityStatement lists the
identifiersoperation for Patient -
Successful responses have the
Cache-Control: no-storeheader -
You can explain which components the framework registers automatically, and which ones
FhirConfigregisters -
All tests pass, and your work is committed and pushed
Continue to 7. Connect to Oracle Health to connect your service to Oracle Health.