com.ibm.fhir.server.util.FHIRRestHelper Maven / Gradle / Ivy
/*
* (C) Copyright IBM Corp. 2016, 2021
*
* SPDX-License-Identifier: Apache-2.0
*/
package com.ibm.fhir.server.util;
import static com.ibm.fhir.core.FHIRConstants.EXT_BASE;
import static com.ibm.fhir.model.type.String.string;
import static com.ibm.fhir.model.util.ModelSupport.getResourceType;
import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static javax.servlet.http.HttpServletResponse.SC_GONE;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
import static javax.servlet.http.HttpServletResponse.SC_OK;
import java.net.URI;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.owasp.encoder.Encode;
import com.ibm.fhir.config.FHIRConfigHelper;
import com.ibm.fhir.config.FHIRConfiguration;
import com.ibm.fhir.config.FHIRRequestContext;
import com.ibm.fhir.config.PropertyGroup;
import com.ibm.fhir.core.FHIRConstants;
import com.ibm.fhir.core.HTTPHandlingPreference;
import com.ibm.fhir.core.HTTPReturnPreference;
import com.ibm.fhir.core.context.FHIRPagingContext;
import com.ibm.fhir.database.utils.api.LockException;
import com.ibm.fhir.exception.FHIROperationException;
import com.ibm.fhir.model.patch.FHIRPatch;
import com.ibm.fhir.model.patch.exception.FHIRPatchException;
import com.ibm.fhir.model.resource.Bundle;
import com.ibm.fhir.model.resource.Bundle.Entry;
import com.ibm.fhir.model.resource.Bundle.Entry.Request;
import com.ibm.fhir.model.resource.Bundle.Entry.Search;
import com.ibm.fhir.model.resource.OperationOutcome;
import com.ibm.fhir.model.resource.OperationOutcome.Issue;
import com.ibm.fhir.model.resource.Parameters;
import com.ibm.fhir.model.resource.Resource;
import com.ibm.fhir.model.resource.SearchParameter;
import com.ibm.fhir.model.resource.StructureDefinition;
import com.ibm.fhir.model.type.Code;
import com.ibm.fhir.model.type.CodeableConcept;
import com.ibm.fhir.model.type.DateTime;
import com.ibm.fhir.model.type.Decimal;
import com.ibm.fhir.model.type.Extension;
import com.ibm.fhir.model.type.Reference;
import com.ibm.fhir.model.type.UnsignedInt;
import com.ibm.fhir.model.type.Uri;
import com.ibm.fhir.model.type.Url;
import com.ibm.fhir.model.type.code.BundleType;
import com.ibm.fhir.model.type.code.HTTPVerb;
import com.ibm.fhir.model.type.code.IssueSeverity;
import com.ibm.fhir.model.type.code.IssueType;
import com.ibm.fhir.model.type.code.SearchEntryMode;
import com.ibm.fhir.model.util.CollectingVisitor;
import com.ibm.fhir.model.util.FHIRUtil;
import com.ibm.fhir.model.util.ModelSupport;
import com.ibm.fhir.model.util.ReferenceMappingVisitor;
import com.ibm.fhir.model.util.SaltHash;
import com.ibm.fhir.model.visitor.ResourceFingerprintVisitor;
import com.ibm.fhir.path.FHIRPathNode;
import com.ibm.fhir.path.evaluator.FHIRPathEvaluator;
import com.ibm.fhir.path.evaluator.FHIRPathEvaluator.EvaluationContext;
import com.ibm.fhir.path.patch.FHIRPathPatch;
import com.ibm.fhir.persistence.FHIRPersistence;
import com.ibm.fhir.persistence.FHIRPersistenceTransaction;
import com.ibm.fhir.persistence.ResourceChangeLogRecord;
import com.ibm.fhir.persistence.ResourceEraseRecord;
import com.ibm.fhir.persistence.SingleResourceResult;
import com.ibm.fhir.persistence.context.FHIRHistoryContext;
import com.ibm.fhir.persistence.context.FHIRPersistenceContext;
import com.ibm.fhir.persistence.context.FHIRPersistenceContextFactory;
import com.ibm.fhir.persistence.context.FHIRSystemHistoryContext;
import com.ibm.fhir.persistence.erase.EraseDTO;
import com.ibm.fhir.persistence.exception.FHIRPersistenceException;
import com.ibm.fhir.persistence.exception.FHIRPersistenceResourceDeletedException;
import com.ibm.fhir.persistence.exception.FHIRPersistenceResourceNotFoundException;
import com.ibm.fhir.persistence.helper.FHIRTransactionHelper;
import com.ibm.fhir.persistence.interceptor.FHIRPersistenceEvent;
import com.ibm.fhir.persistence.interceptor.impl.FHIRPersistenceInterceptorMgr;
import com.ibm.fhir.persistence.jdbc.exception.FHIRPersistenceDataAccessException;
import com.ibm.fhir.persistence.util.FHIRPersistenceUtil;
import com.ibm.fhir.profile.ProfileSupport;
import com.ibm.fhir.search.SearchConstants;
import com.ibm.fhir.search.SummaryValueSet;
import com.ibm.fhir.search.context.FHIRSearchContext;
import com.ibm.fhir.search.exception.FHIRSearchException;
import com.ibm.fhir.search.parameters.QueryParameter;
import com.ibm.fhir.search.parameters.QueryParameterValue;
import com.ibm.fhir.search.util.ReferenceUtil;
import com.ibm.fhir.search.util.ReferenceValue;
import com.ibm.fhir.search.util.ReferenceValue.ReferenceType;
import com.ibm.fhir.search.util.SearchUtil;
import com.ibm.fhir.server.exception.FHIRRestBundledRequestException;
import com.ibm.fhir.server.operation.FHIROperationRegistry;
import com.ibm.fhir.server.operation.spi.FHIROperation;
import com.ibm.fhir.server.operation.spi.FHIROperationContext;
import com.ibm.fhir.server.operation.spi.FHIRResourceHelpers;
import com.ibm.fhir.server.operation.spi.FHIRRestOperationResponse;
import com.ibm.fhir.validation.FHIRValidator;
import com.ibm.fhir.validation.exception.FHIRValidationException;
/**
* Helper methods for performing the "heavy lifting" with respect to implementing
* FHIR interactions.
*/
public class FHIRRestHelper implements FHIRResourceHelpers {
private static final Logger log =
java.util.logging.Logger.getLogger(FHIRRestHelper.class.getName());
private static final SecureRandom RANDOM = new SecureRandom();
private static final String LOCAL_REF_PREFIX = "urn:";
private static final com.ibm.fhir.model.type.String SC_BAD_REQUEST_STRING = string(Integer.toString(SC_BAD_REQUEST));
private static final com.ibm.fhir.model.type.String SC_GONE_STRING = string(Integer.toString(SC_GONE));
private static final com.ibm.fhir.model.type.String SC_NOT_FOUND_STRING = string(Integer.toString(SC_NOT_FOUND));
private static final com.ibm.fhir.model.type.String SC_ACCEPTED_STRING = string(Integer.toString(SC_ACCEPTED));
private static final com.ibm.fhir.model.type.String SC_OK_STRING = string(Integer.toString(SC_OK));
private static final ZoneId UTC = ZoneId.of("UTC");
// default number of entries in system history if no _count is given
private static final int DEFAULT_HISTORY_ENTRIES = 100;
// clamp the number of entries in system history to 1000
private static final int MAX_HISTORY_ENTRIES = 1000;
public static final DateTimeFormatter PARSER_FORMATTER = new DateTimeFormatterBuilder()
.appendPattern("EEE")
.optionalStart()
// ANSIC date time format for If-Modified-Since
.appendPattern(" MMM dd HH:mm:ss yyyy")
.optionalEnd()
.optionalStart()
// Touchstone date time format for If-Modified-Since
.appendPattern(", dd-MMM-yy HH:mm:ss")
.optionalEnd().toFormatter();
private FHIRPersistence persistence = null;
// Used for correlating requests within a bundle.
private String bundleRequestCorrelationId = null;
private final FHIRValidator validator = FHIRValidator.validator(FHIRConfigHelper.getBooleanProperty(FHIRConfiguration.PROPERTY_VALIDATION_FAIL_FAST, Boolean.FALSE));
public FHIRRestHelper(FHIRPersistence persistence) {
this.persistence = persistence;
}
@Override
public FHIRRestOperationResponse doCreate(String type, Resource resource, String ifNoneExist,
boolean doValidation) throws Exception {
log.entering(this.getClass().getName(), "doCreate");
// Validate that interaction is allowed for given resource type
validateInteraction(Interaction.CREATE, type);
FHIRRestOperationResponse ior = new FHIRRestOperationResponse();
// Save the current request context.
FHIRRequestContext requestContext = FHIRRequestContext.get();
// Get the transaction started before there's any chance of a rollback
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
try {
// Make sure the expected type (specified in the URL string) is congruent with the actual type
// of the resource.
String resourceType = ModelSupport.getTypeName(resource.getClass());
if (!resourceType.equals(type)) {
String msg = "Resource type '" + resourceType
+ "' does not match type specified in request URI: " + type;
throw buildRestException(msg, IssueType.INVALID);
}
// Check to see if we're supposed to perform a conditional 'create'.
if (ifNoneExist != null && !ifNoneExist.isEmpty()) {
if (log.isLoggable(Level.FINE)) {
log.fine("Performing conditional create with search criteria: " + ifNoneExist);
}
Bundle responseBundle = null;
// Perform the search using the "If-None-Exist" header value.
try {
MultivaluedMap searchParameters = getQueryParameterMap(ifNoneExist);
responseBundle = doSearch(type, null, null, searchParameters, null, resource, false);
} catch (FHIROperationException e) {
throw e;
} catch (Throwable t) {
String msg =
"An error occurred while performing the search for a conditional create operation.";
log.log(Level.WARNING, msg, t);
throw new FHIROperationException(msg, t);
}
// Check the search results to determine whether or not to perform the create operation.
int resultCount = responseBundle.getEntry().size();
if (log.isLoggable(Level.FINE)) {
log.fine("Conditional create search yielded " + resultCount + " results.");
}
if (resultCount == 0) {
// Do nothing and fall through to process the 'create' request.
} else if (resultCount == 1) {
// If we found a single match, bypass the 'create' request and return information
// for the matched resource.
Resource matchedResource = responseBundle.getEntry().get(0).getResource();
ior.setLocationURI(FHIRUtil.buildLocationURI(type, matchedResource));
ior.setStatus(Response.Status.OK);
ior.setResource(matchedResource);
ior.setOperationOutcome(FHIRUtil.buildOperationOutcome("Found a single match; check the Location header",
IssueType.INFORMATIONAL, IssueSeverity.INFORMATION));
if (log.isLoggable(Level.FINE)) {
log.fine("Returning location URI of matched resource: " + ior.getLocationURI());
}
return ior;
} else {
String msg =
"The search criteria specified for a conditional create operation returned multiple matches.";
throw buildRestException(msg, IssueType.MULTIPLE_MATCHES);
}
}
// Validate the input and, if valid, start collecting supplemental warnings
List warnings = doValidation ? new ArrayList<>(validateInput(resource)) : new ArrayList<>();
// For R4, resources may contain an id. For create, this should be ignored and
// we no longer reject the request.
if (resource.getId() != null) {
String msg = "The create request resource included id: '" + resource.getId() + "'; this id has been replaced";
warnings.add(FHIRUtil.buildOperationOutcomeIssue(IssueSeverity.INFORMATION, IssueType.INFORMATIONAL, msg));
if (log.isLoggable(Level.FINE)) {
log.fine(msg);
}
}
// If there were no validation errors, then create the resource and return the location header.
// First, invoke the 'beforeCreate' interceptor methods.
FHIRPersistenceEvent event =
new FHIRPersistenceEvent(resource, buildPersistenceEventProperties(type, null, null, null));
getInterceptorMgr().fireBeforeCreateEvent(event);
// write the resource back in case the interceptors modified it in some way
resource = event.getFhirResource();
FHIRPersistenceContext persistenceContext =
FHIRPersistenceContextFactory.createPersistenceContext(event);
// R4: remember model objects are immutable, so we get back a new resource with the id/meta stuff
SingleResourceResult result = persistence.create(persistenceContext, resource);
resource = result.getResource();
if (result.isSuccess() && result.getOutcome() != null) {
warnings.addAll(result.getOutcome().getIssue());
}
event.setFhirResource(resource); // update event with latest
ior.setStatus(Response.Status.CREATED);
ior.setResource(resource);
ior.setOperationOutcome(FHIRUtil.buildOperationOutcome(warnings));
// Build our location URI and add it to the interceptor event structure since it is now known.
ior.setLocationURI(FHIRUtil.buildLocationURI(ModelSupport.getTypeName(resource.getClass()), resource));
event.getProperties().put(FHIRPersistenceEvent.PROPNAME_RESOURCE_LOCATION_URI, ior.getLocationURI().toString());
// Invoke the 'afterCreate' interceptor methods.
getInterceptorMgr().fireAfterCreateEvent(event);
// Commit our transaction if we started one before.
txn.commit();
txn = null;
return ior;
} finally {
// Restore the original request context.
FHIRRequestContext.set(requestContext);
// If we previously started a transaction and it's still active, we need to rollback due to an error.
if (txn != null) {
txn.rollback();
}
log.exiting(this.getClass().getName(), "doCreate");
}
}
@Override
public FHIRRestOperationResponse doPatch(String type, String id, FHIRPatch patch, String ifMatchValue,
String searchQueryString, boolean skippableUpdate) throws Exception {
// Validate that interaction is allowed for given resource type
validateInteraction(Interaction.PATCH, type);
return doPatchOrUpdate(type, id, patch, null, ifMatchValue, searchQueryString, skippableUpdate, DO_VALIDATION);
}
@Override
public FHIRRestOperationResponse doUpdate(String type, String id, Resource newResource, String ifMatchValue,
String searchQueryString, boolean skippableUpdate, boolean doValidation) throws Exception {
// Validate that interaction is allowed for given resource type
validateInteraction(Interaction.UPDATE, type);
return doPatchOrUpdate(type, id, null, newResource, ifMatchValue, searchQueryString, skippableUpdate, doValidation);
}
private FHIRRestOperationResponse doPatchOrUpdate(String type, String id, FHIRPatch patch,
Resource newResource, String ifMatchValue, String searchQueryString,
boolean skippableUpdate, boolean doValidation) throws Exception {
log.entering(this.getClass().getName(), "doPatchOrUpdate");
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
// Save the current request context.
FHIRRequestContext requestContext = FHIRRequestContext.get();
boolean isDeleted; // stash the deleted status of the resource when we first read it
FHIRRestOperationResponse ior = new FHIRRestOperationResponse();
try {
// Make sure the type specified in the URL string matches the resource type obtained from the new resource.
if (patch == null) {
String resourceType = ModelSupport.getTypeName(newResource.getClass());
if (!resourceType.equals(type)) {
String msg = "Resource type '" + resourceType
+ "' does not match type specified in request URI: " + type;
throw buildRestException(msg, IssueType.INVALID);
}
}
// Next, if a conditional update was invoked then use the search criteria to find the
// resource to be updated. Otherwise, we'll use the id value to retrieve the current
// version of the resource.
if (searchQueryString != null) {
if (log.isLoggable(Level.FINE)) {
log.fine("Performing conditional update/patch with search criteria: "
+ Encode.forHtml(searchQueryString));
}
Bundle responseBundle = null;
try {
MultivaluedMap searchParameters = getQueryParameterMap(searchQueryString);
responseBundle = doSearch(type, null, null, searchParameters, null, newResource, false);
} catch (FHIROperationException e) {
throw e;
} catch (Throwable t) {
String msg =
"An error occurred while performing the search for a conditional update/patch operation.";
throw new FHIROperationException(msg, t);
}
// Check the search results to determine whether or not to perform the update operation.
int resultCount = responseBundle.getEntry().size();
if (log.isLoggable(Level.FINE)) {
log.fine("Conditional update/patch search yielded " + resultCount + " results.");
}
if (resultCount == 0) {
if (patch != null) {
String msg =
"The search criteria specified for a conditional patch operation did not return any results.";
throw buildRestException(msg, IssueType.NOT_FOUND);
}
// Search yielded no matches, so we'll do an update/create operation below.
ior.setPrevResource(null);
// if no id provided, then generate an id for the input resource
if (newResource.getId() == null) {
id = persistence.generateResourceId();
newResource = newResource.toBuilder().id(id).build();
} else {
id = newResource.getId();
}
// No match, so deletion status doesn't matter
isDeleted = false;
} else if (resultCount == 1) {
// If we found a single match, then we'll perform a normal update on the matched resource.
ior.setPrevResource(responseBundle.getEntry().get(0).getResource());
id = ior.getPrevResource().getId();
// If the id of the input resource is different from the id of the search result,
// then throw exception.
if (newResource.getId() != null && id != null && !newResource.getId().equals(id)) {
String msg = "Input resource 'id' attribute must match the id of the search result resource.";
throw buildRestException(msg, IssueType.VALUE);
}
// Make sure the id of the newResource is not null and is the same as the id of the found resource.
newResource = newResource.toBuilder().id(id).build();
// Got a match, so definitely can't be deleted
isDeleted = false;
} else {
String msg =
"The search criteria specified for a conditional update/patch operation returned multiple matches.";
throw buildRestException(msg, IssueType.MULTIPLE_MATCHES);
}
} else {
// Make sure an id value was passed in.
if (id == null) {
String msg = "The 'id' parameter is required for an update/pach operation.";
throw buildRestException(msg, IssueType.REQUIRED);
}
// If an id value was passed in (i.e. the id specified in the REST API URL string),
// then make sure it's the same as the value in the resource.
if (patch == null) {
// Make sure the resource has an 'id' attribute.
if (newResource.getId() == null) {
String msg = "Input resource must contain an 'id' attribute.";
throw buildRestException(msg, IssueType.INVALID);
}
if (!newResource.getId().equals(id)) {
String msg = "Input resource 'id' attribute must match 'id' parameter.";
throw buildRestException(msg, IssueType.VALUE);
}
}
// Retrieve the resource to be updated using the type and id values. Include
// the resource even if it has been deleted
SingleResourceResult extends Resource> srr = doRead(type, id, (patch != null), true, newResource, null, false);
ior.setPrevResource(srr.getResource());
isDeleted = srr.isDeleted();
}
if (patch != null) {
try {
newResource = patch.apply(ior.getPrevResource());
} catch (FHIRPatchException e) {
String msg = "Invalid patch: " + e.getMessage();
String path = e.getPath() != null ? e.getPath() : "";
throw new FHIROperationException(msg, e).withIssue(Issue.builder()
.severity(IssueSeverity.ERROR)
.code(IssueType.INVALID)
.details(CodeableConcept.builder()
.text(string(msg))
.build())
.expression(string(path))
.build());
}
}
// Validate the input and, if valid, start collecting supplemental warnings
List warnings = doValidation ? new ArrayList<>(validateInput(newResource)) : new ArrayList<>() ;
// Perform the "version-aware" update check, and also find out if the resource was deleted.
if (ior.getPrevResource() != null) {
performVersionAwareUpdateCheck(ior.getPrevResource(), ifMatchValue);
// In the case of a patch, we should not be updating meaninglessly.
if ((skippableUpdate || patch != null) && !isDeleted) {
ResourceFingerprintVisitor fingerprinter = new ResourceFingerprintVisitor();
ior.getPrevResource().accept(fingerprinter);
SaltHash baseline = fingerprinter.getSaltAndHash();
fingerprinter = new ResourceFingerprintVisitor(baseline);
newResource.accept(fingerprinter);
if (fingerprinter.getSaltAndHash().equals(baseline)) {
txn.commit();
txn = null;
ior.setResource(ior.getPrevResource());
ior.setStatus(Status.OK);
ior.setLocationURI(FHIRUtil.buildLocationURI(type, ior.getPrevResource()));
ior.setOperationOutcome(OperationOutcome.builder()
.issue(Issue.builder()
.severity(IssueSeverity.INFORMATION)
.code(IssueType.INFORMATIONAL)
.details(CodeableConcept.builder()
.text(string("Update resource matches the existing resource; skipping the update"))
.build())
.build())
.build());
return ior; // early exit
}
}
}
// First, create the persistence event.
FHIRPersistenceEvent event = new FHIRPersistenceEvent(newResource,
buildPersistenceEventProperties(type, newResource.getId(), null, null));
// Next, set the "previous resource" in the persistence event.
event.setPrevFhirResource(ior.getPrevResource());
// Next, invoke the 'beforeUpdate' or 'beforeCreate' interceptor methods as appropriate.
boolean updateCreate = (ior.getPrevResource() == null);
if (updateCreate) {
getInterceptorMgr().fireBeforeCreateEvent(event);
} else {
if (patch != null) {
event.getProperties().put(FHIRPersistenceEvent.PROPNAME_PATCH, patch);
getInterceptorMgr().fireBeforePatchEvent(event);
} else {
getInterceptorMgr().fireBeforeUpdateEvent(event);
}
}
// write the resource back in case the interceptors modified it in some way
newResource = event.getFhirResource();
FHIRPersistenceContext persistenceContext =
FHIRPersistenceContextFactory.createPersistenceContext(event);
SingleResourceResult result = persistence.update(persistenceContext, id, newResource);
if (result.isSuccess() && result.getOutcome() != null) {
warnings.addAll(result.getOutcome().getIssue());
}
newResource = result.getResource();
event.setFhirResource(newResource); // update event with latest
ior.setResource(newResource);
ior.setOperationOutcome(FHIRUtil.buildOperationOutcome(warnings));
// Build our location URI and add it to the interceptor event structure since it is now known.
ior.setLocationURI(FHIRUtil.buildLocationURI(type, newResource));
event.getProperties().put(FHIRPersistenceEvent.PROPNAME_RESOURCE_LOCATION_URI, ior.getLocationURI().toString());
// Invoke the 'afterUpdate' interceptor methods.
if (updateCreate) {
ior.setStatus(Response.Status.CREATED);
getInterceptorMgr().fireAfterCreateEvent(event);
} else {
ior.setStatus(Response.Status.OK);
if (patch != null) {
getInterceptorMgr().fireAfterPatchEvent(event);
} else {
getInterceptorMgr().fireAfterUpdateEvent(event);
}
}
// Commit our transaction if we started one before.
txn.commit();
txn = null;
// If the deleted resource is updated, then simply return 201 instead of 200 to pass Touchstone test.
// We don't set the previous resource to null in above codes if the resource was deleted, otherwise
// it will break the code logic of the resource versioning.
if (isDeleted && ior.getStatus() == Response.Status.OK) {
ior.setStatus(Response.Status.CREATED);
}
return ior;
} finally {
// Restore the original request context.
FHIRRequestContext.set(requestContext);
// If we still have a transaction at this point, we need to rollback due to an error.
if (txn != null) {
txn.rollback();
}
log.exiting(this.getClass().getName(), "doPatchOrUpdate");
}
}
/**
* Performs a 'delete' operation on the specified resource.
*
* @param type
* the resource type associated with the Resource to be deleted
* @param id
* the id of the Resource to be deleted
* @param requestProperties
* additional request properties which supplement the HTTP headers associated with this request
* @return a FHIRRestOperationResponse that contains the results of the operation
* @throws Exception
*/
@Override
public FHIRRestOperationResponse doDelete(String type, String id, String searchQueryString) throws Exception {
log.entering(this.getClass().getName(), "doDelete");
// Validate that interaction is allowed for given resource type
validateInteraction(Interaction.DELETE, type);
// Save the current request context.
FHIRRequestContext requestContext = FHIRRequestContext.get();
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
FHIRRestOperationResponse ior = new FHIRRestOperationResponse();
// Make sure we get a transaction started before there's any chance
// it could be marked for rollback
txn.begin();
// A list of supplemental warnings to include in the response
List warnings = new ArrayList<>();
try {
String resourceTypeName = type;
if (!ModelSupport.isResourceType(type)) {
throw buildUnsupportedResourceTypeException(type);
}
Class extends Resource> resourceType =
getResourceType(resourceTypeName);
// Next, if a conditional delete was invoked then use the search criteria to find the
// resource to be deleted. Otherwise, we'll use the id value to identify the resource
// to be deleted.
Resource resourceToDelete = null;
Bundle responseBundle = null;
if (searchQueryString != null) {
int searchPageSize = FHIRConfigHelper.getIntProperty(FHIRConfiguration.PROPERTY_CONDITIONAL_DELETE_MAX_NUMBER,
FHIRConstants.FHIR_CONDITIONAL_DELETE_MAX_NUMBER_DEFAULT);
if (log.isLoggable(Level.FINE)) {
log.fine("Performing conditional delete with search criteria: "
+ Encode.forHtml(searchQueryString));
}
try {
MultivaluedMap searchParameters = getQueryParameterMap(searchQueryString);
searchParameters.putSingle(SearchConstants.COUNT, Integer.toString(searchPageSize));
// TODO add support for collecting the warnings from the search
responseBundle = doSearch(type, null, null, searchParameters, null, null, false);
} catch (FHIROperationException e) {
throw e;
} catch (Throwable t) {
String msg = "An error occurred while performing the search for a conditional delete operation.";
throw new FHIROperationException(msg, t);
}
// Check the search results to determine whether or not to perform the update operation.
int resultCount = responseBundle.getEntry().size();
if (log.isLoggable(Level.FINE)) {
log.fine("Conditional delete search yielded " + resultCount + " results.");
}
if (resultCount == 0) {
// Search yielded no matches
String msg = "Search criteria for a conditional delete operation yielded no matches.";
if (log.isLoggable(Level.FINE)) {
log.fine(msg);
}
ior.setOperationOutcome(FHIRUtil.buildOperationOutcome(msg, IssueType.NOT_FOUND, IssueSeverity.WARNING));
ior.setStatus(Status.OK);
return ior;
} else if (responseBundle.getTotal().getValue() > searchPageSize) {
String msg = "The search criteria specified for a conditional delete operation returned too many matches ( > " + searchPageSize + " ).";
throw buildRestException(msg, IssueType.MULTIPLE_MATCHES);
}
} else {
// Make sure an id value was passed in.
if (id == null) {
String msg = "The 'id' parameter is required for a delete operation.";
throw buildRestException(msg, IssueType.REQUIRED);
}
// Read the resource so it will be available to the beforeDelete interceptor methods.
try {
resourceToDelete = doRead(type, id, false, false, null, null, false).getResource();
if (resourceToDelete != null) {
responseBundle = Bundle.builder().type(BundleType.SEARCHSET)
.id(UUID.randomUUID().toString())
.entry(Entry.builder().id(id).resource(resourceToDelete).build())
.total(UnsignedInt.of(1))
.build();
} else {
warnings.add(buildOperationOutcomeIssue(IssueSeverity.WARNING, IssueType.NOT_FOUND, "Cannot find "
+ type + " with id '" + id + "'."));
}
} catch (FHIRPersistenceResourceDeletedException e) {
// Absorb this exception.
ior.setResource(doRead(type, id, false, true, null, null, false).getResource());
warnings.add(buildOperationOutcomeIssue(IssueSeverity.WARNING, IssueType.DELETED, "Resource of type '"
+ type + "' with id '" + id + "' is already deleted."));
}
}
if (responseBundle != null) {
for (Entry entry: responseBundle.getEntry()) {
id = entry.getResource().getId();
resourceToDelete = entry.getResource();
// First, invoke the 'beforeDelete' interceptor methods.
FHIRPersistenceEvent event =
new FHIRPersistenceEvent(null, buildPersistenceEventProperties(type, id, null, null));
event.setFhirResource(resourceToDelete);
getInterceptorMgr().fireBeforeDeleteEvent(event);
FHIRPersistenceContext persistenceContext =
FHIRPersistenceContextFactory.createPersistenceContext(event);
SingleResourceResult extends Resource> result = persistence.delete(persistenceContext, resourceType, id);
if (result.getOutcome() != null) {
warnings.addAll(result.getOutcome().getIssue());
}
Resource resource = result.getResource();
event.setFhirResource(resource);
if (responseBundle.getEntry().size() == 1) {
ior.setResource(resource);
}
// Invoke the 'afterDelete' interceptor methods.
getInterceptorMgr().fireAfterDeleteEvent(event);
}
warnings.add(Issue.builder()
.severity(IssueSeverity.INFORMATION)
.code(IssueType.INFORMATIONAL)
.details(CodeableConcept.builder()
.text(string("Deleted " + responseBundle.getEntry().size() + " " + type + " resource(s) " +
"with the following id(s): " +
responseBundle.getEntry().stream().map(Bundle.Entry::getId).collect(Collectors.joining(","))))
.build())
.build());
// Commit our transaction if we started one before.
txn.commit();
txn = null;
}
// The server should return either a 200 OK if the response contains a payload, or a 204 No Content with no response payload
if (!warnings.isEmpty()) {
ior.setOperationOutcome(FHIRUtil.buildOperationOutcome(warnings));
ior.setStatus(Status.OK);
} else {
ior.setStatus(Status.NO_CONTENT);
}
return ior;
} finally {
// Restore the original request context.
FHIRRequestContext.set(requestContext);
// If we previously started a transaction and it's still active, we need to rollback due to an error.
if (txn != null) {
txn.rollback();
}
log.exiting(this.getClass().getName(), "doDelete");
}
}
@Override
public SingleResourceResult extends Resource> doRead(String type, String id, boolean throwExcOnNull, boolean includeDeleted,
Resource contextResource) throws Exception {
return doRead(type, id, throwExcOnNull, includeDeleted, contextResource, null);
}
@Override
public SingleResourceResult extends Resource> doRead(String type, String id, boolean throwExcOnNull, boolean includeDeleted,
Resource contextResource, MultivaluedMap queryParameters) throws Exception {
return doRead(type, id, throwExcOnNull, includeDeleted, contextResource, queryParameters, true);
}
/**
* Performs a 'read' operation to retrieve a Resource.
*
* @param type
* the resource type associated with the Resource to be retrieved
* @param id
* the id of the Resource to be retrieved
* @param throwExcOnNull
* if true, throw an exception if returned resource is null
* @param includeDeleted
* if true, return resource even if deleted
* @param requestProperties
* additional request properties which supplement the HTTP headers associated with this request
* @param contextResource
* a FHIR resource associated with this request
* @param queryParameters
* for supporting _elements and _summary for resource read
* @param checkInteractionAllowed
* if true, check if this interaction is allowed per the tenant configuration; if false, assume interaction is allowed
* @return a {@link SingleResourceResult} containing the Resource
* @throws Exception
*/
private SingleResourceResult extends Resource> doRead(String type, String id, boolean throwExcOnNull, boolean includeDeleted,
Resource contextResource, MultivaluedMap queryParameters, boolean checkInteractionAllowed)
throws Exception {
log.entering(this.getClass().getName(), "doRead");
SingleResourceResult extends Resource> result;
// Validate that interaction is allowed for given resource type
if (checkInteractionAllowed) {
validateInteraction(Interaction.READ, type);
}
// Start a new txn in the persistence layer if one is not already active.
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
// Save the current request context.
FHIRRequestContext requestContext = FHIRRequestContext.get();
try {
String resourceTypeName = type;
if (!ModelSupport.isResourceType(type)) {
throw buildUnsupportedResourceTypeException(type);
}
Class extends Resource> resourceType = getResourceType(resourceTypeName);
FHIRSearchContext searchContext = null;
if (queryParameters != null) {
searchContext = SearchUtil.parseReadQueryParameters(resourceType, queryParameters, Interaction.READ.value(),
HTTPHandlingPreference.LENIENT.equals(requestContext.getHandlingPreference()));
}
// First, invoke the 'beforeRead' interceptor methods.
FHIRPersistenceEvent event =
new FHIRPersistenceEvent(contextResource, buildPersistenceEventProperties(type, id, null, searchContext));
getInterceptorMgr().fireBeforeReadEvent(event);
FHIRPersistenceContext persistenceContext =
FHIRPersistenceContextFactory.createPersistenceContext(event, includeDeleted, searchContext);
result = persistence.read(persistenceContext, resourceType, id);
Resource resource = result.getResource();
if (resource == null && throwExcOnNull) {
throw new FHIRPersistenceResourceNotFoundException("Resource '" + type + "/" + id + "' not found.");
}
event.setFhirResource(resource);
// Invoke the 'afterRead' interceptor methods.
getInterceptorMgr().fireAfterReadEvent(event);
// Commit our transaction if we started one before.
txn.commit();
txn = null;
return result;
} finally {
// Restore the original request context.
FHIRRequestContext.set(requestContext);
// If we previously started a transaction and it's still active, we need to rollback due to an error.
if (txn != null) {
txn.rollback();
}
log.exiting(this.getClass().getName(), "doRead");
}
}
@Override
public Resource doVRead(String type, String id, String versionId, MultivaluedMap queryParameters)
throws Exception {
log.entering(this.getClass().getName(), "doVRead");
// Validate that interaction is allowed for given resource type
validateInteraction(Interaction.VREAD, type);
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
// Start a new txn in the persistence layer if one is not already active.
txn.begin();
Resource resource = null;
// Save the current request context.
FHIRRequestContext requestContext = FHIRRequestContext.get();
try {
String resourceTypeName = type;
if (!ModelSupport.isResourceType(type)) {
throw buildUnsupportedResourceTypeException(type);
}
Class extends Resource> resourceType = getResourceType(resourceTypeName);
FHIRSearchContext searchContext = null;
if (queryParameters != null) {
searchContext = SearchUtil.parseReadQueryParameters(resourceType, queryParameters, Interaction.VREAD.value(),
HTTPHandlingPreference.LENIENT.equals(requestContext.getHandlingPreference()));
}
// First, invoke the 'beforeVread' interceptor methods.
FHIRPersistenceEvent event =
new FHIRPersistenceEvent(null, buildPersistenceEventProperties(type, id, versionId, searchContext));
getInterceptorMgr().fireBeforeVreadEvent(event);
FHIRPersistenceContext persistenceContext =
FHIRPersistenceContextFactory.createPersistenceContext(event, searchContext);
resource = persistence.vread(persistenceContext, resourceType, id, versionId).getResource();
if (resource == null) {
throw new FHIRPersistenceResourceNotFoundException("Resource '"
+ resourceType.getSimpleName() + "/" + id + "' version " + versionId + " not found.");
}
event.setFhirResource(resource);
// Invoke the 'afterVread' interceptor methods.
getInterceptorMgr().fireAfterVreadEvent(event);
// Commit our transaction if we started one before.
txn.commit();
txn = null;
return resource;
} finally {
// Restore the original request context.
FHIRRequestContext.set(requestContext);
// If we previously started a transaction and it's still active, we need to rollback due to an error.
if (txn != null) {
txn.rollback();
}
log.exiting(this.getClass().getName(), "doVRead");
}
}
/**
* Performs the work of retrieving versions of a Resource.
*
* @param type
* the resource type associated with the Resource to be retrieved
* @param id
* the id of the Resource to be retrieved
* @param queryParameters
* a Map containing the query parameters from the request URL
* @param requestUri the URI from the request
* @param requestProperties
* additional request properties which supplement the HTTP headers associated with this request
* @return a Bundle containing the history of the specified Resource
* @throws Exception
*/
@Override
public Bundle doHistory(String type, String id, MultivaluedMap queryParameters, String requestUri)
throws Exception {
log.entering(this.getClass().getName(), "doHistory");
// Validate that interaction is allowed for given resource type
validateInteraction(Interaction.HISTORY, type);
// Start a new txn in the persistence layer if one is not already active.
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
Bundle bundle = null;
// Save the current request context.
FHIRRequestContext requestContext = FHIRRequestContext.get();
try {
String resourceTypeName = type;
if (!ModelSupport.isResourceType(type)) {
throw buildUnsupportedResourceTypeException(type);
}
Class extends Resource> resourceType = getResourceType(resourceTypeName);
FHIRHistoryContext historyContext = FHIRPersistenceUtil.parseHistoryParameters(queryParameters,
HTTPHandlingPreference.LENIENT.equals(requestContext.getHandlingPreference()));
// First, invoke the 'beforeHistory' interceptor methods.
FHIRPersistenceEvent event =
new FHIRPersistenceEvent(null, buildPersistenceEventProperties(type, id, null, null));
getInterceptorMgr().fireBeforeHistoryEvent(event);
FHIRPersistenceContext persistenceContext =
FHIRPersistenceContextFactory.createPersistenceContext(event, historyContext);
List extends Resource> resources =
persistence.history(persistenceContext, resourceType, id).getResource();
bundle = createHistoryBundle(resources, historyContext, type);
bundle = addLinks(historyContext, bundle, requestUri);
event.setFhirResource(bundle);
// Invoke the 'afterHistory' interceptor methods.
getInterceptorMgr().fireAfterHistoryEvent(event);
// Commit our transaction if we started one before.
txn.commit();
txn = null;
return bundle;
} finally {
// Restore the original request context.
FHIRRequestContext.set(requestContext);
// If we previously started a transaction and it's still active, we need to rollback due to an error.
if (txn != null) {
txn.rollback();
}
log.exiting(this.getClass().getName(), "doHistory");
}
}
@Override
public Bundle doSearch(String type, String compartment, String compartmentId,
MultivaluedMap queryParameters, String requestUri, Resource contextResource) throws Exception {
return doSearch(type, compartment, compartmentId, queryParameters, requestUri, contextResource, true);
}
/**
* Performs heavy lifting associated with a 'search' operation.
*
* @param type
* the resource type associated with the search
* @param compartment
* the compartment associated with the search
* @param compartmentId
* the ID of the compartment associated with the search
* @param queryParameters
* a Map containing the query parameters from the request URL
* @param requestUri
* the request URI
* @param contextResource
* a FHIR resource associated with this request
* @param checkInteractionAllowed
* if true, check if this interaction is allowed per the tenant configuration; if false, assume interaction is allowed
* @return a Bundle containing the search result set
* @throws Exception
*/
private Bundle doSearch(String type, String compartment, String compartmentId,
MultivaluedMap queryParameters, String requestUri,
Resource contextResource, boolean checkInteractionAllowed) throws Exception {
log.entering(this.getClass().getName(), "doSearch");
// Validate that interaction is allowed for given resource type
if (checkInteractionAllowed) {
validateInteraction(Interaction.SEARCH, type);
}
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
// Start a new txn in the persistence layer if one is not already active.
txn.begin();
Bundle bundle = null;
// Save the current request context.
FHIRRequestContext requestContext = FHIRRequestContext.get();
try {
String resourceTypeName = type;
// Check to see if it's supported, else, throw a bad request.
// If this is removed, it'll result in nullpointer when processing the request
if (!ModelSupport.isResourceType(type)) {
throw buildUnsupportedResourceTypeException(type);
}
Class extends Resource> resourceType = getResourceType(resourceTypeName);
FHIRSearchContext searchContext = SearchUtil.parseCompartmentQueryParameters(compartment, compartmentId, resourceType, queryParameters,
HTTPHandlingPreference.LENIENT.equals(requestContext.getHandlingPreference()));
// First, invoke the 'beforeSearch' interceptor methods.
FHIRPersistenceEvent event =
new FHIRPersistenceEvent(contextResource, buildPersistenceEventProperties(type, null, null, searchContext));
getInterceptorMgr().fireBeforeSearchEvent(event);
FHIRPersistenceContext persistenceContext =
FHIRPersistenceContextFactory.createPersistenceContext(event, searchContext);
List resources =
persistence.search(persistenceContext, resourceType).getResource();
bundle = createSearchBundle(resources, searchContext, type);
if (requestUri != null) {
bundle = addLinks(searchContext, bundle, requestUri);
}
event.setFhirResource(bundle);
// Invoke the 'afterSearch' interceptor methods.
getInterceptorMgr().fireAfterSearchEvent(event);
// Commit our transaction if we started one before.
txn.commit();
txn = null;
return bundle;
} finally {
// Restore the original request context.
FHIRRequestContext.set(requestContext);
// If we previously started a transaction and it's still active, we need to rollback due to an error.
if (txn != null) {
txn.rollback();
}
log.exiting(this.getClass().getName(), "doSearch");
}
}
/**
* Helper method which invokes a custom operation.
*
* @param operationContext
* the FHIROperationContext associated with the request
* @param resourceTypeName
* the resource type associated with the request
* @param logicalId
* the resource logical id associated with the request
* @param versionId
* the resource version id associated with the request
* @param operationName
* the name of the custom operation to be invoked
* @param resource
* the input resource associated with the custom operation to be invoked
* @param queryParameters
* query parameters may be passed instead of a Parameters resource for certain custom operations invoked
* via GET
* @return a Resource that represents the response to the custom operation
* @throws Exception
*/
@Override
public Resource doInvoke(FHIROperationContext operationContext, String resourceTypeName,
String logicalId, String versionId, String operationName,
Resource resource, MultivaluedMap queryParameters) throws Exception {
log.entering(this.getClass().getName(), "doInvoke");
// Save the current request context.
FHIRRequestContext requestContext = FHIRRequestContext.get();
try {
Class extends Resource> resourceType = null;
if (resourceTypeName != null) {
resourceType = getResourceType(resourceTypeName);
}
String operationKey = (resourceTypeName == null ? operationName : operationName + ":" + resourceTypeName);
FHIROperation operation = FHIROperationRegistry.getInstance().getOperation(operationKey);
Parameters parameters = null;
if (resource instanceof Parameters) {
parameters = (Parameters) resource;
} else {
if (resource == null) {
// build parameters object from query parameters
parameters =
FHIROperationUtil.getInputParameters(operation.getDefinition(), queryParameters);
} else {
// wrap resource in a parameters object
parameters =
FHIROperationUtil.getInputParameters(operation.getDefinition(), resource);
}
}
// Add properties to the FHIR operation context
setOperationContextProperties(operationContext, resourceTypeName);
if (log.isLoggable(Level.FINE)) {
log.fine("Invoking operation '" + operationName + "', context=\n"
+ operationContext.toString());
}
Parameters result =
operation.invoke(operationContext, resourceType, logicalId, versionId, parameters, this);
if (log.isLoggable(Level.FINE)) {
log.fine("Returned from invocation of operation '" + operationName + "'...");
}
// if single resource output parameter, return the resource
if (FHIROperationUtil.hasSingleResourceOutputParameter(result)) {
return FHIROperationUtil.getSingleResourceOutputParameter(result);
}
return result;
} finally {
// Restore the original request context.
FHIRRequestContext.set(requestContext);
log.exiting(this.getClass().getName(), "doInvoke");
}
}
@Override
public Bundle doBundle(Bundle inputBundle, boolean skippableUpdates) throws Exception {
log.entering(this.getClass().getName(), "doBundle");
FHIRRequestContext requestContext = FHIRRequestContext.get();
try {
// First, validate the bundle and save the error / warning responses by index entry.
Map validationResponseEntries = validateBundle(inputBundle);
// Next, process each of the entries in the bundle.
return processBundleEntries(inputBundle, validationResponseEntries, skippableUpdates);
} finally {
// Restore the original request context.
FHIRRequestContext.set(requestContext);
log.exiting(this.getClass().getName(), "doBundle");
}
}
@Override
public FHIRPersistenceTransaction getTransaction() throws Exception {
return persistence.getTransaction();
}
/**
* Validate the input resource and throw if there are validation errors
*
* @param resource
* @throws FHIRValidationException if an error occurs during validation
* @throws FHIROperationException if there are validation errors
* @return A list of validation warnings
*/
private List validateInput(Resource resource)
throws FHIRValidationException, FHIROperationException {
List issues = validateResource(resource);
if (!issues.isEmpty()) {
for (OperationOutcome.Issue issue : issues) {
if (FHIRUtil.isFailure(issue.getSeverity())) {
throw new FHIROperationException("Input resource failed validation.").withIssue(issues);
}
}
if (log.isLoggable(Level.FINE)) {
String info = issues.stream()
.flatMap(issue -> Stream.of(issue.getDetails()))
.flatMap(details -> Stream.of(details.getText()))
.flatMap(text -> Stream.of(text.getValue()))
.collect(Collectors.joining(", "));
log.fine("Validation warnings for input resource: " + info);
}
}
return issues;
}
/**
* @param issues
* @return
*/
private boolean anyFailureInIssues(List issues) {
boolean hasFailure = false;
for (OperationOutcome.Issue issue : issues) {
if (FHIRUtil.isFailure(issue.getSeverity())) {
hasFailure = true;
}
}
return hasFailure;
}
/**
* Performs validation of a request Bundle and returns a Map of entry indices to error / warning
* response entries that correspond to the entries in the request Bundle.
*
* @param bundle
* the bundle to be validated
* @return a map of entry indices to error responses / warnings; empty if there are no validation warnings or errors
* @throws Exception
*/
private Map validateBundle(Bundle bundle) throws Exception {
log.entering(this.getClass().getName(), "validateBundle");
Map validationResponseEntries = new HashMap<>();
try {
// Make sure the bundle isn't empty
if (bundle == null) {
String msg = "Bundle parameter is missing or empty.";
throw buildRestException(msg, IssueType.REQUIRED);
}
BundleType.Value requestType = bundle.getType().getValueAsEnum();
if (requestType != BundleType.Value.BATCH && requestType != BundleType.Value.TRANSACTION) {
// TODO add support for posting history bundles
String msg = "Bundle.type must be either 'batch' or 'transaction'.";
throw buildRestException(msg, IssueType.VALUE);
}
if (requestType == BundleType.Value.TRANSACTION && !persistence.isTransactional()) {
// For a 'transaction' interaction, if the underlying persistence layer doesn't support
// transactions, then throw an error.
String msg = "Bundled 'transaction' request cannot be processed because "
+ "the configured persistence layer does not support transactions.";
IssueType extendedIssueType = IssueType.NOT_SUPPORTED.toBuilder()
.extension(Extension.builder()
.url(EXT_BASE + "not-supported-detail")
.value(Code.of("interaction"))
.build())
.build();
throw buildRestException(msg, extendedIssueType);
}
// For 'transaction' bundle requests, keep a list of issues in case of failure
List issueList = new ArrayList();
Set localIdentifiers = new HashSet<>();
for (int i = 0; i < bundle.getEntry().size(); i++) {
// Create a corresponding response entry and add it to the response bundle.
Bundle.Entry requestEntry = bundle.getEntry().get(i);
Bundle.Entry responseEntry = null;
// Validate 'requestEntry' and update 'responseEntry' with any errors.
try {
Bundle.Entry.Request request = requestEntry.getRequest();
// Verify that the request field is present.
if (request == null) {
String msg = "Bundle.Entry is missing the 'request' field.";
throw buildRestException(msg, IssueType.REQUIRED);
}
// Verify that a method was specified.
if (request.getMethod() == null || request.getMethod().getValue() == null) {
String msg = "Bundle.Entry.request is missing the 'method' field";
throw buildRestException(msg, IssueType.REQUIRED);
}
// Verify that a URL was specified.
if (request.getUrl() == null || request.getUrl().getValue() == null) {
String msg = "Bundle.Entry.request is missing the 'url' field";
throw buildRestException(msg, IssueType.REQUIRED);
}
// Verify that the fullUrl field is not a duplicate if it specifies a local reference
// and if the request method is POST or PUT.
if (request.getMethod().equals(HTTPVerb.POST) || request.getMethod().equals(HTTPVerb.PUT)) {
String localIdentifier = retrieveLocalIdentifier(requestEntry);
if (localIdentifier != null) {
if (localIdentifiers.contains(localIdentifier)) {
String msg = "Duplicate local identifier encountered in bundled request entry: " + localIdentifier;
throw buildRestException(msg, IssueType.DUPLICATE);
}
localIdentifiers.add(localIdentifier);
}
}
// Retrieve the resource from the request entry to prepare for some validations below.
Resource resource = requestEntry.getResource();
// Validate the resource for the requested HTTP method.
methodValidation(request.getMethod(), resource);
// If the request entry contains a resource, then validate it now.
if (resource != null) {
List issues = validateResource(resource);
if (!issues.isEmpty()) {
OperationOutcome oo = FHIRUtil.buildOperationOutcome(issues);
if (anyFailureInIssues(issues)) {
if (requestType == BundleType.Value.TRANSACTION) {
issueList.addAll(issues);
} else {
responseEntry = Entry.builder()
.response(Entry.Response.builder()
.status(SC_BAD_REQUEST_STRING)
.build())
.resource(oo)
.build();
}
} else {
responseEntry = Entry.builder()
.response(Entry.Response.builder()
.status(SC_ACCEPTED_STRING)
.outcome(oo)
.build())
.build();
}
}
}
} catch (FHIROperationException e) {
if (log.isLoggable(Level.FINE)) {
log.log(Level.FINE, "Failed to process BundleEntry ["
+ bundle.getEntry().indexOf(requestEntry) + "]", e);
}
if (requestType == BundleType.Value.TRANSACTION) {
issueList.addAll(e.getIssues());
} else {
Entry.Response response = Entry.Response.builder()
.status(SC_BAD_REQUEST_STRING)
.build();
responseEntry = Entry.builder()
.response(response)
.resource(FHIRUtil.buildOperationOutcome(e, false))
.build();
}
} finally {
if (responseEntry != null) {
validationResponseEntries.put(i, responseEntry);
}
}
} // End foreach requestEntry
// If this is a "transaction" interaction and we encountered any errors, then we'll
// abort processing this request right now since a transaction interaction is supposed to be
// all or nothing.
if (requestType == BundleType.Value.TRANSACTION && issueList.size() > 0) {
String msg =
"One or more errors were encountered while validating a 'transaction' request bundle.";
throw buildRestException(msg, IssueType.INVALID).withIssue(issueList);
}
return validationResponseEntries;
} finally {
log.exiting(this.getClass().getName(), "validateBundle");
}
}
/**
* Perform method-specific validation of the resource
*/
private void methodValidation(HTTPVerb method, Resource resource) throws FHIRPersistenceException, FHIROperationException {
switch(method.getValueAsEnum()) {
case PATCH:
case POST:
break;
case DELETE:
// If the "delete" operation isn't supported by the configured persistence layer,
// then we need to fail validation of this bundle entry.
if (!persistence.isDeleteSupported()) {
String msg = "Bundle.Entry.request contains unsupported HTTP method: "
+ method.getValue();
IssueType extendedIssueType = IssueType.NOT_SUPPORTED.toBuilder()
.extension(Extension.builder()
.url(EXT_BASE + "not-supported-detail")
.value(Code.of("interaction"))
.build())
.build();
throw buildRestException(msg, extendedIssueType);
}
// Purposefully fall through to next clause
case HEAD:
case GET:
if (resource != null) {
String msg =
"Bundle.Entry.resource not allowed for BundleEntry with " + method.getValue() + " method.";
throw buildRestException(msg, IssueType.INVALID);
}
break;
case PUT:
if (resource == null) {
String msg =
"Bundle.Entry.resource is required for BundleEntry with PUT method.";
throw buildRestException(msg, IssueType.INVALID);
}
break;
default:
String msg = "Bundle.Entry.request contains unsupported HTTP method: " + method.getValue();
throw buildRestException(msg, IssueType.INVALID);
}
}
/**
* This function will perform the version-aware update check by making sure that the If-Match request header value
* (if present) specifies a version # equal to the current latest version of the resource. If the check fails, then
* a FHIRRestException will be thrown. If the check succeeds then nothing occurs and processing continues.
*
* @param currentResource
* the current latest version of the resource
*/
private void performVersionAwareUpdateCheck(Resource currentResource, String ifMatchValue) throws FHIROperationException {
if (ifMatchValue != null) {
if (log.isLoggable(Level.FINE)) {
log.fine("Performing a version aware update. ETag value = " + ifMatchValue);
}
String ifMatchVersion = getVersionIdFromETagValue(ifMatchValue);
// Make sure that we got a version # from the request header.
// If not, then return a 400 Bad Request status code.
if (ifMatchVersion == null || ifMatchVersion.isEmpty()) {
throw buildRestException("Invalid ETag value specified in request: "
+ ifMatchValue, IssueType.PROCESSING);
}
if (log.isLoggable(Level.FINE)) {
log.fine("Version id from ETag value specified in request: " + ifMatchVersion);
}
// Retrieve the version #'s from the current and updated resources.
String currentVersion = null;
if (currentResource.getMeta() != null
&& currentResource.getMeta().getVersionId() != null) {
currentVersion = currentResource.getMeta().getVersionId().getValue();
}
// Next, make sure that the If-Match version matches the version # found
// in the current latest version of the resource.
// If they don't match we'll return an HTTP 412 (Precondition Failed) status code.
if (!ifMatchVersion.equals(currentVersion)) {
String msg = "If-Match version '" + ifMatchVersion
+ "' does not match current latest version of resource: " + currentVersion;
IssueType extendedIssueType = IssueType.CONFLICT.toBuilder()
.extension(Extension.builder()
.url(EXT_BASE + "http-failed-precondition")
.value(string("If-Match"))
.build())
.build();
throw buildRestException(msg, extendedIssueType);
}
}
}
private FHIROperationException buildUnsupportedResourceTypeException(String resourceTypeName) {
String msg = "'" + Encode.forHtml(resourceTypeName) + "' is not a valid resource type.";
Issue issue = OperationOutcome.Issue.builder()
.severity(IssueSeverity.FATAL)
.code(IssueType.NOT_SUPPORTED.toBuilder()
.extension(Extension.builder()
.url(EXT_BASE + "not-supported-detail")
.value(Code.of("resource"))
.build())
.build())
.details(CodeableConcept.builder().text(string(msg)).build())
.build();
return new FHIROperationException(msg).withIssue(issue);
}
private FHIROperationException buildRestException(String msg, IssueType issueType) {
return buildRestException(msg, issueType, IssueSeverity.FATAL);
}
private FHIROperationException buildRestException(String msg, IssueType issueType, IssueSeverity severity) {
return new FHIROperationException(msg).withIssue(buildOperationOutcomeIssue(severity, issueType, msg));
}
/**
* Builds an OperationOutcomeIssue with the respective values for some of the fields.
*/
private OperationOutcome.Issue buildOperationOutcomeIssue(IssueSeverity severity, IssueType type, String msg) {
return OperationOutcome.Issue.builder()
.severity(severity)
.code(type)
.details(CodeableConcept.builder().text(string(msg)).build())
.build();
}
/**
* Retrieves the version id value from an ETag header value. The ETag header value will be of the form:
* W/"".
*
* @param ifMatchValue
* the value of the If-Match request header.
*/
private String getVersionIdFromETagValue(String ifMatchValue) {
String result = null;
if (ifMatchValue != null) {
if (ifMatchValue.startsWith("W/")) {
String s = ifMatchValue.substring(2);
// If the part after "W/" starts and ends with a ",
// then extract the part between the " characters and we're done.
if (s.charAt(0) == '\"' && s.charAt(s.length() - 1) == '\"') {
result = s.substring(1, s.length() - 1);
}
}
}
return result;
}
/**
* This function will process each request contained in the specified request bundle, and update the response bundle
* with the appropriate response information.
*
* @param requestBundle
* the bundle containing the requests
* @param validationResponseEntries
* a map from entry indices to the corresponding response entries created during validation
* @param skippableUpdates
* if true, and the bundle contains an update for which the resource content in the update matches the existing
* resource on the server, then skip the update; if false, then always attempt the updates specified in the bundle
* @return a response bundle
*/
private Bundle processBundleEntries(Bundle requestBundle, Map validationResponseEntries, boolean skippableUpdates) throws Exception {
log.entering(this.getClass().getName(), "processBundleEntries");
// Generate a request correlation id for this request bundle.
bundleRequestCorrelationId = UUID.randomUUID().toString();
if (log.isLoggable(Level.FINE)) {
log.fine("Processing request bundle, request-correlation-id=" + bundleRequestCorrelationId);
}
try {
// Build a mapping of local identifiers to external identifiers for local reference resolution.
Map localRefMap = buildLocalRefMap(requestBundle, validationResponseEntries);
// Process entries.
BundleType.Value bundleType = requestBundle.getType().getValueAsEnum();
List responseEntries = processEntriesByMethod(requestBundle, validationResponseEntries,
bundleType == BundleType.Value.TRANSACTION, localRefMap, bundleRequestCorrelationId, skippableUpdates);
// Build the response bundle.
// TODO add support for posting history bundles
Bundle.Builder bundleResponseBuilder = Bundle.builder().entry(responseEntries);
if (bundleType == BundleType.Value.BATCH) {
bundleResponseBuilder.type(BundleType.BATCH_RESPONSE);
} else if (bundleType == BundleType.Value.TRANSACTION) {
bundleResponseBuilder.type(BundleType.TRANSACTION_RESPONSE);
}
return bundleResponseBuilder.build();
} finally {
if (log.isLoggable(Level.FINE)) {
log.fine("Finished processing request bundle, request-correlation-id="
+ bundleRequestCorrelationId);
}
// Clear the request correlation id field since we're done processing the bundle.
bundleRequestCorrelationId = null;
log.exiting(this.getClass().getName(), "processBundleEntries");
}
}
/**
* Processes request entries in the specified request bundle whose method matches 'httpMethod'.
*
* @param requestBundle
* the bundle containing the request entries
* @param validationResponseEntries
* the response entries with errors/warnings constructed during validation
* @param failFast
* a boolean value indicating if processing should stop on first failure
* @param localRefMap
* the map of local references to external references
* @param bundleRequestProperties
* the bundle request properties
* @param bundleRequestCorrelationId
* the bundle request correlation ID
* @param skippableUpdates
* if true, and the bundle contains an update for which the resource content in the update matches the existing
* resource on the server, then skip the update; if false, then always attempt the updates specified in the bundle
* @return a list of entries for the response bundle
* @throws Exception
*/
private List processEntriesByMethod(Bundle requestBundle, Map validationResponseEntries,
boolean failFast, Map localRefMap, String bundleRequestCorrelationId, boolean skippableUpdates) throws Exception {
log.entering(this.getClass().getName(), "processEntriesByMethod");
FHIRTransactionHelper txn = null;
try {
// Placeholder for response entries
Entry[] responseEntries = new Entry[requestBundle.getEntry().size()];
// Group the request entries by request method; LinkedHashMap because order is important
Map> requestEntriesByMethod = new LinkedHashMap<>(6);
requestEntriesByMethod.put(HTTPVerb.Value.DELETE, new ArrayList<>());
requestEntriesByMethod.put(HTTPVerb.Value.POST, new ArrayList<>());
requestEntriesByMethod.put(HTTPVerb.Value.PUT, new ArrayList<>());
requestEntriesByMethod.put(HTTPVerb.Value.GET, new ArrayList<>());
requestEntriesByMethod.put(HTTPVerb.Value.PATCH, new ArrayList<>());
requestEntriesByMethod.put(HTTPVerb.Value.HEAD, new ArrayList<>());
for (int i = 0; i < requestBundle.getEntry().size(); i++) {
if (validationResponseEntries.containsKey(i) &&
!validationResponseEntries.get(i).getResponse().getStatus().equals(SC_ACCEPTED_STRING)) {
// validation marked this entry as invalid, so write the validation response entry and skip it
responseEntries[i] = validationResponseEntries.get(i);
continue;
}
Entry entry = requestBundle.getEntry().get(i);
requestEntriesByMethod.get(entry.getRequest().getMethod().getValueAsEnum()).add(i);
}
if (log.isLoggable(Level.FINE)) {
log.fine("Bundle request indices to be processed: " +
"DELETE" + requestEntriesByMethod.get(HTTPVerb.Value.DELETE) + ", " +
"POST" + requestEntriesByMethod.get(HTTPVerb.Value.POST) + ", " +
"PUT" + requestEntriesByMethod.get(HTTPVerb.Value.PUT) + ", " +
"GET" + requestEntriesByMethod.get(HTTPVerb.Value.GET) + ", " +
"PATCH" + requestEntriesByMethod.get(HTTPVerb.Value.PATCH) + ", " +
"HEAD" + requestEntriesByMethod.get(HTTPVerb.Value.HEAD));
}
// If we're working on a 'transaction' type interaction, or an interaction
// where we're processing only GET or HEAD requests, then start a new transaction now.
BundleType.Value bundleType = requestBundle.getType().getValueAsEnum();
if (bundleType == BundleType.Value.TRANSACTION ||
((!requestEntriesByMethod.get(HTTPVerb.Value.GET).isEmpty() ||
!requestEntriesByMethod.get(HTTPVerb.Value.HEAD).isEmpty()) &&
requestEntriesByMethod.get(HTTPVerb.Value.DELETE).isEmpty() &&
requestEntriesByMethod.get(HTTPVerb.Value.POST).isEmpty() &&
requestEntriesByMethod.get(HTTPVerb.Value.PUT).isEmpty() &&
requestEntriesByMethod.get(HTTPVerb.Value.PATCH).isEmpty())) {
txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
if (log.isLoggable(Level.FINE)) {
log.fine("Started new transaction for '" + bundleType.toString() +
"' bundle, request-correlation-id=" + bundleRequestCorrelationId);
}
}
for (Map.Entry> methodIndices : requestEntriesByMethod.entrySet()) {
HTTPVerb.Value httpMethod = methodIndices.getKey();
List entryIndices = methodIndices.getValue();
if (log.isLoggable(Level.FINER)) {
log.finer("Beginning processing for method: " + httpMethod);
}
// For PUT and DELETE requests, we need to sort the indices by the request url path value.
if (httpMethod == HTTPVerb.Value.PUT || httpMethod == HTTPVerb.Value.DELETE) {
sortBundleRequestEntries(requestBundle, entryIndices);
if (log.isLoggable(Level.FINER)) {
log.finer("Sorted bundle request indices to be processed: "
+ entryIndices.toString());
}
}
// Now visit each of the request entries using the list of indices obtained above.
// Use hashmap to store both the index and the accordingly updated response bundle entry.
Map responseIndexAndEntries = new HashMap();
for (Integer entryIndex : entryIndices) {
Entry requestEntry = requestBundle.getEntry().get(entryIndex);
Entry.Request request = requestEntry.getRequest();
StringBuilder requestDescription = new StringBuilder();
long initialTime = System.currentTimeMillis();
try {
FHIRUrlParser requestURL = new FHIRUrlParser(request.getUrl().getValue());
// Log our initial info message for this request.
requestDescription.append("entryIndex:[");
requestDescription.append(entryIndex);
requestDescription.append("] correlationId:[");
requestDescription.append(bundleRequestCorrelationId);
requestDescription.append("] method:[");
requestDescription.append(request.getMethod().getValue());
requestDescription.append("] uri:[");
requestDescription.append(request.getUrl().getValue());
requestDescription.append("]");
if (log.isLoggable(Level.FINE)) {
log.fine("Processing bundled request: " + requestDescription.toString());
if (log.isLoggable(Level.FINER)) {
log.finer("--> path: '" + requestURL.getPath() + "'");
log.finer("--> query: '" + requestURL.getQuery() + "'");
}
}
// Construct the absolute requestUri to be used for any response bundles associated
// with history and search requests.
String absoluteUri = getAbsoluteUri(getRequestUri(), request.getUrl().getValue());
if (request.getMethod().equals(HTTPVerb.GET)) {
responseEntries[entryIndex] = processEntryForGet(request, requestURL, absoluteUri,
requestDescription.toString(), initialTime);
} else if (request.getMethod().equals(HTTPVerb.POST)) {
Entry validationResponseEntry = validationResponseEntries.get(entryIndex);
responseEntries[entryIndex] = processEntryForPost(requestEntry, validationResponseEntry, responseIndexAndEntries,
entryIndex, localRefMap, requestURL, absoluteUri, requestDescription.toString(), initialTime, (bundleType == BundleType.Value.TRANSACTION));
} else if (request.getMethod().equals(HTTPVerb.PUT)) {
Entry validationResponseEntry = validationResponseEntries.get(entryIndex);
responseEntries[entryIndex] = processEntryForPut(requestEntry, validationResponseEntry, responseIndexAndEntries,
entryIndex, localRefMap, requestURL, absoluteUri, requestDescription.toString(), initialTime, skippableUpdates, (bundleType == BundleType.Value.TRANSACTION));
} else if (request.getMethod().equals(HTTPVerb.PATCH)) {
responseEntries[entryIndex] = processEntryForPatch(requestEntry, requestURL,entryIndex,
requestDescription.toString(), initialTime, skippableUpdates);
} else if (request.getMethod().equals(HTTPVerb.DELETE)) {
responseEntries[entryIndex] = processEntryForDelete(requestURL, requestDescription.toString(), initialTime);
} else {
// Internal error, should not get here!
throw new IllegalStateException("Internal Server Error: reached an unexpected code location.");
}
} catch (FHIRPersistenceResourceNotFoundException e) {
if (failFast) {
updateIssuesWithEntryIndexAndThrow(entryIndex, e);
}
responseEntries[entryIndex] = Entry.builder()
.resource(FHIRUtil.buildOperationOutcome(e, false))
.response(Entry.Response.builder()
.status(SC_NOT_FOUND_STRING)
.build())
.build();
logBundledRequestCompletedMsg(requestDescription.toString(), initialTime, SC_NOT_FOUND);
} catch (FHIRPersistenceResourceDeletedException e) {
if (failFast) {
updateIssuesWithEntryIndexAndThrow(entryIndex, e);
}
responseEntries[entryIndex] = Entry.builder()
.resource(FHIRUtil.buildOperationOutcome(e, false))
.response(Entry.Response.builder()
.status(SC_GONE_STRING)
.build())
.build();
logBundledRequestCompletedMsg(requestDescription.toString(), initialTime, SC_GONE);
} catch (FHIROperationException e) {
if (failFast) {
updateIssuesWithEntryIndexAndThrow(entryIndex, e);
}
Status status;
if (e instanceof FHIRSearchException) {
status = Status.BAD_REQUEST;
} else {
status = IssueTypeToHttpStatusMapper.issueListToStatus(e.getIssues());
}
responseEntries[entryIndex] = Entry.builder()
.resource(FHIRUtil.buildOperationOutcome(e, false))
.response(Entry.Response.builder()
.status(string(Integer.toString(status.getStatusCode())))
.build())
.build();
logBundledRequestCompletedMsg(requestDescription.toString(), initialTime, status.getStatusCode());
}
} // end foreach method entry
if (log.isLoggable(Level.FINER)) {
log.finer("Finished processing for method: " + httpMethod);
}
} // end foreach method
// Commit transaction if started
if (txn != null) {
if (log.isLoggable(Level.FINE)) {
log.fine("Committing transaction for '" + bundleType.toString() +
"' bundle, request-correlation-id=" + bundleRequestCorrelationId);
}
txn.commit();
txn = null;
}
return Arrays.asList(responseEntries);
} finally {
if (txn != null) {
txn.rollback();
txn = null;
}
log.exiting(this.getClass().getName(), "processEntriesForMethod");
}
}
private void updateIssuesWithEntryIndexAndThrow(Integer entryIndex, FHIROperationException cause) throws FHIROperationException {
String msg = "Error while processing request bundle on entry " + entryIndex;
List updatedIssues = cause.getIssues().stream()
.map(i -> i.toBuilder().expression(string("Bundle.entry[" + entryIndex + "]")).build())
.collect(Collectors.toList());
// no need to keep the issues in the cause any more since we've "promoted" them to the wrapped exception
cause.withIssue(Collections.emptyList());
throw new FHIRRestBundledRequestException(msg, cause).withIssue(updatedIssues);
}
/**
* Processes a request entry with a request method of Patch.
*
* @param requestEntry
* the request bundle entry
* @param requestURL
* the request URL
* @param entryIndex
* the bundle entry index of the bundle entry being processed
* @param requestDescription
* a description of the request
* @param initialTime
* the time the bundle entry processing started
* @param skippableUpdate
* if true, and the resource content in the update matches the existing resource on the server, then skip the update;
* if false, then always attempt the update
* @return the bundle entry response
* @throws Exception
*/
private Entry processEntryForPatch(Entry requestEntry, FHIRUrlParser requestURL, Integer entryIndex, String requestDescription,
long initialTime, boolean skippableUpdate) throws Exception {
FHIRRestOperationResponse ior = null;
String[] pathTokens = requestURL.getPathTokens();
String resourceType = null;
String resourceId = null;
// Process a PATCH.
if (pathTokens.length == 1) {
// A single-part url would be a conditional update: ?
// This is not yet supported for PATCH requests.
String msg = "Conditional update operation is not supported for PATCH requests.";
throw buildRestException(msg, IssueType.NOT_SUPPORTED);
} else if (pathTokens.length == 2) {
// A two-part url would be a normal patch: /.
resourceType = pathTokens[0];
resourceId = pathTokens[1];
} else {
// A url with any other pattern is an error.
String msg = "Request URL for bundled PATCH request should have path part with two tokens (/).";
throw buildRestException(msg, IssueType.INVALID);
}
checkResourceType(resourceType);
if (!requestEntry.getResource().is(Parameters.class)) {
String msg="Request resource type for PATCH request must be type 'Parameters'";
throw buildRestException(msg, IssueType.INVALID);
}
Parameters parameters = requestEntry.getResource().as(Parameters.class);
FHIRPatch patch = FHIRPathPatch.from(parameters);
ior = doPatch(resourceType, resourceId, patch, null, null, skippableUpdate);
return buildResponseBundleEntry(ior, null, requestDescription, initialTime);
}
/**
* Processes a request entry with a request method of GET.
*
* @param entryRequest
* the request portion of the corresponding request bundle entry
* @param requestURL
* the request URL
* @param absoluteUri
* the absolute URI
* @param requestDescription
* a description of the request
* @param initialTime
* the time the bundle entry processing started
* @return the bundle entry response
* @throws Exception
*/
private Entry processEntryForGet(Entry.Request entryRequest, FHIRUrlParser requestURL, String absoluteUri,
String requestDescription, long initialTime) throws Exception {
String[] pathTokens = requestURL.getPathTokens();
MultivaluedMap queryParams = requestURL.getQueryParameters();
Resource resource = null;
// Process a GET (read, vread, history, search, etc.).
// Determine the type of request from the path tokens.
if (pathTokens.length > 0 && pathTokens[pathTokens.length - 1].startsWith("$")) {
// This is a custom operation request.
// Chop off the '$' and save the name
String operationName = pathTokens[pathTokens.length - 1].substring(1);
FHIROperationContext operationContext;
switch (pathTokens.length) {
case 1:
operationContext = FHIROperationContext.createSystemOperationContext();
updateOperationContext(operationContext, "GET");
resource = doInvoke(operationContext, null, null, null, operationName, null, queryParams);
break;
case 2:
checkResourceType(pathTokens[0]);
operationContext = FHIROperationContext.createResourceTypeOperationContext();
updateOperationContext(operationContext, "GET");
resource = doInvoke(operationContext, pathTokens[0], null, null, operationName, null, queryParams);
break;
case 3:
checkResourceType(pathTokens[0]);
operationContext = FHIROperationContext.createInstanceOperationContext();
updateOperationContext(operationContext, "GET");
resource = doInvoke(operationContext, pathTokens[0], pathTokens[1], null, operationName, null, queryParams);
break;
default:
String msg = "Invalid URL for custom operation '" + pathTokens[pathTokens.length - 1] + "'";
throw buildRestException(msg, IssueType.NOT_FOUND);
}
} else if (pathTokens.length == 1) {
// This is a 'search' request.
if ("_search".equals(pathTokens[0])) {
resource = doSearch("Resource", null, null, queryParams, absoluteUri, null);
} else {
checkResourceType(pathTokens[0]);
resource = doSearch(pathTokens[0], null, null, queryParams, absoluteUri, null);
}
} else if (pathTokens.length == 2) {
// This is a 'read' request.
checkResourceType(pathTokens[0]);
resource = doRead(pathTokens[0], pathTokens[1], true, false, null).getResource();
} else if (pathTokens.length == 3) {
if ("_history".equals(pathTokens[2])) {
// This is a 'history' request.
checkResourceType(pathTokens[0]);
resource = doHistory(pathTokens[0], pathTokens[1], queryParams, absoluteUri);
} else {
// This is a compartment based search
checkResourceType(pathTokens[2]);
resource = doSearch(pathTokens[2], pathTokens[0], pathTokens[1], queryParams, absoluteUri, null);
}
} else if (pathTokens.length == 4 && pathTokens[2].equals("_history")) {
// This is a 'vread' request.
checkResourceType(pathTokens[0]);
resource = doVRead(pathTokens[0], pathTokens[1], pathTokens[3], null);
} else {
String msg = "Unrecognized path in request URL: " + requestURL.getPath();
throw buildRestException(msg, IssueType.NOT_FOUND);
}
// Save the results of the operation in the bundle response field.
logBundledRequestCompletedMsg(requestDescription, initialTime, SC_OK);
return Entry.builder()
.response(Entry.Response.builder()
.status(SC_OK_STRING)
.build())
.resource(resource)
.build();
}
/**
* common update to the operationContext
* @param operationContext
* @param method
*/
private void updateOperationContext(FHIROperationContext operationContext, String method) {
FHIRRequestContext requestContext = FHIRRequestContext.get();
operationContext.setProperty(FHIROperationContext.PROPNAME_URI_INFO, requestContext.getExtendedOperationProperties(FHIROperationContext.PROPNAME_URI_INFO));
operationContext.setProperty(FHIROperationContext.PROPNAME_HTTP_HEADERS, requestContext.getExtendedOperationProperties(FHIROperationContext.PROPNAME_HTTP_HEADERS));
operationContext.setProperty(FHIROperationContext.PROPNAME_SECURITY_CONTEXT, requestContext.getExtendedOperationProperties(FHIROperationContext.PROPNAME_SECURITY_CONTEXT));
operationContext.setProperty(FHIROperationContext.PROPNAME_HTTP_REQUEST, requestContext.getExtendedOperationProperties(FHIROperationContext.PROPNAME_HTTP_REQUEST));
operationContext.setProperty(FHIROperationContext.PROPNAME_METHOD_TYPE, method);
}
/**
* Processes a request entry with a request method of POST.
*
* @param requestEntry
* the request bundle entry
* @param validationResponseEntry
* the response bundle entry created during validation, possibly null
* @param responseIndexAndEntries
* the hashmap containing bundle entry indexes and their associated response entries
* @param entryIndex
* the bundle entry index of the bundle entry being processed
* @param localRefMap
* the map of local references to external references
* @param requestURL
* the request URL
* @param absoluteUri
* the absolute URI
* @param requestDescription
* a description of the request
* @param initialTime
* the time the bundle entry processing started
* @return the bundle entry response
* @throws Exception
*/
private Entry processEntryForPost(Entry requestEntry, Entry validationResponseEntry, Map responseIndexAndEntries,
Integer entryIndex, Map localRefMap, FHIRUrlParser requestURL, String absoluteUri, String requestDescription, long initialTime, boolean transaction)
throws Exception {
String[] pathTokens = requestURL.getPathTokens();
MultivaluedMap queryParams = requestURL.getQueryParameters();
Resource resource = null;
// Process a POST (create or search, or custom operation).
if (pathTokens.length > 0 && pathTokens[pathTokens.length - 1].startsWith("$")) {
// This is a custom operation request.
// Chop off the '$' and save the name.
String operationName = pathTokens[pathTokens.length - 1].substring(1);
// Retrieve the resource from the request entry.
resource = requestEntry.getResource();
FHIROperationContext operationContext;
Resource result;
switch (pathTokens.length) {
case 1:
operationContext = FHIROperationContext.createSystemOperationContext();
updateOperationContext(operationContext, "POST");
result = doInvoke(operationContext, null, null, null, operationName, resource, queryParams);
break;
case 2:
checkResourceType(pathTokens[0]);
operationContext = FHIROperationContext.createResourceTypeOperationContext();
updateOperationContext(operationContext, "POST");
result = doInvoke(operationContext, pathTokens[0], null, null, operationName, resource, queryParams);
break;
case 3:
checkResourceType(pathTokens[0]);
operationContext = FHIROperationContext.createInstanceOperationContext();
updateOperationContext(operationContext, "POST");
result = doInvoke(operationContext, pathTokens[0], pathTokens[1], null, operationName, resource, queryParams);
break;
default:
String msg = "Invalid URL for custom operation '" + pathTokens[pathTokens.length - 1] + "'";
throw buildRestException(msg, IssueType.NOT_FOUND);
}
logBundledRequestCompletedMsg(requestDescription, initialTime, SC_OK);
return Bundle.Entry.builder()
.resource(result)
.response(Entry.Response.builder()
.status(SC_OK_STRING)
.build())
.build();
} else if (pathTokens.length == 2 && "_search".equals(pathTokens[1])) {
// This is a 'search' request.
checkResourceType(pathTokens[0]);
Bundle searchResults = doSearch(pathTokens[0], null, null, queryParams, absoluteUri, null);
logBundledRequestCompletedMsg(requestDescription, initialTime, SC_OK);
return Bundle.Entry.builder()
.resource(searchResults)
.response(Entry.Response.builder()
.status(SC_OK_STRING)
.build())
.build();
} else if (pathTokens.length == 1) {
// This is a 'create' request.
checkResourceType(pathTokens[0]);
// Retrieve the local identifier from the request entry (if present).
String localIdentifier = retrieveLocalIdentifier(requestEntry);
// Retrieve the resource from the request entry.
resource = requestEntry.getResource();
if (resource == null) {
String msg = "BundleEntry.resource is required for bundled create requests.";
throw buildRestException(msg, IssueType.NOT_FOUND);
}
if (transaction) {
resolveConditionalReferences(resource, localRefMap);
}
// Convert any local references found within the resource to their corresponding external reference.
ReferenceMappingVisitor visitor = new ReferenceMappingVisitor(localRefMap);
resource.accept(visitor);
resource = visitor.getResult();
// Determine if we have a pre-generated resource ID
String resourceId = retrieveGeneratedIdentifier(localRefMap, localIdentifier);
// Perform the 'create' or 'update' operation.
FHIRRestOperationResponse ior;
Entry.Request request = requestEntry.getRequest();
String ifNoneExist = request.getIfNoneExist() != null
&& request.getIfNoneExist().getValue() != null
&& !request.getIfNoneExist().getValue().isEmpty() ? request.getIfNoneExist().getValue() : null;
if (ifNoneExist != null || resourceId == null) {
ior = doCreate(pathTokens[0], resource, ifNoneExist, !DO_VALIDATION);
} else {
resource = resource.toBuilder().id(resourceId).build();
// Skip validation because its already been performed.
ior = doUpdate(pathTokens[0], resourceId, resource, null, null, !SKIPPABLE_UPDATE, !DO_VALIDATION);
}
// If a local identifier was present and not already mapped to its external identifier, add mapping.
if (localIdentifier != null && localRefMap.get(localIdentifier) == null) {
addLocalRefMapping(localRefMap, localIdentifier, null, ior.getResource());
}
// Use the validationOutcome and not the FHIRRestOperationResponse outcome.
OperationOutcome validationOutcome = null;
if (validationResponseEntry != null && validationResponseEntry.getResponse() != null) {
validationOutcome = validationResponseEntry.getResponse().getOutcome().as(OperationOutcome.class);
}
return buildResponseBundleEntry(ior, validationOutcome, requestDescription, initialTime);
} else {
String msg = "Request URL for bundled create requests should have a path with exactly one token ().";
throw buildRestException(msg, IssueType.NOT_FOUND);
}
}
private void resolveConditionalReferences(Resource resource, Map localRefMap) throws Exception {
for (String conditionalReference : getConditionalReferences(resource)) {
if (localRefMap.containsKey(conditionalReference)) {
continue;
}
FHIRUrlParser parser = new FHIRUrlParser(conditionalReference);
String type = parser.getPathTokens()[0];
MultivaluedMap queryParameters = parser.getQueryParameters();
if (queryParameters.isEmpty()) {
throw buildRestException("Invalid conditional reference: no query parameters found", IssueType.INVALID);
}
if (queryParameters.keySet().stream().anyMatch(key -> SearchConstants.SEARCH_RESULT_PARAMETER_NAMES.contains(key))) {
throw buildRestException("Invalid conditional reference: only filtering parameters are allowed", IssueType.INVALID);
}
queryParameters.add("_summary", "true");
queryParameters.add("_count", "1");
Bundle bundle = doSearch(type, null, null, queryParameters, null, resource, false);
int total = bundle.getTotal().getValue();
if (total == 0) {
throw buildRestException("Error resolving conditional reference: search '" + Encode.forHtml(conditionalReference) +
"' returned no results", IssueType.NOT_FOUND);
}
if (total > 1) {
throw buildRestException("Error resolving conditional reference: search '" + Encode.forHtml(conditionalReference) +
"' returned multiple results", IssueType.MULTIPLE_MATCHES);
}
localRefMap.put(conditionalReference, type + "/" + bundle.getEntry().get(0).getResource().getId());
}
}
private Set getConditionalReferences(Resource resource) {
Set conditionalReferences = new HashSet<>();
CollectingVisitor visitor = new CollectingVisitor<>(Reference.class);
resource.accept(visitor);
for (Reference reference : visitor.getResult()) {
if (reference.getReference() != null && reference.getReference().getValue() != null) {
String value = reference.getReference().getValue();
if (!value.startsWith("#") &&
!value.startsWith("urn:") &&
!value.startsWith("http:") &&
!value.startsWith("https:") &&
value.contains("?")) {
conditionalReferences.add(value);
}
}
}
return conditionalReferences;
}
/**
* Processes a request entry with a request method of PUT.
*
* @param requestEntry
* the request bundle entry
* @param validationResponseEntry
* the response bundle entry created during validation, possibly null
* @param responseIndexAndEntries
* the hashmap containing bundle entry indexes and their associated response entries
* @param entryIndex
* the bundle entry index of the bundle entry being processed
* @param localRefMap
* the map of local references to external references
* @param requestURL
* the request URL
* @param absoluteUri
* the absolute URI
* @param requestDescription
* a description of the request
* @param initialTime
* the time the bundle entry processing started
* @param skippableUpdate
* if true, and the resource content in the update matches the existing resource on the server, then skip the update;
* if false, then always attempt the update
* @return the bundle entry response
* @throws Exception
*/
private Entry processEntryForPut(Entry requestEntry, Entry validationResponseEntry, Map responseIndexAndEntries,
Integer entryIndex, Map localRefMap, FHIRUrlParser requestURL, String absoluteUri, String requestDescription,
long initialTime, boolean skippableUpdate, boolean transaction) throws Exception {
String[] pathTokens = requestURL.getPathTokens();
String type = null;
String id = null;
// Process a PUT (update).
if (pathTokens.length == 1) {
// A single-part url would be a conditional update: ?
type = pathTokens[0];
if (requestURL.getQuery() == null || requestURL.getQuery().isEmpty()) {
String msg = "A search query string is required for a conditional update operation.";
throw buildRestException(msg, IssueType.INVALID);
}
} else if (pathTokens.length == 2) {
// A two-part url would be a normal update: /.
type = pathTokens[0];
id = pathTokens[1];
} else {
// A url with any other pattern is an error.
String msg = "Request URL for bundled PUT request should have path part with either one or two tokens ( or /).";
throw buildRestException(msg, IssueType.INVALID);
}
checkResourceType(type);
// Retrieve the resource from the request entry.
Resource resource = requestEntry.getResource();
if (transaction) {
resolveConditionalReferences(resource, localRefMap);
}
// Convert any local references found within the resource to their corresponding external reference.
ReferenceMappingVisitor visitor = new ReferenceMappingVisitor(localRefMap);
resource.accept(visitor);
resource = visitor.getResult();
// Perform the 'update' operation.
String ifMatchBundleValue = null;
if (requestEntry.getRequest().getIfMatch() != null) {
ifMatchBundleValue = requestEntry.getRequest().getIfMatch().getValue();
}
FHIRRestOperationResponse ior = doUpdate(type, id, resource, ifMatchBundleValue, requestURL.getQuery(), skippableUpdate, !DO_VALIDATION);
// If this was a conditional update, and if a local identifier was present and not already mapped to its external identifier, add mapping.
if (pathTokens.length == 1) {
String localIdentifier = retrieveLocalIdentifier(requestEntry);
if (localIdentifier != null && localRefMap.get(localIdentifier) == null) {
addLocalRefMapping(localRefMap, localIdentifier, null, ior.getResource());
}
}
// Use the validationOutcome and not the FHIRRestOperationResponse outcome.
OperationOutcome validationOutcome = null;
if (validationResponseEntry != null && validationResponseEntry.getResponse() != null) {
validationOutcome = validationResponseEntry.getResponse().getOutcome().as(OperationOutcome.class);
}
return buildResponseBundleEntry(ior, validationOutcome, requestDescription, initialTime);
}
/**
* Processes a request entry with a request method of DELETE.
*
* @param requestURL
* the request URL
* @param requestDescription
* a description of the request
* @param initialTime
* the time the bundle entry processing started
* @return the bundle entry response
* @throws Exception
*/
private Entry processEntryForDelete(FHIRUrlParser requestURL, String requestDescription, long initialTime) throws Exception {
String[] pathTokens = requestURL.getPathTokens();
String type = null;
String id = null;
// Process a DELETE.
if (pathTokens.length == 1) {
// A single-part url would be a conditional delete: ?
type = pathTokens[0];
if (requestURL.getQuery() == null || requestURL.getQuery().isEmpty()) {
String msg = "A search query string is required for a conditional delete operation.";
throw buildRestException(msg, IssueType.INVALID);
}
} else if (pathTokens.length == 2) {
type = pathTokens[0];
id = pathTokens[1];
} else {
String msg = "Request URL for bundled DELETE request should have path part with one or two tokens ( or /).";
throw buildRestException(msg, IssueType.INVALID);
}
checkResourceType(type);
// Perform the 'delete' operation.
FHIRRestOperationResponse ior = doDelete(type, id, requestURL.getQuery());
int httpStatus = ior.getStatus().getStatusCode();
OperationOutcome oo = ior.getOperationOutcome();
logBundledRequestCompletedMsg(requestDescription, initialTime, httpStatus);
return Entry.builder()
.response(Entry.Response.builder()
.status(string(Integer.toString(httpStatus)))
.outcome(oo)
.build())
.build();
}
/**
* This function sorts the request entries in the specified bundle, based on the path part of the entry's 'url'
* field.
*
* @param bundle
* the bundle containing the request entries to be sorted.
* @return an array of Integer which provides the "sorted" ordering of request entry index values.
*/
private void sortBundleRequestEntries(Bundle bundle, List indices) {
// Sort the list of indices based on the contents of their entries in the bundle.
Collections.sort(indices, new BundleEntryComparator(bundle.getEntry()));
}
private static class BundleEntryComparator implements Comparator {
private List entries;
public BundleEntryComparator(List entries) {
this.entries = entries;
}
@Override
public int compare(Integer indexA, Integer indexB) {
Entry a = entries.get(indexA);
Entry b = entries.get(indexB);
String pathA = getUrlPath(a);
String pathB = getUrlPath(b);
if (log.isLoggable(Level.FINE)) {
log.fine("Comparing request entry URL paths: " + pathA + ", " + pathB);
}
if (pathA != null && pathB != null) {
return pathA.compareTo(pathB);
} else if (pathA != null) {
return 1;
} else if (pathB != null) {
return -1;
}
return 0;
}
}
/**
* Returns the specified BundleEntry's path component of the 'url' field.
*
* @param entry
* the bundle entry
* @return the bundle entry's 'url' field's path component
*/
private static String getUrlPath(Entry entry) {
String path = null;
Entry.Request request = entry.getRequest();
if (request != null) {
if (request.getUrl() != null && request.getUrl().getValue() != null) {
FHIRUrlParser requestURL = new FHIRUrlParser(request.getUrl().getValue());
path = requestURL.getPath();
}
}
return path;
}
/**
* This function converts the specified query string (a String) into an equivalent MultivaluedMap
* containing the query parameters defined in the query string.
*
* @param queryString
* the query string to be processed
* @return
*/
private MultivaluedMap getQueryParameterMap(String queryString) {
MultivaluedMap result = null;
FHIRUrlParser parser = new FHIRUrlParser("foo?" + queryString);
result = parser.getQueryParameters();
return result;
}
/**
* This method will build a mapping of local identifiers to external identifiers for bundle entries
* which specify local identifiers and which have a request method of POST or PUT.
*
* @param requestBundle
* the bundle containing the requests
*
* @return local reference map
*/
private Map buildLocalRefMap(Bundle requestBundle, Map validationResponseEntries) throws Exception {
Map localRefMap = new HashMap<>();
for (int entryIndex = 0; entryIndex < requestBundle.getEntry().size(); entryIndex++) {
Entry requestEntry = requestBundle.getEntry().get(entryIndex);
Entry.Request request = requestEntry.getRequest();
Entry validationResponseEntry = validationResponseEntries.get(entryIndex);
// Only add mappings for POST and PUT requests where response is ACCEPTED.
if (validationResponseEntry != null && !validationResponseEntry.getResponse().getStatus().equals(SC_ACCEPTED_STRING)) {
continue;
}
HTTPVerb.Value method = request.getMethod().getValueAsEnum();
if (method == HTTPVerb.Value.POST || method == HTTPVerb.Value.PUT) {
// Retrieve the local identifier from the request entry (if present).
String localIdentifier = retrieveLocalIdentifier(requestEntry);
if (localIdentifier != null) {
// Retrieve the resource from the request entry (if present).
Resource resource = requestEntry.getResource();
if (resource != null) {
// Get and parse the request URL.
FHIRUrlParser requestURL = new FHIRUrlParser(request.getUrl().getValue());
String[] pathTokens = requestURL.getPathTokens();
// Only add mapping for POST request if it's a non-conditional create.
// Only add mapping for PUT request if a resource ID is specified.
if (method == HTTPVerb.Value.POST && pathTokens.length == 1 && !pathTokens[0].startsWith("$") &&
(request.getIfNoneExist() == null || request.getIfNoneExist().getValue() == null || request.getIfNoneExist().getValue().isEmpty())) {
// Generate external identifier and add mapping.
String externalIdentifier = ModelSupport.getTypeName(resource.getClass()) + "/" + persistence.generateResourceId();
addLocalRefMapping(localRefMap, localIdentifier, externalIdentifier, null);
} else if (request.getMethod().equals(HTTPVerb.PUT) && resource.getId() != null) {
// Add mapping.
addLocalRefMapping(localRefMap, localIdentifier, null, resource);
}
}
}
}
}
return localRefMap;
}
/**
* This method will add a mapping to the local-to-external identifier map if the specified localIdentifier is
* non-null.
*
* @param localRefMap
* the map containing the local-to-external identifier mappings
* @param localIdentifier
* the localIdentifier previously obtained for the resource
* @param externalIdentifier
* the externalIdentifier previously obtained for the resource (may be null
* if resource is not null)
* @param resource
* the resource for which an external identifier will be built (may be null
* if externalIdentifier is not null)
*/
private void addLocalRefMapping(Map localRefMap, String localIdentifier, String externalIdentifier, Resource resource) {
if (localIdentifier != null) {
if (externalIdentifier == null) {
externalIdentifier = ModelSupport.getTypeName(resource.getClass()) + "/" + resource.getId();
}
localRefMap.put(localIdentifier, externalIdentifier);
if (log.isLoggable(Level.FINER)) {
log.finer("Added local/ext identifier mapping: " + localIdentifier + " --> " + externalIdentifier);
}
}
}
/**
* This method will retrieve the local identifier associated with the specified bundle request entry, or return null
* if the fullUrl field is not specified or doesn't contain a local identifier.
*
* @param requestEntry
* the bundle request entry
* @return the local identifier
*/
private String retrieveLocalIdentifier(Entry requestEntry) {
String localIdentifier = null;
if (requestEntry.getFullUrl() != null) {
String fullUrl = requestEntry.getFullUrl().getValue();
if (fullUrl != null && fullUrl.startsWith(LOCAL_REF_PREFIX)) {
localIdentifier = fullUrl;
if (log.isLoggable(Level.FINER)) {
log.finer("Request entry contains local identifier: " + localIdentifier);
}
}
}
return localIdentifier;
}
/**
* This method will retrieve the generated identifier associated with the specified local identifier from the
* local ref map, or return null if there is no mapping for the local identifier.
*
* @param localRefMap
* the map containing the local-to-external identifier mappings
* @param localIdentifier
* the localIdentifier previously obtained for the resource
* @return the generated identifier
*/
private String retrieveGeneratedIdentifier(Map localRefMap, String localIdentifier) {
String generatedIdentifier = null;
String externalIdentifier = localRefMap.get(localIdentifier);
if (externalIdentifier != null) {
int index = externalIdentifier.indexOf("/");
if (index > -1) {
generatedIdentifier = externalIdentifier.substring(index+1);
}
}
return generatedIdentifier;
}
/**
* This function will build an absolute URI from the specified base URI and relative URI.
*
* @param baseUri
* the base URI to be used; this will be of the form ://:/
* @param relativeUri
* the path and query parts
* @return the full URI value as a String
*/
private String getAbsoluteUri(String baseUri, String relativeUri) {
StringBuilder fullUri = new StringBuilder();
fullUri.append(baseUri);
if (!baseUri.endsWith("/")) {
fullUri.append("/");
}
fullUri.append((relativeUri.startsWith("/") ? relativeUri.substring(1) : relativeUri));
return fullUri.toString();
}
private Entry buildResponseBundleEntry(FHIRRestOperationResponse operationResponse, OperationOutcome validationOutcome,
String requestDescription, long initialTime) throws FHIROperationException {
Resource resource = operationResponse.getResource();
URI locationURI = operationResponse.getLocationURI();
int httpStatus = operationResponse.getStatus().getStatusCode();
Entry.Response.Builder entryResponseBuilder = Entry.Response.builder()
.status(string(Integer.toString(httpStatus)))
.outcome(validationOutcome);
if (resource != null) {
entryResponseBuilder = entryResponseBuilder
.id(resource.getId())
.lastModified(resource.getMeta().getLastUpdated())
.etag(string(getEtagValue(resource)));
}
if (locationURI != null) {
entryResponseBuilder = entryResponseBuilder.location(Uri.of(locationURI.toString()));
}
Entry.Builder bundleEntryBuilder = Entry.builder();
if (HTTPReturnPreference.REPRESENTATION.equals(FHIRRequestContext.get().getReturnPreference())) {
bundleEntryBuilder.resource(resource);
} else if (HTTPReturnPreference.OPERATION_OUTCOME.equals(FHIRRequestContext.get().getReturnPreference())) {
// Given that we execute the operation with validation turned off, the operationResponse outcome is unlikely
// to contain useful information, but the validationOutcome already exists under the Entry.response
bundleEntryBuilder.resource(operationResponse.getOperationOutcome());
}
logBundledRequestCompletedMsg(requestDescription, initialTime, httpStatus);
return bundleEntryBuilder.response(entryResponseBuilder.build()).build();
}
private void logBundledRequestCompletedMsg(String requestDescription, long initialTime, int httpStatus) {
StringBuffer statusMsg = new StringBuffer();
statusMsg.append(" status:[" + httpStatus + "]");
double elapsedSecs = (System.currentTimeMillis() - initialTime) / 1000.0;
log.info("Completed bundled request[" + elapsedSecs + " secs]: "
+ requestDescription.toString() + statusMsg.toString());
}
private String getEtagValue(Resource resource) {
return "W/\"" + resource.getMeta().getVersionId().getValue() + "\"";
}
/**
* Creates a bundle that will hold results for a search operation.
*
* @param resources
* the list of resources to include in the bundle
* @param searchContext
* the FHIRSearchContext object associated with the search
* @param type
* the name of the resource type being searched
* @return the bundle
* @throws Exception
*/
Bundle createSearchBundle(List resources, FHIRSearchContext searchContext, String type) throws Exception {
// throws if we have a count of more than 2,147,483,647 resources
UnsignedInt totalCount = searchContext.getTotalCount() != null ? UnsignedInt.of(searchContext.getTotalCount()) : null;
// generate ID for this bundle and set total
Bundle.Builder bundleBuilder = Bundle.builder()
.type(BundleType.SEARCHSET)
.id(UUID.randomUUID().toString())
.total(totalCount);
if (resources.size() > 0) {
// Calculate how many resources are 'match' mode
int matchResourceCount = searchContext.getMatchCount();
List matchResources = resources.subList(0, matchResourceCount);
// Check if too many included resources
if (resources.size() > matchResourceCount + searchContext.getMaxPageIncludeCount()) {
throw buildRestException("Number of returned 'include' resources exceeds allowable limit of " + searchContext.getMaxPageIncludeCount(),
IssueType.BUSINESS_RULE, IssueSeverity.ERROR);
}
// Find chained search parameters and find reference search parameters containing only a logical ID
List chainedSearchParameters = new ArrayList<>();
List logicalIdReferenceSearchParameters = new ArrayList<>();
for (QueryParameter queryParameter : searchContext.getSearchParameters()) {
// We do not need to look at canonical references here. They will not contain versions of the
// form '.../_history/xx' nor logical ID-only references, which is what we want to check
// these search parameters for.
if (!queryParameter.isReverseChained() && !queryParameter.isCanonical()) {
if (queryParameter.isChained()) {
chainedSearchParameters.add(queryParameter);
} else if (SearchConstants.Type.REFERENCE == queryParameter.getType()) {
// Look for logical ID-only value
for (QueryParameterValue value : queryParameter.getValues()) {
ReferenceValue refVal = ReferenceUtil.createReferenceValueFrom(value.getValueString(), null, ReferenceUtil.getBaseUrl(null));
if (refVal.getType() == ReferenceType.LITERAL_RELATIVE && refVal.getTargetResourceType() == null) {
logicalIdReferenceSearchParameters.add(queryParameter);
break;
}
}
}
}
}
List issues = new ArrayList<>();
if (searchContext.getOutcomeIssues() != null) {
issues.addAll(searchContext.getOutcomeIssues());
}
if (!chainedSearchParameters.isEmpty() || !logicalIdReferenceSearchParameters.isEmpty()) {
// Check 'match' resources for versioned references in chain search parameter fields and
// multiple resource types with matching logical ID in reference search parameter fields.
issues = performSearchReferenceChecks(type, chainedSearchParameters, logicalIdReferenceSearchParameters, matchResources);
}
for (Resource resource : resources) {
Entry.Builder entryBuilder = Entry.builder();
if (resource != null) {
if (resource.getId() != null) {
entryBuilder.id(resource.getId());
entryBuilder.fullUrl(Uri.of(getRequestBaseUri(type) + "/" + resource.getClass().getSimpleName() + "/" + resource.getId()));
} else {
String msg = "A resource with no id was found.";
log.warning(msg);
issues.add(FHIRUtil.buildOperationOutcomeIssue(IssueSeverity.WARNING, IssueType.NOT_SUPPORTED, msg));
}
entryBuilder.resource(resource);
} else {
String msg = "A resource with no data was found.";
log.warning(msg);
issues.add(FHIRUtil.buildOperationOutcomeIssue(IssueSeverity.WARNING, IssueType.NOT_SUPPORTED, msg));
}
// Search mode is determined by the matchResourceCount, which will be decremented each time through the loop.
// If the count is greater than 0, the mode is MATCH. If less than or equal to 0, the mode is INCLUDE.
Entry entry = entryBuilder
.search(Search.builder()
.mode(matchResourceCount-- > 0 ? SearchEntryMode.MATCH : SearchEntryMode.INCLUDE)
.score(Decimal.of("1"))
.build())
.build();
bundleBuilder.entry(entry);
}
if (!issues.isEmpty()) {
// Add OperationOutcome resource containing issues
bundleBuilder.entry(
Entry.builder()
.search(Search.builder().mode(SearchEntryMode.OUTCOME).build())
.resource(FHIRUtil.buildOperationOutcome(issues))
.build());
}
}
Bundle bundle = bundleBuilder.build();
// Add the SUBSETTED tag, if the _elements search result parameter was applied to limit elements included in
// returned resources or _summary is required.
if (searchContext.hasElementsParameters()
|| (searchContext.hasSummaryParameter() && !searchContext.getSummaryParameter().equals(SummaryValueSet.FALSE))) {
bundle = FHIRUtil.addTag(bundle, SearchConstants.SUBSETTED_TAG);
}
return bundle;
}
/**
* For chained search, check 'match' resources for existence of a versioned reference in the field
* associated with the chain search parameter.
*
* For reference search specifying logical ID only, check 'match' resources for existence of multiple
* resource types containing the same logical ID in the field associated with the reference search parameter.
*
* @param resourceType
* The search resource type.
* @param chainQueryParameters
* The chained query parameters. These will be mutually exclusive of the logicalIdReferenceQueryParameters.
* @param logicalIdReferenceQueryParameters
* The list of reference query parameters that only specified a logical ID.
* @param matchResources
* The list of 'match' resources to check.
* @return
* A list of Issues, one per resource in which a versioned reference is found.
* @throws Exception if multiple resource types containing the same logical ID are found
*/
private List performSearchReferenceChecks(String resourceType, List chainQueryParameters,
List logicalIdReferenceQueryParameters, List matchResources) throws Exception {
List issues = new ArrayList<>();
if (!chainQueryParameters.isEmpty() || !logicalIdReferenceQueryParameters.isEmpty()) {
// Build a map of parameter name to SearchParameter for all queryParameters.
// Since the search was successful, we can assume search parameters exist, are valid, and of type Reference.
// However, if this is a whole-system search, we will need to get the SearchParameters based on
// the resource type returned.
Map searchParameterMap = new HashMap<>();
if (!Resource.class.getSimpleName().equals(resourceType)) {
Class extends Resource> resourceTypeClass = ModelSupport.getResourceType(resourceType);
for (QueryParameter queryParameter : chainQueryParameters) {
searchParameterMap.put(queryParameter, SearchUtil.getSearchParameter(resourceTypeClass, queryParameter.getCode()));
}
for (QueryParameter queryParameter : logicalIdReferenceQueryParameters) {
searchParameterMap.put(queryParameter, SearchUtil.getSearchParameter(resourceTypeClass, queryParameter.getCode()));
}
}
List queryParameters = new ArrayList<>(chainQueryParameters);
queryParameters.addAll(logicalIdReferenceQueryParameters);
Map logicalIdToTypeMap = new HashMap<>();
// Loop through the resources, looking for versioned references and references to multiple resource types for the same logical ID
for (Resource resource : matchResources) {
FHIRPathEvaluator evaluator = FHIRPathEvaluator.evaluator();
EvaluationContext evaluationContext = new EvaluationContext(resource);
for (QueryParameter queryParameter : queryParameters) {
SearchParameter searchParameter = searchParameterMap.get(queryParameter);
if (searchParameter == null) {
searchParameter = SearchUtil.getSearchParameter(resource.getClass(), queryParameter.getCode());
}
// For logical ID check, only need to look at search parameters with more than one target resource type
if (logicalIdReferenceQueryParameters.contains(queryParameter) && searchParameter.getTarget().size() == 1) {
continue;
}
Collection nodes = evaluator.evaluate(evaluationContext, searchParameter.getExpression().getValue());
for (FHIRPathNode node : nodes) {
Reference reference = node.asElementNode().element().as(Reference.class);
ReferenceValue rv = ReferenceUtil.createReferenceValueFrom(reference, ReferenceUtil.getBaseUrl(null));
if (chainQueryParameters.contains(queryParameter) && rv.getVersion() != null &&
(rv.getTargetResourceType() == null || rv.getTargetResourceType().equals(queryParameter.getModifierResourceTypeName()))) {
// Found versioned reference value
String msg = "Resource with id '" + resource.getId() +
"' contains a versioned reference in an element used for chained search, but chained search does not act on versioned references.";
issues.add(FHIRUtil.buildOperationOutcomeIssue(IssueSeverity.WARNING, IssueType.NOT_SUPPORTED, msg, node.path()));
} else if (logicalIdReferenceQueryParameters.contains(queryParameter) && rv.getTargetResourceType() != null &&
!rv.getTargetResourceType().equals(logicalIdToTypeMap.computeIfAbsent(queryParameter.getCode() + "|" + rv.getValue(), v -> rv.getTargetResourceType()))) {
// Found multiple resource types this logical ID
String msg = "Multiple resource type matches found for logical ID '" + rv.getValue() +
"' for search parameter '" + queryParameter.getCode() + "'.";
throw buildRestException(msg, IssueType.INVALID, IssueSeverity.ERROR);
}
}
}
}
}
return issues;
}
/**
* Creates a bundle that will hold the results of a history operation.
*
* @param resources
* the list of resources to include in the bundle
* @param historyContext
* the FHIRHistoryContext associated with the history operation
* @param type
* the name of the resource type on which the history operation was requested
* @return the bundle
* @throws Exception
*/
private Bundle createHistoryBundle(List extends Resource> resources, FHIRHistoryContext historyContext, String type)
throws Exception {
// throws if we have a count of more than 2,147,483,647 resources
UnsignedInt totalCount = historyContext.getTotalCount() != null ? UnsignedInt.of(historyContext.getTotalCount()) : null;
// generate ID for this bundle and set the "total" field for the bundle
Bundle.Builder bundleBuilder = Bundle.builder()
.type(BundleType.HISTORY)
.id(UUID.randomUUID().toString())
.total(totalCount);
Map> deletedResourcesMap = historyContext.getDeletedResources();
for (int i = 0; i < resources.size(); i++) {
Resource resource = resources.get(i);
if (resource == null) {
String msg = "A resource with no data was found.";
log.warning(msg);
throw new IllegalStateException(msg);
}
if (resource.getId() == null) {
String msg = "A resource with no id was found.";
log.warning(msg);
throw new IllegalStateException(msg);
}
Integer versionId = Integer.valueOf(resource.getMeta().getVersionId().getValue());
String logicalId = resource.getId();
String resourceType = ModelSupport.getTypeName(resource.getClass());
List deletedVersions = deletedResourcesMap.get(logicalId);
// Determine the correct method to include in this history entry (POST, PUT, DELETE).
HTTPVerb method;
if (deletedVersions != null && deletedVersions.contains(versionId)) {
method = HTTPVerb.DELETE;
} else if (versionId == 1) {
method = HTTPVerb.POST;
} else {
method = HTTPVerb.PUT;
}
// Create the 'request' entry, and set the request.url field.
// 'create' --> url = ""
// 'update'/'delete' --> url = "/"
Entry.Request request =
Entry.Request.builder().method(method).url(Url.of(method == HTTPVerb.POST
? resourceType : resourceType + "/" + logicalId)).build();
Entry.Response response =
Entry.Response.builder().status(string("200")).build();
Entry entry =
Entry.builder().request(request).fullUrl(Uri.of(getRequestBaseUri(type) + "/"
+ resource.getClass().getSimpleName() + "/"
+ resource.getId())).response(response).resource(resource).build();
bundleBuilder.entry(entry);
}
return bundleBuilder.build();
}
/**
* Retrieves the shared interceptor mgr instance from the servlet context.
*/
private FHIRPersistenceInterceptorMgr getInterceptorMgr() {
return FHIRPersistenceInterceptorMgr.getInstance();
}
private Bundle addLinks(FHIRPagingContext context, Bundle responseBundle, String requestUri) throws Exception {
String selfUri = null;
SummaryValueSet summaryParameter = null;
Bundle.Builder bundleBuilder = responseBundle.toBuilder();
if (context instanceof FHIRSearchContext) {
FHIRSearchContext searchContext = (FHIRSearchContext) context;
summaryParameter = searchContext.getSummaryParameter();
try {
selfUri = SearchUtil.buildSearchSelfUri(requestUri, searchContext);
} catch (Exception e) {
log.log(Level.WARNING, "Unable to construct self link for search result bundle; using the request URI instead.", e);
}
}
if (selfUri == null) {
selfUri = requestUri;
}
// create 'self' link
Bundle.Link selfLink =
Bundle.Link.builder().relation(string("self")).url(Url.of(selfUri)).build();
bundleBuilder.link(selfLink);
// If for search with _summary=count or pageSize == 0, then don't add previous and next links.
if (!SummaryValueSet.COUNT.equals(summaryParameter) && context.getPageSize() > 0) {
// In case the currently requested page is < 1, ensure the next link points to page 1,
// to avoid unnecessarily paging through additional page numbers < 1
int nextPageNumber = Math.max(context.getPageNumber() + 1, 1);
if (nextPageNumber <= context.getLastPageNumber()
&& (nextPageNumber == 1 || context.getTotalCount() != null || context.getMatchCount() == context.getPageSize())) {
// starting with the self URI
String nextLinkUrl = selfUri;
// remove existing _page parameters from the query string
nextLinkUrl = nextLinkUrl.replace("&_page=" + context.getPageNumber(), "").replace("_page="
+ context.getPageNumber() + "&", "").replace("_page=" + context.getPageNumber(), "");
if (nextLinkUrl.contains("?")) {
if (!nextLinkUrl.endsWith("?")) {
// there are other parameters in the query string
nextLinkUrl += "&";
}
} else {
nextLinkUrl += "?";
}
// add new _page parameter to the query string
nextLinkUrl += "_page=" + nextPageNumber;
// create 'next' link
Bundle.Link nextLink =
Bundle.Link.builder().relation(string("next")).url(Url.of(nextLinkUrl)).build();
bundleBuilder.link(nextLink);
}
int prevPageNumber = Math.min(context.getPageNumber() - 1, context.getLastPageNumber());
if (prevPageNumber > 0) {
// starting with the original request URI
String prevLinkUrl = requestUri;
// remove existing _page parameters from the query string
prevLinkUrl =
prevLinkUrl.replace("&_page=" + context.getPageNumber(), "").replace("_page="
+ context.getPageNumber() + "&", "").replace("_page="
+ context.getPageNumber(), "");
if (prevLinkUrl.contains("?")) {
if (!prevLinkUrl.endsWith("?")) {
// there are other parameters in the query string
prevLinkUrl += "&";
}
} else {
prevLinkUrl += "?";
}
// add new _page parameter to the query string
prevLinkUrl += "_page=" + prevPageNumber;
// create 'previous' link
Bundle.Link prevLink =
Bundle.Link.builder().relation(string("previous")).url(Url.of(prevLinkUrl)).build();
bundleBuilder.link(prevLink);
}
}
return bundleBuilder.build();
}
/**
* Get the original request URI from either the HttpServletRequest or a configured Header (in case of re-writing proxies).
*
* When the 'fhirServer/core/originalRequestUriHeaderName' property is empty, this method returns the equivalent of
* uriInfo.getRequestUri().toString(), except that uriInfo.getRequestUri() will throw an IllegalArgumentException
* when the query string portion contains a vertical bar | character. The vertical bar is one known case of a special character
* causing the exception. There could be others.
*
* @return String The complete request URI
* @throws Exception if an error occurs while reading the config
*/
private String getRequestUri() throws Exception {
return FHIRRequestContext.get().getOriginalRequestUri();
}
/**
* This method returns the "base URI" associated with the current request. For example, if a client invoked POST
* https://myhost:9443/fhir-server/api/v4/Patient to create a Patient resource, this method would return
* "https://myhost:9443/fhir-server/api/v4".
*
* @return The base endpoint URI associated with the current request.
* @throws Exception if an error occurs while reading the config
* @implNote This method uses {@link #getRequestUri()} to get the original request URI and then strips it to the
* Service Base URL
*/
private String getRequestBaseUri(String type) throws Exception {
String baseUri = null;
String requestUri = getRequestUri();
// Strip off everything after the path
int queryPathSeparatorLoc = requestUri.indexOf("?");
if (queryPathSeparatorLoc != -1) {
baseUri = requestUri.substring(0, queryPathSeparatorLoc);
} else {
baseUri = requestUri;
}
// Strip off any path elements after the base
if (type != null && !type.isEmpty()) {
int resourceNamePathLocation = baseUri.lastIndexOf("/" + type);
if (resourceNamePathLocation != -1) {
baseUri = requestUri.substring(0, resourceNamePathLocation);
} else {
// Assume the request was a batch/transaction; nothing to strip
}
}
// Strip any path segments for whole-system interactions (in case of whole-system search, "Resource" is passed as the type, or $everything-based search)
if (type == null || type.isEmpty() || "Resource".equals(type) || baseUri.contains("$everything")) {
if (baseUri.endsWith("/_search")) {
baseUri = baseUri.substring(0, baseUri.length() - "/_search".length());
} else if (baseUri.endsWith("/_history")) {
baseUri = baseUri.substring(0, baseUri.length() - "/_history".length());
} else if (baseUri.contains("/$")) {
baseUri = baseUri.substring(0, baseUri.lastIndexOf("/$"));
}
}
return baseUri;
}
/**
* Builds a collection of properties that will be passed to the persistence interceptors.
*
* @param type
* the resource type
* @param id
* the resource logical ID
* @param version
* the resource version
* @param searchContext
* the request search context
* @return a map of persistence event properties
* @throws FHIRPersistenceException
*/
private Map buildPersistenceEventProperties(String type, String id,
String version, FHIRSearchContext searchContext) throws FHIRPersistenceException {
Map props = new HashMap<>();
props.put(FHIRPersistenceEvent.PROPNAME_PERSISTENCE_IMPL, persistence);
if (type != null) {
props.put(FHIRPersistenceEvent.PROPNAME_RESOURCE_TYPE, type);
}
if (id != null) {
props.put(FHIRPersistenceEvent.PROPNAME_RESOURCE_ID, id);
}
if (version != null) {
props.put(FHIRPersistenceEvent.PROPNAME_VERSION_ID, version);
}
if (searchContext != null) {
props.put(FHIRPersistenceEvent.PROPNAME_SEARCH_CONTEXT_IMPL, searchContext);
}
return props;
}
/**
* Sets various properties on the FHIROperationContext instance.
*
* @param operationContext
* the FHIROperationContext on which to set the properties
* @throws Exception
*/
private void setOperationContextProperties(FHIROperationContext operationContext, String resourceTypeName) throws Exception {
operationContext.setProperty(FHIROperationContext.PROPNAME_REQUEST_BASE_URI, getRequestBaseUri(resourceTypeName));
operationContext.setProperty(FHIROperationContext.PROPNAME_PERSISTENCE_IMPL, persistence);
}
@Override
public int doReindex(FHIROperationContext operationContext, OperationOutcome.Builder operationOutcomeResult, Instant tstamp,
List indexIds, String resourceLogicalId) throws Exception {
int result = 0;
// Since the try logic is slightly different in the code paths, we want to dispatch to separate methods to simplify the logic.
if (indexIds == null) {
result = doReindexSingle(operationOutcomeResult, tstamp, resourceLogicalId);
} else {
result = doReindexList(operationOutcomeResult, tstamp, indexIds);
}
return result;
}
/**
* encapsulates the logic to process a list with graduated backoff through the full list of indexIds
*
* @param operationOutcomeResult
* @param tstamp
* @param indexIds
* @return
* @throws Exception
*/
public int doReindexList(OperationOutcome.Builder operationOutcomeResult, Instant tstamp, List indexIds) throws Exception {
// If the indexIds are empty or null, then it's not properly formed.
if (indexIds == null || indexIds.isEmpty()) {
throw new IllegalArgumentException("No indexIds sent to the $reindex list method");
}
/*
* How the backoff works...
* indexIds[1,2,3,4,5,6,7,8,9,10]
*
* Pass 1 left=0, right=10, max=10
* Result: Deadlock
*
* Pass2 left=0, right=1, max=10
* Move over by 1
* Result: Pass
*
* Pass3 left=1, right=2, max=10
* Move over by 1
* Result: Pass
*
* ... all the way up to 10
*
* Return total count back to caller.
*
* @implNote tried divide and conquer and it caused it to try large pass fail, small pass succeed, and therefore chose a small linear pass.
*/
// Maximum attempts to retry across all windows.
final int TX_ATTEMPTS = 10;
int result = 0;
int max = indexIds.size();
int left = 0;
int right = max;
int window = 0;
int attempt = 1;
while (left < max && attempt <= TX_ATTEMPTS) {
window++;
if (log.isLoggable(Level.FINE)) {
log.fine("$reindex window [" + window + "/" + attempt + "] -> left=[" + left + "] right=[" + right + "] max=[" + max + "]");
}
boolean backoff = false;
List subListIndexIds = indexIds.subList(left, right);
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
try {
FHIRPersistenceContext persistenceContext = null;
result += persistence.reindex(persistenceContext, operationOutcomeResult, tstamp, subListIndexIds, null);
} catch (FHIRPersistenceDataAccessException x) {
// At this point, the transaction is marked for rollback
if (x.isTransactionRetryable() && ++attempt <= TX_ATTEMPTS) {
if (x.getCause() instanceof LockException && ((LockException) x.getCause()).isDeadlock()) {
backoff = true;
long wait = RANDOM.nextInt(5000);
log.info("attempt #" + window + "/" + attempt + " failed, retrying transaction, backing off [wait=" + wait + "ms]");
Thread.sleep(wait);
} else {
log.info("attempt #" + window + "/" + attempt + " failed, retrying transaction, not backing off");
}
} else {
throw x;
}
} finally {
txn.end();
}
// backoff controls how we increment the window.
if (backoff) {
// we're now going to move over one at a time.
right = left + 1;
} else {
// we're sliding over by one more
left = right;
right += 1;
}
}
return result;
}
/**
* do a single reindex on a specific resourceLogicalId
*
* @param operationOutcomeResult
* @param tstamp
* @param resourceLogicalId
* @return
* @throws Exception
*/
public int doReindexSingle(OperationOutcome.Builder operationOutcomeResult, Instant tstamp, String resourceLogicalId) throws Exception {
int result = 0;
// handle some retries in case of deadlock exceptions
final int TX_ATTEMPTS = 5;
int attempt = 1;
do {
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
try {
FHIRPersistenceContext persistenceContext = null;
result = persistence.reindex(persistenceContext, operationOutcomeResult, tstamp, null, resourceLogicalId);
attempt = TX_ATTEMPTS; // end the retry loop
} catch (FHIRPersistenceDataAccessException x) {
if (x.isTransactionRetryable() && attempt < TX_ATTEMPTS) {
log.info("attempt #" + attempt + " failed, retrying transaction");
} else {
throw x;
}
} finally {
txn.end();
}
} while (attempt++ < TX_ATTEMPTS);
return result;
}
/**
* Validate a resource. First validate profile assertions for the resource if configured to do so,
* then validate the resource itself.
*
* @param resource
* the resource to be validated
* @return A list of validation errors and warnings
* @throws FHIRValidationException
*/
private List validateResource(Resource resource) throws FHIRValidationException {
List profiles = null;
List profilesWithoutVersion = null;
// Retrieve the profile configuration
try {
StringBuilder defaultProfileConfigPath = new StringBuilder(FHIRConfiguration.PROPERTY_RESOURCES).append("/Resource/")
.append(FHIRConfiguration.PROPERTY_FIELD_RESOURCES_PROFILES).append("/")
.append(FHIRConfiguration.PROPERTY_FIELD_RESOURCES_PROFILES_AT_LEAST_ONE);
StringBuilder resourceSpecificProfileConfigPath = new StringBuilder(FHIRConfiguration.PROPERTY_RESOURCES).append("/")
.append(resource.getClass().getSimpleName()).append("/").append(FHIRConfiguration.PROPERTY_FIELD_RESOURCES_PROFILES)
.append("/").append(FHIRConfiguration.PROPERTY_FIELD_RESOURCES_PROFILES_AT_LEAST_ONE);
// Get the 'atLeastOne' property
List resourceSpecificProfiles = FHIRConfigHelper.getStringListProperty(resourceSpecificProfileConfigPath.toString());
if (resourceSpecificProfiles != null) {
profiles = resourceSpecificProfiles;
} else {
List defaultProfiles = FHIRConfigHelper.getStringListProperty(defaultProfileConfigPath.toString());
if (defaultProfiles != null) {
profiles = defaultProfiles;
}
}
if (log.isLoggable(Level.FINER)) {
log.finer("Required profile list: " + profiles);
}
// Build the list of profiles that didn't specify a version
if (profiles != null && !profiles.isEmpty()) {
profilesWithoutVersion = new ArrayList<>();
for (String profile : profiles) {
if (!profile.contains("|")) {
profilesWithoutVersion.add(profile);
}
}
}
} catch (Exception e) {
return Collections.singletonList(buildOperationOutcomeIssue(IssueSeverity.ERROR, IssueType.UNKNOWN,
"Error retrieving profile configuration."));
}
// If required profiles were configured, perform validation of asserted profiles against required profiles
if (profiles != null && !profiles.isEmpty()) {
// Get the profiles asserted for this resource
List resourceAssertedProfiles = ProfileSupport.getResourceAssertedProfiles(resource);
if (log.isLoggable(Level.FINE)) {
log.fine("Asserted profiles: " + resourceAssertedProfiles);
}
// Check if a profile is required but none specified
if (resourceAssertedProfiles.isEmpty()) {
return Collections.singletonList(buildOperationOutcomeIssue(IssueSeverity.ERROR, IssueType.BUSINESS_RULE,
"A required profile was not specified. Resources of type '" +
resource.getClass().getSimpleName() +
"' must declare conformance to at least one of the following profiles: " +
profiles));
}
// Check if at least one asserted profile is in list of required profiles.
// If a required profile specifies a version, an asserted profile must be an exact match.
// If a required profile does not specify a version, any asserted profile of the same name
// will be a match regardless if it specifies a version or not.
boolean validProfileFound = false;
for (String resourceAssertedProfile : resourceAssertedProfiles) {
// Check if asserted profile contains a version
String strippedAssertedProfile = null;
int index = resourceAssertedProfile.indexOf("|");
if (index != -1) {
strippedAssertedProfile = resourceAssertedProfile.substring(0, index);
}
// Look for exact match or match after stripping version from asserted profile
if (profiles.contains(resourceAssertedProfile) || profilesWithoutVersion.contains(strippedAssertedProfile)) {
if (log.isLoggable(Level.FINE)) {
log.fine("Valid asserted profile found: '" + resourceAssertedProfile + "'");
}
validProfileFound = true;
break;
}
}
if (!validProfileFound) {
return Collections.singletonList(buildOperationOutcomeIssue(IssueSeverity.ERROR, IssueType.BUSINESS_RULE,
"A required profile was not specified. Resources of type '" +
resource.getClass().getSimpleName() +
"' must declare conformance to at least one of the following profiles: " +
profiles));
}
// Check if asserted profiles are supported
List issues = new ArrayList<>();
for (String resourceAssertedProfile : resourceAssertedProfiles) {
StructureDefinition profile = ProfileSupport.getProfile(resourceAssertedProfile);
if (profile == null) {
issues.add(buildOperationOutcomeIssue(IssueSeverity.ERROR, IssueType.NOT_SUPPORTED,
"Profile '" + resourceAssertedProfile + "' is not supported"));
}
}
if (!issues.isEmpty()) {
return issues;
}
}
return validator.validate(resource);
}
/**
* Validate an interaction for a specified resource type.
*
* @param interaction
* the interaction to be performed
* @param resourceType
* the resource type against which the interaction is to be performed
* @throws FHIROperationException
*/
private void validateInteraction(Interaction interaction, String resourceType) throws FHIROperationException {
List interactions = null;
boolean resourceValid = true;
// Retrieve the interaction configuration
try {
StringBuilder defaultInteractionsConfigPath = new StringBuilder(FHIRConfiguration.PROPERTY_RESOURCES).append("/Resource/")
.append(FHIRConfiguration.PROPERTY_FIELD_RESOURCES_INTERACTIONS);
StringBuilder resourceSpecificInteractionsConfigPath = new StringBuilder(FHIRConfiguration.PROPERTY_RESOURCES).append("/")
.append(resourceType).append("/").append(FHIRConfiguration.PROPERTY_FIELD_RESOURCES_INTERACTIONS);
// Get the 'interactions' property
List resourceSpecificInteractions = FHIRConfigHelper.getStringListProperty(resourceSpecificInteractionsConfigPath.toString());
if (resourceSpecificInteractions != null) {
interactions = resourceSpecificInteractions;
} else {
// Check the 'open' property, and if that's false, check if resource was specified
if (!FHIRConfigHelper.getBooleanProperty(FHIRConfiguration.PROPERTY_RESOURCES + "/" + FHIRConfiguration.PROPERTY_FIELD_RESOURCES_OPEN, true)) {
PropertyGroup resourceGroup = FHIRConfigHelper.getPropertyGroup(FHIRConfiguration.PROPERTY_RESOURCES + "/" + resourceType);
if (resourceGroup == null) {
resourceValid = false;
}
}
if (resourceValid) {
// Get the 'Resource' interaction property
List defaultInteractions = FHIRConfigHelper.getStringListProperty(defaultInteractionsConfigPath.toString());
if (defaultInteractions != null) {
interactions = defaultInteractions;
}
}
}
if (log.isLoggable(Level.FINE)) {
log.fine("Allowed interactions: " + interactions);
}
} catch (Exception e) {
throw buildRestException("Error retrieving interactions configuration.", IssueType.UNKNOWN, IssueSeverity.ERROR);
}
// Perform validation of specified interaction against specified resourceType
if (interactions != null && !interactions.contains(interaction.value())) {
throw buildRestException("The requested interaction of type '" + interaction.value() + "' is not allowed for resource type '" + resourceType + "'",
IssueType.BUSINESS_RULE, IssueSeverity.ERROR);
} else if (!resourceValid) {
throw buildRestException("The requested resource type '" + resourceType + "' is not found",
IssueType.NOT_FOUND, IssueSeverity.ERROR);
}
}
public enum Interaction {
CREATE("create"),
DELETE("delete"),
HISTORY("history"),
PATCH("patch"),
READ("read"),
SEARCH("search"),
UPDATE("update"),
VREAD("vread");
private final String value;
Interaction(String value) {
this.value = value;
}
public String value() {
return value;
}
public static Interaction from(String value) {
for (Interaction interaction : Interaction.values()) {
if (interaction.value.equals(value)) {
return interaction;
}
}
throw new IllegalArgumentException(value);
}
}
@Override
public Bundle doHistory(MultivaluedMap queryParameters, String requestUri) throws Exception {
log.entering(this.getClass().getName(), "doHistory");
// Validate that the interaction is allowed
validateInteraction(Interaction.HISTORY, "Resource");
// extract the query parameters
FHIRRequestContext requestContext = FHIRRequestContext.get();
FHIRSystemHistoryContext historyContext =
FHIRPersistenceUtil.parseSystemHistoryParameters(queryParameters, HTTPHandlingPreference.LENIENT.equals(requestContext.getHandlingPreference()));
List records;
// Start a new txn in the persistence layer if one is not already active.
Integer count = historyContext.getCount();
Instant since = historyContext.getSince() != null && historyContext.getSince().getValue() != null ? historyContext.getSince().getValue().toInstant() : null;
FHIRTransactionHelper txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
try {
if (count == null) {
count = DEFAULT_HISTORY_ENTRIES;
} else if (count > MAX_HISTORY_ENTRIES) {
count = MAX_HISTORY_ENTRIES;
}
records = persistence.changes(count, since, historyContext.getAfterHistoryId(), null);
} catch (FHIRPersistenceDataAccessException x) {
log.log(Level.SEVERE, "Error reading history; params = {" + historyContext + "}",
x);
throw x;
} finally {
txn.end();
}
// Create a history bundle and add an entry for each record
Bundle.Builder bundleBuilder = Bundle.builder();
Long lastChangeId = null;
Instant lastChangeTime = null;
// The FHIR spec states the result bundle entries are "sorted with oldest versions last",
// (which has to be with respect to versions of a particular resource)
// When using _since - the 'records' list is sorted {change_tstamp, resourceType, resourceId}
// When using _afterHistoryId - the 'records' list is sorted {resourceId}
// Both sort orders guarantee that for a given resource, versions will be increasing, so to
// meet the spec, we just process in reverse.
for (int i=records.size()-1; i>=0; i--) {
ResourceChangeLogRecord changeRecord = records.get(i);
if (lastChangeId == null || changeRecord.getChangeId() > lastChangeId) {
// Keep track of the greatest change id value
lastChangeId = changeRecord.getChangeId();
}
if (lastChangeTime == null || changeRecord.getChangeTstamp().isAfter(lastChangeTime)) {
// Keep track of the latest timestamp
lastChangeTime = changeRecord.getChangeTstamp();
}
Request.Builder requestBuilder = Request.builder();
Entry.Response.Builder responseBuilder = Entry.Response.builder();
switch (changeRecord.getChangeType()) {
case CREATE:
requestBuilder.method(HTTPVerb.POST);
requestBuilder.url(Url.of(changeRecord.getResourceTypeName()));
responseBuilder.status(com.ibm.fhir.model.type.String.of("201 Created"));
break;
case UPDATE:
requestBuilder.method(HTTPVerb.PUT);
requestBuilder.url(Url.of(changeRecord.getResourceTypeName() + "/" + changeRecord.getLogicalId()));
responseBuilder.status(com.ibm.fhir.model.type.String.of("200 OK"));
break;
case DELETE:
requestBuilder.method(HTTPVerb.DELETE);
requestBuilder.url(Url.of(changeRecord.getResourceTypeName() + "/" + changeRecord.getLogicalId()));
responseBuilder.status(com.ibm.fhir.model.type.String.of("200 OK"));
break;
}
responseBuilder.lastModified(com.ibm.fhir.model.type.Instant.of(changeRecord.getChangeTstamp().atZone(UTC)));
Entry.Builder entryBuilder = Entry.builder();
entryBuilder.fullUrl(Url.of(changeRecord.getResourceTypeName() + "/" + changeRecord.getLogicalId() + "/_history/" + changeRecord.getVersionId()));
entryBuilder.request(requestBuilder.build());
entryBuilder.response(responseBuilder.build());
bundleBuilder.entry(entryBuilder.build());
}
// Get the service base address to use for next and self links
String serviceBase = ReferenceUtil.getBaseUrl(null);
if (serviceBase.endsWith("/")) {
serviceBase = serviceBase.substring(0, serviceBase.length()-1);
}
if (lastChangeId != null) {
// post the next link which a client can use to get the next set of changes.
// If this link is not included, the client can assume we've reached the end.
// We don't include the _since filter, because the _afterHistoryId is more
// specific and avoids any nasty issues related to clock drift in a cluster
// of IBM FHIR Servers.
StringBuilder nextRequest = new StringBuilder();
nextRequest.append(serviceBase);
nextRequest.append("?");
nextRequest.append("_count=").append(count);
if (historyContext.getSince() != null && historyContext.getSince().getValue() != null) {
// As _since was given, we need to go with time-based paging, and the client
// will be responsible for managing duplicates.
nextRequest.append("&_since=").append(lastChangeTime.atZone(UTC).format(DateTime.PARSER_FORMATTER));
} else {
// in all other cases we fetch ordered by resource_id, and so the client
// should get the next page using the _afterHistoryId marker, allowing
// easy paging through the stream of changes without duplicates
nextRequest.append("&_afterHistoryId=").append(lastChangeId);
}
Bundle.Link.Builder linkBuilder = Bundle.Link.builder();
linkBuilder.url(Uri.of(nextRequest.toString()));
linkBuilder.relation(com.ibm.fhir.model.type.String.of("next"));
bundleBuilder.link(linkBuilder.build());
}
// Add a self link
StringBuilder selfRequest = new StringBuilder();
selfRequest.append(serviceBase);
selfRequest.append("?");
selfRequest.append("_count=").append(count);
// only one of afterHistoryId or since can be not null at this stage
if (historyContext.getAfterHistoryId() != null) {
selfRequest.append("&_afterHistoryId=").append(historyContext.getAfterHistoryId());
}
if (historyContext.getSince() != null && historyContext.getSince().getValue() != null) {
selfRequest.append("&_since=").append(historyContext.getSince().getValue().format(DateTime.PARSER_FORMATTER));
}
Bundle.Link.Builder linkBuilder = Bundle.Link.builder();
linkBuilder.url(Uri.of(selfRequest.toString()));
linkBuilder.relation(com.ibm.fhir.model.type.String.of("self"));
bundleBuilder.link(linkBuilder.build());
bundleBuilder.type(BundleType.HISTORY);
return bundleBuilder.build();
}
@Override
public ResourceEraseRecord doErase(FHIROperationContext operationContext, EraseDTO eraseDto) throws FHIROperationException {
// @implNote doReindex has a nice pattern to handle some retries in case of deadlock exceptions
final int TX_ATTEMPTS = 5;
int attempt = 1;
ResourceEraseRecord eraseRecord = new ResourceEraseRecord();
do {
FHIRTransactionHelper txn = null;
try {
txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
eraseRecord = persistence.erase(eraseDto);
attempt = TX_ATTEMPTS; // end the retry loop
} catch (FHIRPersistenceDataAccessException x) {
if (x.isTransactionRetryable() && attempt < TX_ATTEMPTS) {
log.info("attempt #" + attempt + " failed, retrying transaction");
} else {
throw new FHIROperationException("Error during $erase", x);
}
} catch (Exception x) {
attempt = TX_ATTEMPTS; // end the retry loop
throw new FHIROperationException("Error during $erase", x);
} finally {
if (txn != null) {
txn.end();
}
}
} while (attempt++ < TX_ATTEMPTS);
return eraseRecord;
}
@Override
public List doRetrieveIndex(FHIROperationContext operationContext, String resourceTypeName, int count, Instant notModifiedAfter, Long afterIndexId) throws Exception {
List indexIds = null;
FHIRTransactionHelper txn = null;
try {
txn = new FHIRTransactionHelper(getTransaction());
txn.begin();
indexIds = persistence.retrieveIndex(count, notModifiedAfter, afterIndexId, resourceTypeName);
} finally {
if (txn != null) {
txn.end();
}
}
return indexIds;
}
/**
* This method will do a quick check of the {type} URL parameter. If the type is not a valid FHIR resource type, then
* we'll throw an error to short-circuit the current in-progress REST API invocation.
*/
private void checkResourceType(String type) throws FHIROperationException {
if (!ModelSupport.isResourceType(type)) {
throw buildUnsupportedResourceTypeException(type);
}
if (!ModelSupport.isConcreteResourceType(type)) {
log.warning("Use of abstract resource types like '" + type + "' in FHIR URLs is deprecated and will be removed in a future release");
}
}
}