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 your PatientProvider

  • every bean that implements HAPI’s IServerInterceptor interface

  • every FhirRestfulServerCustomizer bean, 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:

  1. 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<>() {
        };
  2. 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);
        }
  3. 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:

  1. 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";
  2. 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()));
            }
        }
  3. 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;

PatientIdentifier also has a getIdStatus() method, which is deprecated in favor of getIdentifierStatus(). Our build treats compiler warnings as errors, so using it fails the build. The build also runs Error Prone and PMD, which is why toParameters delegates to addIdentifier instead of creating objects in a loop.

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 = true lets clients invoke it with GET, because it doesn’t change anything.

  • The @IdParam makes it an instance-level operation. The operation uses the same PatientAccess checks 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);
    }
}
  • @Interceptor marks 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 OperationOutcome contains no patient data.

  • The class is a Spring bean, but it doesn’t implement IServerInterceptor, so the framework doesn’t register it. Register it in FhirConfig.

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 with validateIcn in SystemOperationsITCase, which uses onServer().

  • readById_isNotCached registers an IClientInterceptor with 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 for OTHER_ICN with a veteran JWT for VETERAN_ICN and expect a ForbiddenOperationException.

  • For identifiers_notFound, invoke it for UNKNOWN_ICN and expect a ResourceNotFoundException.

  • 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 one identifier parameter for each (parameters.getParameters(PatientMapper.IDENTIFIER_PARAM)), with the expected parts. The second one has no status part.

Run the Build

./mvnw clean install -Pwith-skaffold

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.java and src/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}/$identifiers returns the identifiers, with the same access rules as the read

  • The CapabilityStatement lists the identifiers operation for Patient

  • Successful responses have the Cache-Control: no-store header

  • You can explain which components the framework registers automatically, and which ones FhirConfig registers

  • All tests pass, and your work is committed and pushed

Continue to 7. Connect to Oracle Health to connect your service to Oracle Health.