8. Search Encounters in Oracle Health

On the previous page, you deployed cerner-mock and registered the cerner FHIR client. On this page, you write the code that uses it: an Encounter search, which returns a patient’s encounters from Oracle Health:

GET [base]/Encounter?patient=10115V737384&status=finished

To answer it, your service:

  1. Validates the ICN and checks the user’s access, like the Patient operations do.

  2. Finds the patient in Oracle Health with GET Patient?identifier=urn:oid:2.16.840.1.113883.4.349|10115V737384. Oracle Health has its own patient ids, so you can’t use the ICN directly.

  3. Searches for the encounters of that patient with GET Encounter?subject=Patient/<Oracle Health id>&status=finished.

  4. Returns the encounters, with a subject that refers to your Patient resource, Patient/10115V737384.

Every request to Oracle Health needs an OAuth 2.0 access token, which your service requests with its own client ID and secret, as you did with curl on the previous page.

On page 2, a search for Encounters returned HTTP 404, "Unknown resource type 'Encounter'". By the end of this page, Encounter is a resource type of your server.

Authenticate with an Access Token

Oracle Health uses the OAuth 2.0 client credentials grant. Your service sends its client ID and secret to the token endpoint, and gets an access token that is valid for a few minutes. It sends the token in the Authorization header of each FHIR request.

You add the header with an HAPI client interceptor, the client-side counterpart of the server interceptor you wrote on page 6. In the client package, create a class named CernerAuthInterceptor:

package gov.va.mobile.fhirstarter.v1.client;

import ca.uhn.fhir.rest.client.api.IClientInterceptor;
import ca.uhn.fhir.rest.client.api.IHttpRequest;
import ca.uhn.fhir.rest.client.api.IHttpResponse;
import com.fasterxml.jackson.annotation.JsonProperty;
import gov.va.mobile.fhirstarter.v1.AppProperties;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;

/**
 * HAPI FHIR client interceptor that authenticates requests to Oracle Health (Cerner) with an OAuth 2.0 access token.
 *
 * <p>The token is requested with the client credentials grant and cached until shortly before it expires. This class is
 * deliberately not a Spring bean: the Mobile Framework registers every {@link IClientInterceptor} bean with every FHIR
 * client. {@code CernerConfig} registers it with the {@code cerner} FHIR client only.
 *
 * @since 1.0
 */
public class CernerAuthInterceptor implements IClientInterceptor {

    /**
     * Settings of the Oracle Health API, including the token endpoint and the client credentials.
     */
    private final AppProperties.Cerner settings;

    /**
     * REST client used to request access tokens.
     */
    private final RestClient restClient;

    /**
     * The current access token, or {@code null} before the first request.
     */
    private final AtomicReference<AccessToken> accessToken = new AtomicReference<>();

    /**
     * Creates the interceptor.
     *
     * @param settings the settings of the Oracle Health API
     * @param restClient the REST client used to request access tokens
     */
    public CernerAuthInterceptor(final AppProperties.Cerner settings, final RestClient restClient) {
        this.settings = settings;
        this.restClient = restClient;
    }

    @Override
    public void interceptRequest(final IHttpRequest request) {
        request.removeHeaders(HttpHeaders.AUTHORIZATION);
        request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken());
    }

    @Override
    public void interceptResponse(final IHttpResponse response) {
        // Nothing to do
    }

    /**
     * Returns the current access token, and requests a new one when it is about to expire.
     *
     * @return the access token
     */
    private String getAccessToken() {
        final AccessToken current = accessToken.get();
        if (current != null && Instant.now().plus(settings.getTokenRefreshMargin()).isBefore(current.expiresAt())) {
            return current.value();
        }
        final AccessToken requested = requestAccessToken();
        accessToken.set(requested);
        return requested.value();
    }

    /**
     * Requests an access token from the OAuth 2.0 token endpoint with the client credentials grant.
     *
     * @return the access token and when it expires
     * @throws RestClientException if the token request fails or the response contains no access token;
     *         {@link CernerClient} translates it into HTTP 503
     */
    private AccessToken requestAccessToken() {
        final MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
        form.add("grant_type", "client_credentials");
        // Oracle Health expects the scopes separated by commas
        form.add("scope", String.join(",", settings.getScopes()));
        final TokenResponse token = restClient.post()
                                              .uri(settings.getTokenUrl())
                                              .headers(headers -> headers.setBasicAuth(settings.getClientId(), settings.getClientSecret()))
                                              .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                                              .accept(MediaType.APPLICATION_JSON)
                                              .body(form)
                                              .retrieve()
                                              .body(TokenResponse.class);
        if (token == null || token.accessToken() == null) {
            throw new RestClientException("The token response contains no access token");
        }
        return new AccessToken(token.accessToken(), Instant.now().plusSeconds(token.expiresIn()));
    }

    /**
     * An access token and the time it expires.
     *
     * @param value the access token
     * @param expiresAt when the access token expires
     */
    record AccessToken(String value, Instant expiresAt) {
    }

    /**
     * The response of the OAuth 2.0 token endpoint.
     *
     * @param accessToken the access token
     * @param expiresIn the number of seconds until the access token expires
     */
    record TokenResponse(@JsonProperty("access_token") String accessToken, @JsonProperty("expires_in") long expiresIn) {
    }
}
  • interceptRequest runs before every request of the FHIR client. It replaces any Authorization header with the current token.

  • getAccessToken keeps the token in an AtomicReference, which many request threads can use safely at the same time, and requests a new token 30 seconds (tokenRefreshMargin) before the current one expires. Two threads may occasionally both request a new token, which is harmless.

  • requestAccessToken posts the form grant_type=client_credentials&scope=…​, with the client ID and secret in a Basic Authorization header. The scopes are separated by commas, as cfa sends them to Oracle Health; cerner-mock rejects a list of scopes separated by spaces.

  • The two records describe the token and the JSON response of the token endpoint. @JsonProperty maps the snake_case JSON names to the record components.

  • A failed token request throws a RestClientException. CernerClient, which you create next, translates it to HTTP 503.

CernerAuthInterceptor is deliberately not a Spring bean: it has no @Component. The framework registers every IClientInterceptor bean with every FHIR client, but the token must only go to Oracle Health. Register it with the cerner client instead. In the config package, create a class named CernerConfig:

package gov.va.mobile.fhirstarter.v1.config;

import gov.va.mobile.boot.fhir.client.FhirRestfulClientCustomizer;
import gov.va.mobile.fhirstarter.v1.AppProperties;
import gov.va.mobile.fhirstarter.v1.client.CernerAuthInterceptor;
import gov.va.mobile.fhirstarter.v1.client.CernerClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;

/**
 * Configuration of the Oracle Health (Cerner) FHIR client.
 *
 * @since 1.0
 */
@Configuration
public class CernerConfig {

    /**
     * Registers the {@link CernerAuthInterceptor} with the {@code cerner} FHIR client.
     *
     * <p>The Mobile Framework applies every {@link FhirRestfulClientCustomizer} bean to each FHIR client it creates, with
     * the registration ID of the client, so the interceptor only authenticates the requests to Oracle Health.
     *
     * @param appProperties the service settings, including the Oracle Health token endpoint and client credentials
     * @param restClientBuilder builds the REST client used to request access tokens
     * @return the customizer of the {@code cerner} FHIR client
     */
    @Bean
    public FhirRestfulClientCustomizer cernerAuthCustomizer(final AppProperties appProperties,
                                                            final RestClient.Builder restClientBuilder) {
        final CernerAuthInterceptor authInterceptor = new CernerAuthInterceptor(appProperties.getCerner(), restClientBuilder.build());
        return (registrationId, client) -> {
            if (CernerClient.REGISTRATION_ID.equals(registrationId)) {
                client.registerInterceptor(authInterceptor);
            }
        };
    }
}

The framework calls every FhirRestfulClientCustomizer bean for each FHIR client it creates, with the registration ID of the client. Spring Boot provides the RestClient.Builder.

Don’t add @Component to CernerAuthInterceptor. If you did, your Oracle Health access token would be sent to every FHIR server your service calls.

Create the Oracle Health Client

CernerClient reports a new error when more than one patient in Oracle Health has the requested ICN. Add its message to the end of the ErrorMessage catalog:

    /** A downstream service failed or couldn't be reached. */
    DOWNSTREAM_UNAVAILABLE("%s is unavailable, try again later"),
    /** More than one patient in a downstream system has the requested ICN. */
    AMBIGUOUS_PATIENT("More than one patient in %s has the requested ICN");

In the client package, create a class named CernerClient. Like MviClient, it hides the details of the downstream system from your providers:

package gov.va.mobile.fhirstarter.v1.client;

import ca.uhn.fhir.rest.gclient.IQuery;
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import gov.va.mobile.boot.fhir.client.FhirClientManager;
import gov.va.mobile.fhirstarter.v1.exception.DownstreamServiceException;
import gov.va.mobile.fhirstarter.v1.exception.ErrorMessage;
import gov.va.mobile.security.FhirSystems;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Encounter;
import org.hl7.fhir.r4.model.Patient;
import org.hl7.fhir.r4.model.Resource;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;

import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;

/**
 * Client for the Oracle Health (Cerner) Millennium FHIR R4 API.
 *
 * <p>The FHIR client is configured by the {@code va.mobile.fhir.client.registration.cerner.*} properties, and
 * authenticated by the {@link CernerAuthInterceptor}. Local deployments call cerner-mock instead of Oracle Health.
 *
 * @since 1.0
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CernerClient {

    /**
     * Registration ID of the Oracle Health FHIR client ({@code va.mobile.fhir.client.registration.cerner.*}).
     */
    public static final String REGISTRATION_ID = "cerner";

    /**
     * Name of the downstream system, as shown to clients when it is unavailable.
     */
    public static final String SERVICE_NAME = "Oracle Health";

    /**
     * Provides the FHIR clients of the registrations in the service properties.
     */
    private final FhirClientManager fhirClientManager;

    /**
     * Finds the Oracle Health id of the patient with the given ICN.
     *
     * @param icn the ICN of the patient
     * @return the logical id of the patient in Oracle Health, or empty if Oracle Health has no record of the patient
     * @throws InternalErrorException if more than one patient in Oracle Health has the ICN (HTTP 500)
     * @throws DownstreamServiceException if Oracle Health fails or can't be reached (HTTP 503)
     */
    public Optional<String> findPatientId(final String icn) {
        final Bundle bundle = send(() -> fhirClientManager.getClient(REGISTRATION_ID)
                                                          .search()
                                                          .forResource(Patient.class)
                                                          .where(Patient.IDENTIFIER.exactly().systemAndIdentifier(FhirSystems.ICN_SYSTEM, icn))
                                                          .returnBundle(Bundle.class)
                                                          .execute());
        final List<Patient> patients = resources(bundle, Patient.class);
        if (patients.size() > 1) {
            throw new InternalErrorException(ErrorMessage.AMBIGUOUS_PATIENT.format(SERVICE_NAME));
        }
        return patients.stream().findFirst().map(patient -> patient.getIdElement().getIdPart());
    }

    /**
     * Searches for the encounters of a patient.
     *
     * @param patientId the logical id of the patient in Oracle Health
     * @param status the status of the encounters to return, or {@code null} for every status
     * @return the encounters of the patient
     * @throws DownstreamServiceException if Oracle Health fails or can't be reached (HTTP 503)
     */
    public List<Encounter> searchEncounters(final String patientId, final String status) {
        IQuery<Bundle> query = fhirClientManager.getClient(REGISTRATION_ID)
                                                .search()
                                                .forResource(Encounter.class)
                                                .where(Encounter.SUBJECT.hasId("Patient/" + patientId))
                                                .returnBundle(Bundle.class);
        if (status != null) {
            query = query.and(Encounter.STATUS.exactly().code(status));
        }
        final IQuery<Bundle> search = query;
        return resources(send(search::execute), Encounter.class);
    }

    /**
     * Sends a request to Oracle Health and translates its failures.
     *
     * @param request the request
     * @param <T> the type of the response
     * @return the response
     * @throws DownstreamServiceException if Oracle Health fails or can't be reached (HTTP 503)
     */
    private static <T> T send(final Supplier<T> request) {
        try {
            return request.get();
        } catch (final BaseServerResponseException | RestClientException e) {
            // The HAPI FHIR client throws BaseServerResponseException subclasses for error responses and connection
            // failures, and CernerAuthInterceptor throws RestClientException when it can't get an access token
            final String failure = e.getClass().getSimpleName();
            log.warn("The Oracle Health request failed: {}", failure);
            throw new DownstreamServiceException(SERVICE_NAME, e);
        }
    }

    /**
     * Returns the resources of a type in a Bundle.
     *
     * @param bundle the Bundle
     * @param type the resource type
     * @param <T> the resource type
     * @return the resources of the type, in the order of the Bundle entries
     */
    private static <T extends Resource> List<T> resources(final Bundle bundle, final Class<T> type) {
        return bundle.getEntry()
                     .stream()
                     .map(Bundle.BundleEntryComponent::getResource)
                     .filter(type::isInstance)
                     .map(type::cast)
                     .toList();
    }
}
  • fhirClientManager.getClient(REGISTRATION_ID) returns an HAPI IGenericClient for the cerner registration, with its URL, its timeouts, and your interceptor. Creating a client is cheap, so CernerClient gets a new one for each request.

  • The generic client has a fluent API that reads like the request it sends. search().forResource(Patient.class).where(Patient.IDENTIFIER.exactly().systemAndIdentifier(…​)) sends GET Patient?identifier=urn:oid:2.16.840.1.113883.4.349|<icn>, and returnBundle(Bundle.class).execute() returns the searchset Bundle. Your integration tests use the same API.

  • findPatientId returns an empty Optional when Oracle Health has no record of the patient. When more than one patient has the ICN, it throws InternalErrorException (HTTP 500): the data in Oracle Health is wrong, which isn’t the client’s fault, so a 4xx status would be misleading. Like the 404 message on page 3, its message leaves out the ICN: HAPI logs every HTTP 500 at ERROR level, with the message and a stack trace, and logs must never contain patient identifiers.

  • searchEncounters adds the status to the query only when the request has one.

  • send translates failures to HTTP 503, like MviClient.send. The HAPI client throws subclasses of BaseServerResponseException for error responses (for example, InternalErrorException for HTTP 500) and for connection failures (FhirClientConnectionException). CernerAuthInterceptor throws a RestClientException when it can’t get a token. As before, only the type of failure is logged.

  • resources gets the resources of one type from a Bundle. A searchset Bundle can contain other resources too, for example an OperationOutcome with warnings.

Oracle Health returns large results one page at a time, with a next link in each Bundle that has more pages. A real service follows those links with client.loadPage().next(bundle).execute(). cerner-mock returns all the results at once, so this service doesn’t.

Create the Encounter Provider

The Encounter provider rejects a status that isn’t a FHIR encounter status. Add the message to the end of the ErrorMessage catalog:

    /** More than one patient in a downstream system has the requested ICN. */
    AMBIGUOUS_PATIENT("More than one patient in %s has the requested ICN"),
    /** The requested encounter status isn't a FHIR encounter status. */
    INVALID_ENCOUNTER_STATUS("Invalid encounter status: %s");

In the resource package, create a class named EncounterProvider. It uses the same PatientAccess checks as PatientProvider:

package gov.va.mobile.fhirstarter.v1.resource;

import ca.uhn.fhir.model.api.annotation.Description;
import ca.uhn.fhir.rest.annotation.OptionalParam;
import ca.uhn.fhir.rest.annotation.RequiredParam;
import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.param.ReferenceParam;
import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.rest.server.IResourceProvider;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import gov.va.mobile.Sanitizer;
import gov.va.mobile.aep.v2.client.web.JwtAudited;
import gov.va.mobile.fhirstarter.v1.client.CernerClient;
import gov.va.mobile.fhirstarter.v1.exception.ErrorMessage;
import gov.va.mobile.security.UserRoles;
import gov.va.mobile.security.web.jwt.JwtRestricted;
import gov.va.mobile.security.web.jwt.JwtRestrictedGroup;
import lombok.RequiredArgsConstructor;
import org.hl7.fhir.exceptions.FHIRException;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.Encounter;
import org.hl7.fhir.r4.model.Reference;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * Encounter resource provider, backed by Oracle Health (Cerner).
 *
 * <p>Access is role based: {@link JwtRestrictedGroup} admits staff and veteran users. The search then validates the
 * ICN and the user's access with {@link PatientAccess}, which results in HTTP 400 for an invalid ICN and HTTP 403 when
 * a veteran requests the records of another patient. Operations that return patient data are audited with
 * {@link JwtAudited}.
 *
 * @since 1.0
 */
@Component
@RequiredArgsConstructor
@JwtRestrictedGroup(oneOf = {
        @JwtRestricted(roles = UserRoles.STAFF),
        @JwtRestricted(roles = UserRoles.VETERAN)})
public class EncounterProvider implements IResourceProvider {

    /**
     * Client for the Oracle Health FHIR API.
     */
    private final CernerClient cernerClient;

    /**
     * Returns the FHIR resource type handled by this provider.
     *
     * @return the Encounter resource class
     */
    @Override
    public Class<? extends IBaseResource> getResourceType() {
        return Encounter.class;
    }

    /**
     * Searches for the encounters of a patient, for example {@code GET [base]/Encounter?patient=10115V737384&status=finished}.
     *
     * <p>The encounters come from Oracle Health; see {@link CernerClient} for the errors it results in.
     *
     * @param requestDetails request details provided by HAPI FHIR
     * @param patient the patient, by the logical id of the Patient in this service, which is the ICN
     * @param status the status of the encounters to return, optional
     * @return the encounters of the patient; HAPI FHIR returns the list as a Bundle of type {@code searchset}
     * @throws InvalidRequestException if the status isn't an encounter status (HTTP 400)
     */
    @Search
    @JwtAudited(JwtAudited.AuditOptions.REQUIRED)
    public List<Encounter> search(
            final ServletRequestDetails requestDetails,
            @Description(shortDefinition = "The patient's ICN, for example 10115V737384 or Patient/10115V737384")
            @RequiredParam(name = Encounter.SP_PATIENT)
            final ReferenceParam patient,
            @Description(shortDefinition = "The encounter status, for example finished")
            @OptionalParam(name = Encounter.SP_STATUS)
            final TokenParam status) {
        final String icn = PatientAccess.validateIcn(patient.getIdPart());
        PatientAccess.verifyPatientAccess(requestDetails, icn);
        final String statusCode = validateStatus(status);
        return cernerClient.findPatientId(icn)
                           .map(patientId -> cernerClient.searchEncounters(patientId, statusCode))
                           .orElseGet(List::of)
                           .stream()
                           .map(encounter -> toServiceEncounter(encounter, icn))
                           .toList();
    }

    /**
     * Prepares an Oracle Health encounter for the clients of this service.
     *
     * <p>The id of a resource read by a FHIR client includes the base URL of the server it came from, and HAPI FHIR uses
     * that id for the {@code fullUrl} of the Bundle entry. Keeping only the id part hides the address of Oracle Health
     * from our clients. The subject is pointed at the Patient in this service instead of the Patient in Oracle Health.
     *
     * @param encounter the encounter from Oracle Health, changed in place
     * @param icn the patient's ICN
     * @return the encounter
     */
    private static Encounter toServiceEncounter(final Encounter encounter, final String icn) {
        encounter.setId(encounter.getIdElement().getIdPart());
        encounter.setSubject(new Reference("Patient/" + icn));
        return encounter;
    }

    /**
     * Validates that the status is an encounter status, before the request is sent to Oracle Health.
     *
     * @param status the status from the request, optional
     * @return the status code, or {@code null} if the request has no status
     * @throws InvalidRequestException if the status isn't an encounter status (HTTP 400)
     */
    private static String validateStatus(final TokenParam status) {
        if (status == null || status.getValue() == null || status.getValue().isBlank()) {
            return null;
        }
        try {
            return Encounter.EncounterStatus.fromCode(status.getValue()).toCode();
        } catch (final FHIRException e) {
            throw new InvalidRequestException(ErrorMessage.INVALID_ENCOUNTER_STATUS.format(Sanitizer.sanitize(status.getValue())), e);
        }
    }
}
  • Like PatientProvider, it is a Spring bean that implements IResourceProvider, so the framework registers it and HAPI adds Encounter to your CapabilityStatement. It admits the same roles, and its search is audited.

  • @RequiredParam(name = Encounter.SP_PATIENT) makes patient required: HAPI rejects a search without it before your method runs. It’s a ReferenceParam, so clients can send patient=10115V737384 or patient=Patient/10115V737384. getIdPart() returns the ICN in both cases.

  • @OptionalParam(name = Encounter.SP_STATUS) declares the optional status parameter, a TokenParam.

  • validateStatus checks the status against the FHIR encounter statuses (Encounter.EncounterStatus) before calling Oracle Health. Validate input before you call a downstream system: the client gets a clear HTTP 400, and Oracle Health doesn’t get a request it can’t answer. The message includes the status, so Sanitizer.sanitize cleans it first. A status isn’t a patient identifier, so it may appear in the message and the log.

  • toServiceEncounter prepares each encounter for your clients. A resource that the HAPI client reads keeps the base URL of the server it came from in its id, and HAPI would use that id for the fullUrl of the Bundle entry, giving away the address of Oracle Health. With only the id part, HAPI builds the fullUrl from your server’s base URL. Your service doesn’t support reading an Encounter by id, but the fullUrl still identifies the entry. The method also changes the subject to refer to your Patient, whose id is the ICN, instead of the Oracle Health patient.

Try It

This time you only changed Java code, so skaffold dev picks up your changes by itself (see Your Development Loop). If it isn’t running, start it. Then search for PATIENT FIFTEEN’s encounters:

curl -s -H "x-vamf-jwt: $STAFF_JWT" 'http://localhost:8080/fhirstarter/v1/fhir/Encounter?patient=10115V737384' | jq

The response is a searchset Bundle with "total":3. Each entry is an encounter from cerner-mock, with a fullUrl on your server and a subject that refers to your Patient. For example:

{
  "fullUrl": "http://localhost:8080/fhirstarter/v1/fhir/Encounter/90001503",
  "resource": {
    "resourceType": "Encounter",
    "id": "90001503",
    "status": "planned",
    "class": {
      "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
      "code": "AMB"
    },
    "subject": {
      "reference": "Patient/10115V737384"
    },
    "period": {
      "start": "2026-12-01T10:00:00Z"
    }
  }
}

Then try the other cases:

  • Encounter?patient=10115V737384&status=finished returns the two finished encounters, "total":2.

  • Encounter?patient=10115V737384 with $VETERAN_JWT returns the same three encounters: they are the veteran’s own. Encounter?patient=10132V467385 with $VETERAN_JWT returns HTTP 403.

  • Encounter?patient=10132V467385 returns an empty Bundle, "total":0, because PATIENT THIRTYTWO has no record in Oracle Health.

  • Encounter?patient=10180V273016 returns HTTP 500:

    {
      "resourceType": "OperationOutcome",
      "issue": [
        {
          "severity": "error",
          "code": "processing",
          "diagnostics": "More than one patient in Oracle Health has the requested ICN"
        }
      ]
    }
  • Encounter?patient=10115V737384&status=bogus returns HTTP 400, "diagnostics":"Invalid encounter status: bogus". Its issue code is processing, not invalid like the response for an invalid ICN: HAPI gives a plain InvalidRequestException the code processing. To return invalid, you would create a subclass that sets the code, like InvalidIcnException. Both are HTTP 400, and that’s what clients rely on, so this guide keeps the simpler exception.

  • Encounter without a patient returns HTTP 400 with the code not-supported, because HAPI has no search that matches the request.

In /metadata, the Encounter resource has a search-type interaction with the patient (reference) and status (token) search parameters.

Update the Tests

To check your tests, compare them with the test classes in the "FHIR starter page 8" commit of the reference branch; see When You Get Stuck.

In src/test/java/gov/va/mobile/fhirstarter/v1, create a class named EncounterResourceITCase. The first six tests are complete; implement the other three:

package gov.va.mobile.fhirstarter.v1;

import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.gclient.IQuery;
import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import gov.va.mobile.security.jwt.test.JwtTestUtils;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Encounter;
import org.junit.jupiter.api.Test;

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

/**
 * Integration tests for the Encounter resource provider.
 *
 * <p>The encounters come from cerner-mock, which loads the files in {@code kubernetes/components/dev/cerner-mock-data}
 * when it starts.
 */
class EncounterResourceITCase extends BaseITCase {

    /**
     * ICN of the veteran user, PATIENT FIFTEEN, who has three encounters in Oracle Health, two of them finished.
     */
    private static final String VETERAN_ICN = "10115V737384";

    /**
     * ICN of another patient, who has no record in Oracle Health.
     */
    private static final String OTHER_ICN = "10132V467385";

    /**
     * ICN that two patients in Oracle Health have.
     */
    private static final String AMBIGUOUS_ICN = "10180V273016";

    /**
     * Verify a staff user gets the encounters of a patient, with URLs on this service and subjects that refer to the
     * Patient in this service.
     */
    @Test
    void search_staff() {
        final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());

        final Bundle bundle = searchByPatient(client, VETERAN_ICN).execute();

        assertThat(bundle.getEntry())
                .hasSize(3)
                .allSatisfy(entry -> {
                    assertThat(entry.getFullUrl()).startsWith(serviceUrl + "/Encounter/");
                    assertThat(((Encounter) entry.getResource()).getSubject().getReference())
                            .isEqualTo("Patient/" + VETERAN_ICN);
                });
    }

    /**
     * Verify the search returns only the encounters with the requested status.
     */
    @Test
    void search_status() {
        final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());

        final Bundle bundle = searchByPatient(client, VETERAN_ICN)
                .and(Encounter.STATUS.exactly().code("finished"))
                .execute();

        assertThat(bundle.getEntry()).hasSize(2);
    }

    /**
     * Verify a veteran user may not search for the encounters of another patient.
     */
    @Test
    void search_veteranOther() {
        final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createVeteranJwt(VETERAN_ICN));
        final IQuery<Bundle> query = searchByPatient(client, OTHER_ICN);

        assertThatThrownBy(query::execute).isInstanceOf(ForbiddenOperationException.class);
    }

    /**
     * Verify a patient without an Oracle Health record has no encounters.
     */
    @Test
    void search_noOracleHealthRecord() {
        final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());

        final Bundle bundle = searchByPatient(client, OTHER_ICN).execute();

        assertThat(bundle.getEntry()).isEmpty();
    }

    /**
     * Verify an ICN that more than one Oracle Health patient has results in HTTP 500.
     */
    @Test
    void search_ambiguousPatient() {
        final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());
        final IQuery<Bundle> query = searchByPatient(client, AMBIGUOUS_ICN);

        assertThatThrownBy(query::execute)
                .isInstanceOf(InternalErrorException.class)
                .hasMessageContaining("More than one patient in Oracle Health has the requested ICN");
    }

    /**
     * Verify a search without the required patient parameter is rejected.
     */
    @Test
    void search_missingPatient() {
        final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());
        final IQuery<Bundle> query = client.search().forResource(Encounter.class).returnBundle(Bundle.class);

        assertThatThrownBy(query::execute).isInstanceOf(InvalidRequestException.class);
    }

    /**
     * Verify a veteran user may search for their own encounters.
     */
    @Test
    void search_veteranSelf() {
        // TODO: implement
    }

    /**
     * Verify a status that isn't an encounter status is rejected.
     */
    @Test
    void search_invalidStatus() {
        // TODO: implement
    }

    /**
     * Verify requests without a valid authenticated JWT are rejected.
     */
    @Test
    void search_unauthenticated() {
        // TODO: implement
    }

    /**
     * Build an Encounter search by patient ({@code GET [base]/Encounter?patient=<icn>}).
     *
     * @param client the FHIR client to search with
     * @param icn the ICN of the patient, which is the logical id of the Patient in this service
     * @return the search, ready to execute
     */
    private static IQuery<Bundle> searchByPatient(final IGenericClient client, final String icn) {
        return client.search()
                     .forResource(Encounter.class)
                     .where(Encounter.PATIENT.hasId(icn))
                     .returnBundle(Bundle.class);
    }
}
  • search_staff checks the fullUrl and the subject of every entry.

  • search_ambiguousPatient expects an InternalErrorException, which the HAPI client throws for HTTP 500.

  • search_missingPatient builds a search without a where clause.

  • For search_veteranSelf, search for VETERAN_ICN with a veteran JWT for VETERAN_ICN (JwtTestUtils.V2.createVeteranJwt(VETERAN_ICN)), and expect the three encounters.

  • For search_invalidStatus, add .and(Encounter.STATUS.exactly().code("bogus")) to the search, and expect an InvalidRequestException whose message contains Invalid encounter status: bogus.

  • For search_unauthenticated, follow searchByIcn_unauthenticated in PatientResourceITCase: create a JWT with authenticated(false), and expect an AuthenticationException. Add the import for AuthenticationException.

Check the CapabilityStatement

In CapabilityStatementITCase, add the following assertion at the end of the satisfies block, after the assertion for the Patient identifiers operation:

                    assertThat(rest.getResource())
                            .as("CapabilityStatement should declare the Encounter resource")
                            .anyMatch(resource -> "Encounter".equals(resource.getType()));

Run the Build

./mvnw clean install -Pwith-skaffold

Commit Your Work

git add .
git commit -m "Add an Encounter search through cerner-mock"
git push

See It in a Real Service

  • cerner-fhir-adapter: src/main/java/gov/va/mobile/cfa/v1/services/CernerService.java, which calls Oracle Health with HAPI clients, and src/main/java/gov/va/mobile/cfa/v1/interceptors/MillenniumAuthInterceptor.java, its access token interceptor

  • chapi-proxy-service: uses the Mobile Framework’s OAuth 2.0 client starter for FHIR clients, mobile-boot-starter-fhir-oauth2-client, which you configure with properties alone. It sends the scopes separated by spaces, though, so this service has its own interceptor, like cfa’s MillenniumAuthInterceptor.

You’re Done When

  • GET [base]/Encounter?patient=10115V737384 returns PATIENT FIFTEEN’s three encounters from cerner-mock, with subjects that refer to your Patient

  • The status filter, the empty result, and the error cases behave as described in Try It

  • You can explain why CernerAuthInterceptor isn’t a Spring bean

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

Continue to 9. Configure, Document, and Review to configure, document, and review your service.