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:
-
Validates the ICN and checks the user’s access, like the Patient operations do.
-
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. -
Searches for the encounters of that patient with
GET Encounter?subject=Patient/<Oracle Health id>&status=finished. -
Returns the encounters, with a
subjectthat 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) {
}
}
-
interceptRequestruns before every request of the FHIR client. It replaces anyAuthorizationheader with the current token. -
getAccessTokenkeeps the token in anAtomicReference, 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. -
requestAccessTokenposts the formgrant_type=client_credentials&scope=…, with the client ID and secret in a BasicAuthorizationheader. 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.
@JsonPropertymaps 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 |
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 HAPIIGenericClientfor thecernerregistration, with its URL, its timeouts, and your interceptor. Creating a client is cheap, soCernerClientgets 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(…))sendsGET Patient?identifier=urn:oid:2.16.840.1.113883.4.349|<icn>, andreturnBundle(Bundle.class).execute()returns the searchset Bundle. Your integration tests use the same API. -
findPatientIdreturns an emptyOptionalwhen Oracle Health has no record of the patient. When more than one patient has the ICN, it throwsInternalErrorException(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 atERRORlevel, with the message and a stack trace, and logs must never contain patient identifiers. -
searchEncountersadds the status to the query only when the request has one. -
sendtranslates failures to HTTP 503, likeMviClient.send. The HAPI client throws subclasses ofBaseServerResponseExceptionfor error responses (for example,InternalErrorExceptionfor HTTP 500) and for connection failures (FhirClientConnectionException).CernerAuthInterceptorthrows aRestClientExceptionwhen it can’t get a token. As before, only the type of failure is logged. -
resourcesgets the resources of one type from a Bundle. A searchset Bundle can contain other resources too, for example anOperationOutcomewith warnings.
|
Oracle Health returns large results one page at a time, with a |
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 implementsIResourceProvider, 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)makespatientrequired: HAPI rejects a search without it before your method runs. It’s aReferenceParam, so clients can sendpatient=10115V737384orpatient=Patient/10115V737384.getIdPart()returns the ICN in both cases. -
@OptionalParam(name = Encounter.SP_STATUS)declares the optionalstatusparameter, aTokenParam. -
validateStatuschecks 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, soSanitizer.sanitizecleans it first. A status isn’t a patient identifier, so it may appear in the message and the log. -
toServiceEncounterprepares 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 thefullUrlof the Bundle entry, giving away the address of Oracle Health. With only the id part, HAPI builds thefullUrlfrom your server’s base URL. Your service doesn’t support reading an Encounter by id, but thefullUrlstill identifies the entry. The method also changes thesubjectto 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=finishedreturns the two finished encounters,"total":2. -
Encounter?patient=10115V737384with$VETERAN_JWTreturns the same three encounters: they are the veteran’s own.Encounter?patient=10132V467385with$VETERAN_JWTreturns HTTP 403. -
Encounter?patient=10132V467385returns an empty Bundle,"total":0, because PATIENT THIRTYTWO has no record in Oracle Health. -
Encounter?patient=10180V273016returns 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=bogusreturns HTTP 400,"diagnostics":"Invalid encounter status: bogus". Its issue code isprocessing, notinvalidlike the response for an invalid ICN: HAPI gives a plainInvalidRequestExceptionthe codeprocessing. To returninvalid, you would create a subclass that sets the code, likeInvalidIcnException. Both are HTTP 400, and that’s what clients rely on, so this guide keeps the simpler exception. -
Encounterwithout a patient returns HTTP 400 with the codenot-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. |
Test the Encounter Search
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_staffchecks thefullUrland thesubjectof every entry. -
search_ambiguousPatientexpects anInternalErrorException, which the HAPI client throws for HTTP 500. -
search_missingPatientbuilds a search without awhereclause. -
For
search_veteranSelf, search forVETERAN_ICNwith a veteran JWT forVETERAN_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 anInvalidRequestExceptionwhose message containsInvalid encounter status: bogus. -
For
search_unauthenticated, followsearchByIcn_unauthenticatedinPatientResourceITCase: create a JWT withauthenticated(false), and expect anAuthenticationException. Add the import forAuthenticationException.
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()));
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, andsrc/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’sMillenniumAuthInterceptor.
You’re Done When
-
GET [base]/Encounter?patient=10115V737384returns PATIENT FIFTEEN’s three encounters from cerner-mock, with subjects that refer to your Patient -
The
statusfilter, the empty result, and the error cases behave as described in Try It -
You can explain why
CernerAuthInterceptorisn’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.