Add support for conditional patch (#1348)

* Add support for conditional patch

* Add changelog

* Test fix
This commit is contained in:
James Agnew 2019-06-17 16:12:05 -04:00 committed by GitHub
parent 087c53286c
commit b2e99cf035
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 262 additions and 112 deletions

View File

@ -1,6 +1,7 @@
package ca.uhn.fhir.i18n;
import ca.uhn.fhir.context.ConfigurationException;
import ca.uhn.fhir.util.UrlUtil;
import java.text.MessageFormat;
import java.util.*;
@ -92,6 +93,20 @@ public class HapiLocalizer {
return getMessage(toKey(theType, theKey), theParameters);
}
/**
* Create the message and sanitize parameters using {@link }
*/
public String getMessageSanitized(Class<?> theType, String theKey, Object... theParameters) {
if (theParameters != null) {
for (int i = 0; i < theParameters.length; i++) {
if (theParameters[i] instanceof CharSequence) {
theParameters[i] = UrlUtil.sanitizeUrlPart((CharSequence) theParameters[i]);
}
}
}
return getMessage(toKey(theType, theKey), theParameters);
}
public String getMessage(String theQualifiedKey, Object... theParameters) {
if (theParameters != null && theParameters.length > 0) {
MessageFormat format = myKeyToMessageFormat.get(theQualifiedKey);

View File

@ -123,7 +123,7 @@ public class UrlUtil {
return value.startsWith("http://") || value.startsWith("https://");
}
public static boolean isNeedsSanitization(String theString) {
public static boolean isNeedsSanitization(CharSequence theString) {
if (theString != null) {
for (int i = 0; i < theString.length(); i++) {
char nextChar = theString.charAt(i);
@ -302,7 +302,7 @@ public class UrlUtil {
* This method specifically HTML-encodes the &quot; and
* &lt; characters in order to prevent injection attacks
*/
public static String sanitizeUrlPart(String theString) {
public static String sanitizeUrlPart(CharSequence theString) {
if (theString == null) {
return null;
}
@ -316,6 +316,9 @@ public class UrlUtil {
char nextChar = theString.charAt(j);
switch (nextChar) {
case '\'':
buffer.append("&apos;");
break;
case '"':
buffer.append("&quot;");
break;
@ -332,7 +335,7 @@ public class UrlUtil {
return buffer.toString();
}
return theString;
return theString.toString();
}
private static Map<String, String[]> toQueryStringMap(HashMap<String, List<String>> map) {

View File

@ -75,6 +75,7 @@ import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
import static ca.uhn.fhir.util.UrlUtil.sanitizeUrlPart;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
@Transactional(propagation = Propagation.REQUIRED)
@ -151,7 +152,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
if (isNotBlank(theResource.getIdElement().getIdPart())) {
if (getContext().getVersion().getVersion().isOlderThan(FhirVersionEnum.DSTU3)) {
String message = getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "failedToCreateWithClientAssignedId", theResource.getIdElement().getIdPart());
String message = getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "failedToCreateWithClientAssignedId", theResource.getIdElement().getIdPart());
throw new InvalidRequestException(message, createErrorOperationOutcome(message, "processing"));
} else {
// As of DSTU3, ID and version in the body should be ignored for a create/update
@ -287,7 +288,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
Set<Long> resourceIds = myMatchResourceUrlService.processMatchUrl(theUrl, myResourceType);
if (resourceIds.size() > 1) {
if (myDaoConfig.isAllowMultipleDelete() == false) {
throw new PreconditionFailedException(getContext().getLocalizer().getMessage(BaseHapiFhirDao.class, "transactionOperationWithMultipleMatchFailure", "DELETE", theUrl, resourceIds.size()));
throw new PreconditionFailedException(getContext().getLocalizer().getMessageSanitized(BaseHapiFhirDao.class, "transactionOperationWithMultipleMatchFailure", "DELETE", theUrl, resourceIds.size()));
}
}
@ -335,7 +336,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
IBaseOperationOutcome oo;
if (deletedResources.isEmpty()) {
oo = OperationOutcomeUtil.newInstance(getContext());
String message = getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "unableToDeleteNotFound", theUrl);
String message = getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "unableToDeleteNotFound", theUrl);
String severity = "warning";
String code = "not-found";
OperationOutcomeUtil.addIssue(getContext(), oo, severity, message, null, code);
@ -384,7 +385,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
if (isNotBlank(theIfNoneExist)) {
Set<Long> match = myMatchResourceUrlService.processMatchUrl(theIfNoneExist, myResourceType);
if (match.size() > 1) {
String msg = getContext().getLocalizer().getMessage(BaseHapiFhirDao.class, "transactionOperationWithMultipleMatchFailure", "CREATE", theIfNoneExist, match.size());
String msg = getContext().getLocalizer().getMessageSanitized(BaseHapiFhirDao.class, "transactionOperationWithMultipleMatchFailure", "CREATE", theIfNoneExist, match.size());
throw new PreconditionFailedException(msg);
} else if (match.size() == 1) {
Long pid = match.iterator().next();
@ -400,11 +401,11 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
switch (myDaoConfig.getResourceClientIdStrategy()) {
case NOT_ALLOWED:
throw new ResourceNotFoundException(
getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "failedToCreateWithClientAssignedIdNotAllowed", theResource.getIdElement().getIdPart()));
getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "failedToCreateWithClientAssignedIdNotAllowed", theResource.getIdElement().getIdPart()));
case ALPHANUMERIC:
if (theResource.getIdElement().isIdPartValidLong()) {
throw new InvalidRequestException(
getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "failedToCreateWithClientAssignedNumericId", theResource.getIdElement().getIdPart()));
getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "failedToCreateWithClientAssignedNumericId", theResource.getIdElement().getIdPart()));
}
createForcedIdIfNeeded(entity, theResource.getIdElement(), false);
break;
@ -480,7 +481,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
outcome.setId(theResource.getIdElement());
}
String msg = getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "successfulCreate", outcome.getId(), w.getMillisAndRestart());
String msg = getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "successfulCreate", outcome.getId(), w.getMillisAndRestart());
outcome.setOperationOutcome(createInfoOperationOutcome(msg));
ourLog.debug(msg);
@ -775,11 +776,29 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
}
@Override
public DaoMethodOutcome patch(IIdType theId, PatchTypeEnum thePatchType, String thePatchBody, RequestDetails theRequestDetails) {
ResourceTable entityToUpdate = readEntityLatestVersion(theId);
if (theId.hasVersionIdPart()) {
if (theId.getVersionIdPartAsLong() != entityToUpdate.getVersion()) {
throw new ResourceVersionConflictException("Version " + theId.getVersionIdPart() + " is not the most recent version of this resource, unable to apply patch");
public DaoMethodOutcome patch(IIdType theId, String theConditionalUrl, PatchTypeEnum thePatchType, String thePatchBody, RequestDetails theRequestDetails) {
ResourceTable entityToUpdate;
if (isNotBlank(theConditionalUrl)) {
Set<Long> match = myMatchResourceUrlService.processMatchUrl(theConditionalUrl, myResourceType);
if (match.size() > 1) {
String msg = getContext().getLocalizer().getMessageSanitized(BaseHapiFhirDao.class, "transactionOperationWithMultipleMatchFailure", "PATCH", theConditionalUrl, match.size());
throw new PreconditionFailedException(msg);
} else if (match.size() == 1) {
Long pid = match.iterator().next();
entityToUpdate = myEntityManager.find(ResourceTable.class, pid);
} else {
String msg = getContext().getLocalizer().getMessageSanitized(BaseHapiFhirDao.class, "invalidMatchUrlNoMatches", theConditionalUrl);
throw new ResourceNotFoundException(msg);
}
} else {
entityToUpdate = readEntityLatestVersion(theId);
if (theId.hasVersionIdPart()) {
if (theId.getVersionIdPartAsLong() != entityToUpdate.getVersion()) {
throw new ResourceVersionConflictException("Version " + theId.getVersionIdPart() + " is not the most recent version of this resource, unable to apply patch");
}
}
}
@ -831,12 +850,12 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
protected void preProcessResourceForStorage(T theResource) {
String type = getContext().getResourceDefinition(theResource).getName();
if (!getResourceName().equals(type)) {
throw new InvalidRequestException(getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "incorrectResourceType", type, getResourceName()));
throw new InvalidRequestException(getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "incorrectResourceType", type, getResourceName()));
}
if (theResource.getIdElement().hasIdPart()) {
if (!theResource.getIdElement().isIdPartValid()) {
throw new InvalidRequestException(getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "failedToCreateWithInvalidId", theResource.getIdElement().getIdPart()));
throw new InvalidRequestException(getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "failedToCreateWithInvalidId", theResource.getIdElement().getIdPart()));
}
}
@ -945,7 +964,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
if (theId.hasVersionIdPart()) {
if (theId.isVersionIdPartValidLong() == false) {
throw new ResourceNotFoundException(getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "invalidVersion", theId.getVersionIdPart(), theId.toUnqualifiedVersionless()));
throw new ResourceNotFoundException(getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "invalidVersion", theId.getVersionIdPart(), theId.toUnqualifiedVersionless()));
}
if (entity.getVersion() != theId.getVersionIdPartAsLong()) {
entity = null;
@ -961,7 +980,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
try {
entity = q.getSingleResult();
} catch (NoResultException e) {
throw new ResourceNotFoundException(getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "invalidVersion", theId.getVersionIdPart(), theId.toUnqualifiedVersionless()));
throw new ResourceNotFoundException(getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "invalidVersion", theId.getVersionIdPart(), theId.toUnqualifiedVersionless()));
}
}
}
@ -1216,7 +1235,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
QualifierDetails qualifiedParamName = SearchMethodBinding.extractQualifiersFromParameterName(nextParamName);
RuntimeSearchParam param = searchParams.get(qualifiedParamName.getParamName());
if (param == null) {
String msg = getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "invalidSearchParameter", qualifiedParamName.getParamName(), new TreeSet<String>(searchParams.keySet()));
String msg = getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "invalidSearchParameter", qualifiedParamName.getParamName(), new TreeSet<String>(searchParams.keySet()));
throw new InvalidRequestException(msg);
}
@ -1268,7 +1287,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
if (isNotBlank(theMatchUrl)) {
Set<Long> match = myMatchResourceUrlService.processMatchUrl(theMatchUrl, myResourceType);
if (match.size() > 1) {
String msg = getContext().getLocalizer().getMessage(BaseHapiFhirDao.class, "transactionOperationWithMultipleMatchFailure", "UPDATE", theMatchUrl, match.size());
String msg = getContext().getLocalizer().getMessageSanitized(BaseHapiFhirDao.class, "transactionOperationWithMultipleMatchFailure", "UPDATE", theMatchUrl, match.size());
throw new PreconditionFailedException(msg);
} else if (match.size() == 1) {
Long pid = match.iterator().next();
@ -1340,7 +1359,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
outcome.setId(theResource.getIdElement());
}
String msg = getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "successfulUpdate", outcome.getId(), w.getMillisAndRestart());
String msg = getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "successfulUpdate", outcome.getId(), w.getMillisAndRestart());
outcome.setOperationOutcome(createInfoOperationOutcome(msg));
ourLog.debug(msg);

View File

@ -150,7 +150,7 @@ public interface IFhirResourceDao<T extends IBaseResource> extends IDao {
*/
<MT extends IBaseMetaType> MT metaGetOperation(Class<MT> theType, RequestDetails theRequestDetails);
DaoMethodOutcome patch(IIdType theId, PatchTypeEnum thePatchType, String thePatchBody, RequestDetails theRequestDetails);
DaoMethodOutcome patch(IIdType theId, String theConditionalUrl, PatchTypeEnum thePatchType, String thePatchBody, RequestDetails theRequestDetails);
Set<Long> processMatchUrl(String theMatchUrl);

View File

@ -115,10 +115,10 @@ public abstract class BaseJpaResourceProvider<T extends IBaseResource> extends B
}
@Patch
public DaoMethodOutcome patch(HttpServletRequest theRequest, @IdParam IIdType theId, RequestDetails theRequestDetails, @ResourceParam String theBody, PatchTypeEnum thePatchType) {
public DaoMethodOutcome patch(HttpServletRequest theRequest, @IdParam IIdType theId, @ConditionalUrlParam String theConditionalUrl, RequestDetails theRequestDetails, @ResourceParam String theBody, PatchTypeEnum thePatchType) {
startRequest(theRequest);
try {
return myDao.patch(theId, thePatchType, theBody, theRequestDetails);
return myDao.patch(theId, theConditionalUrl, thePatchType, theBody, theRequestDetails);
} finally {
endRequest(theRequest);
}

View File

@ -56,14 +56,11 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_REPRESENTATION);
patch.addHeader(Constants.HEADER_ACCEPT, Constants.CT_FHIR_JSON);
CloseableHttpResponse response = ourHttpClient.execute(patch);
try {
try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
assertEquals(200, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info("Response:\n{}", responseString);
assertThat(responseString, containsString("\"derivedFrom\":[{\"reference\":\"Media/465eb73a-bce3-423a-b86e-5d0d267638f4\"}]"));
} finally {
response.close();
}
}
@ -84,14 +81,11 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
patch.setEntity(new StringEntity("[ { \"op\":\"replace\", \"path\":\"/active\", \"value\":false } ]", ContentType.parse(Constants.CT_JSON_PATCH + Constants.CHARSET_UTF8_CTSUFFIX)));
patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
CloseableHttpResponse response = ourHttpClient.execute(patch);
try {
try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
assertEquals(200, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("INFORMATION"));
} finally {
response.close();
}
Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
@ -99,6 +93,92 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
assertEquals(false, newPt.getActive());
}
@Test
public void testPatchUsingJsonPatch_Conditional_Success() throws Exception {
String methodName = "testPatchUsingJsonPatch";
IIdType pid1;
{
Patient patient = new Patient();
patient.setActive(true);
patient.addIdentifier().setSystem("urn:system").setValue("0");
patient.addName().setFamily(methodName).addGiven("Joe");
pid1 = myPatientDao.create(patient, mySrd).getId().toUnqualifiedVersionless();
}
HttpPatch patch = new HttpPatch(ourServerBase + "/Patient?_id=" + pid1.getIdPart());
patch.setEntity(new StringEntity("[ { \"op\":\"replace\", \"path\":\"/active\", \"value\":false } ]", ContentType.parse(Constants.CT_JSON_PATCH + Constants.CHARSET_UTF8_CTSUFFIX)));
patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
assertEquals(200, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("INFORMATION"));
}
Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
assertEquals("2", newPt.getIdElement().getVersionIdPart());
assertEquals(false, newPt.getActive());
}
@Test
public void testPatchUsingJsonPatch_Conditional_NoMatch() throws Exception {
String methodName = "testPatchUsingJsonPatch";
IIdType pid1;
{
Patient patient = new Patient();
patient.setActive(true);
patient.addIdentifier().setSystem("urn:system").setValue("0");
patient.addName().setFamily(methodName).addGiven("Joe");
pid1 = myPatientDao.create(patient, mySrd).getId().toUnqualifiedVersionless();
}
HttpPatch patch = new HttpPatch(ourServerBase + "/Patient?_id=" + pid1.getIdPart()+"FOO");
patch.setEntity(new StringEntity("[ { \"op\":\"replace\", \"path\":\"/active\", \"value\":false } ]", ContentType.parse(Constants.CT_JSON_PATCH + Constants.CHARSET_UTF8_CTSUFFIX)));
patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
assertEquals(404, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("Invalid match URL &quot;Patient?_id=" + pid1.getIdPart() + "FOO&quot; - No resources match this search"));
}
Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
assertEquals("1", newPt.getIdElement().getVersionIdPart());
}
@Test
public void testPatchUsingJsonPatch_Conditional_MultipleMatch() throws Exception {
String methodName = "testPatchUsingJsonPatch";
{
Patient patient = new Patient();
patient.setActive(true);
patient.addIdentifier().setSystem("urn:system").setValue("0");
patient.addName().setFamily(methodName).addGiven("Joe");
myPatientDao.create(patient, mySrd).getId().toUnqualifiedVersionless();
}
{
Patient patient = new Patient();
patient.setActive(true);
patient.addIdentifier().setSystem("urn:system").setValue("1");
patient.addName().setFamily(methodName).addGiven("Joe");
myPatientDao.create(patient, mySrd).getId().toUnqualifiedVersionless();
}
HttpPatch patch = new HttpPatch(ourServerBase + "/Patient?active=true");
patch.setEntity(new StringEntity("[ { \"op\":\"replace\", \"path\":\"/active\", \"value\":false } ]", ContentType.parse(Constants.CT_JSON_PATCH + Constants.CHARSET_UTF8_CTSUFFIX)));
patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
assertEquals(412, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("Failed to PATCH resource with match URL &quot;Patient?active=true&quot; because this search matched 2 resources"));
}
}
/**
* Pass in an invalid JSON Patch and make sure the error message
* that is returned is useful
@ -127,14 +207,11 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
patch.setEntity(new StringEntity(patchText, ContentType.parse(Constants.CT_JSON_PATCH + Constants.CHARSET_UTF8_CTSUFFIX)));
patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
CloseableHttpResponse response = ourHttpClient.execute(patch);
try {
try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("was expecting double-quote to start field name"));
assertEquals(400, response.getStatusLine().getStatusCode());
} finally {
response.close();
}
}
@ -155,14 +232,11 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
patch.setEntity(new StringEntity("[ { \"op\":\"replace\", \"path\":\"/active\", \"value\":false } ]", ContentType.parse(Constants.CT_JSON_PATCH + Constants.CHARSET_UTF8_CTSUFFIX)));
patch.addHeader("If-Match", "W/\"9\"");
CloseableHttpResponse response = ourHttpClient.execute(patch);
try {
try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
assertEquals(409, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("<diagnostics value=\"Version 9 is not the most recent version of this resource, unable to apply patch\"/>"));
} finally {
response.close();
}
Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
@ -187,14 +261,11 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
patch.addHeader("If-Match", "W/\"1\"");
patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
CloseableHttpResponse response = ourHttpClient.execute(patch);
try {
try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
assertEquals(200, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("INFORMATION"));
} finally {
response.close();
}
Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
@ -219,14 +290,11 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
patch.setEntity(new StringEntity(patchString, ContentType.parse(Constants.CT_XML_PATCH + Constants.CHARSET_UTF8_CTSUFFIX)));
CloseableHttpResponse response = ourHttpClient.execute(patch);
try {
try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
assertEquals(200, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("INFORMATION"));
} finally {
response.close();
}
Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();

View File

@ -11,7 +11,6 @@ import ca.uhn.fhir.jpa.util.SubscriptionsRequireManualActivationInterceptorDstu3
import ca.uhn.fhir.rest.server.interceptor.IServerInterceptor;
import ca.uhn.fhir.rest.server.interceptor.ResponseHighlighterInterceptor;
import org.apache.commons.dbcp2.BasicDataSource;
import org.hibernate.jpa.HibernatePersistenceProvider;
import org.hibernate.search.elasticsearch.cfg.ElasticsearchEnvironment;
import org.springframework.beans.factory.annotation.Autowire;
import org.springframework.context.annotation.Bean;
@ -87,9 +86,10 @@ public class FhirServerConfig extends BaseJavaConfigDstu3 {
/**
* This interceptor adds some pretty syntax highlighting in responses when a browser is detected
* @return
*/
@Bean(autowire = Autowire.BY_TYPE)
public IServerInterceptor responseHighlighterInterceptor() {
public ResponseHighlighterInterceptor responseHighlighterInterceptor() {
ResponseHighlighterInterceptor retVal = new ResponseHighlighterInterceptor();
return retVal;
}

View File

@ -113,29 +113,32 @@ public abstract class RequestDetails {
* @return Returns the <b>conditional URL</b> if this request has one, or <code>null</code> otherwise
*/
public String getConditionalUrl(RestOperationTypeEnum theOperationType) {
if (theOperationType == RestOperationTypeEnum.CREATE) {
String retVal = this.getHeader(Constants.HEADER_IF_NONE_EXIST);
if (isBlank(retVal)) {
switch (theOperationType) {
case CREATE:
String retVal = this.getHeader(Constants.HEADER_IF_NONE_EXIST);
if (isBlank(retVal)) {
return null;
}
if (retVal.startsWith(this.getFhirServerBase())) {
retVal = retVal.substring(this.getFhirServerBase().length());
}
return retVal;
case DELETE:
case UPDATE:
case PATCH:
if (this.getId() != null && this.getId().hasIdPart()) {
return null;
}
int questionMarkIndex = this.getCompleteUrl().indexOf('?');
if (questionMarkIndex == -1) {
return null;
}
return this.getResourceName() + this.getCompleteUrl().substring(questionMarkIndex);
default:
return null;
}
if (retVal.startsWith(this.getFhirServerBase())) {
retVal = retVal.substring(this.getFhirServerBase().length());
}
return retVal;
} else if (theOperationType != RestOperationTypeEnum.DELETE && theOperationType != RestOperationTypeEnum.UPDATE) {
return null;
}
if (this.getId() != null && this.getId().hasIdPart()) {
return null;
}
int questionMarkIndex = this.getCompleteUrl().indexOf('?');
if (questionMarkIndex == -1) {
return null;
}
return this.getResourceName() + this.getCompleteUrl().substring(questionMarkIndex);
}
/**

View File

@ -1,10 +1,14 @@
package ca.uhn.fhir.rest.server;
import static org.junit.Assert.assertEquals;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.ConditionalUrlParam;
import ca.uhn.fhir.rest.annotation.IdParam;
import ca.uhn.fhir.rest.annotation.Patch;
import ca.uhn.fhir.rest.annotation.ResourceParam;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.PatchTypeEnum;
import ca.uhn.fhir.test.utilities.JettyUtil;
import ca.uhn.fhir.util.TestUtil;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPatch;
@ -16,32 +20,39 @@ import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.hl7.fhir.dstu3.model.*;
import org.hl7.fhir.dstu3.model.IdType;
import org.hl7.fhir.dstu3.model.OperationOutcome;
import org.hl7.fhir.dstu3.model.Patient;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.junit.*;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.*;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.PatchTypeEnum;
import ca.uhn.fhir.test.utilities.JettyUtil;
import ca.uhn.fhir.util.TestUtil;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
public class PatchDstu3Test {
import static org.junit.Assert.assertEquals;
public class PatchServerDstu3Test {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(PatchServerDstu3Test.class);
private static CloseableHttpClient ourClient;
private static FhirContext ourCtx = FhirContext.forDstu3();
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(PatchDstu3Test.class);
private static int ourPort;
private static Server ourServer;
private static String ourLastMethod;
private static PatchTypeEnum ourLastPatchType;
private static String ourLastBody;
private static IdType ourLastId;
private static String ourLastConditional;
@Before
public void before() {
ourLastMethod = null;
ourLastBody = null;
ourLastId = null;
ourLastConditional = null;
}
@Test
@ -67,6 +78,30 @@ public class PatchDstu3Test {
assertEquals(PatchTypeEnum.JSON_PATCH, ourLastPatchType);
}
@Test
public void testPatchUsingConditional() throws Exception {
String requestContents = "[ { \"op\": \"add\", \"path\": \"/a/b/c\", \"value\": [ \"foo\", \"bar\" ] } ]";
HttpPatch httpPatch = new HttpPatch("http://localhost:" + ourPort + "/Patient?_id=123");
httpPatch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
httpPatch.setEntity(new StringEntity(requestContents, ContentType.parse(Constants.CT_JSON_PATCH)));
CloseableHttpResponse status = ourClient.execute(httpPatch);
try {
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(responseContent);
assertEquals(200, status.getStatusLine().getStatusCode());
assertEquals("<OperationOutcome xmlns=\"http://hl7.org/fhir\"><text><div xmlns=\"http://www.w3.org/1999/xhtml\">OK</div></text></OperationOutcome>", responseContent);
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
assertEquals("patientPatch", ourLastMethod);
assertEquals("Patient?_id=123", ourLastConditional);
assertEquals(null, ourLastId);
assertEquals(requestContents, ourLastBody);
assertEquals(PatchTypeEnum.JSON_PATCH, ourLastPatchType);
}
@Test
public void testPatchValidXml() throws Exception {
String requestContents = "<root/>";
@ -128,6 +163,32 @@ public class PatchDstu3Test {
}
public static class DummyPatientResourceProvider implements IResourceProvider {
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
@Patch
public OperationOutcome patientPatch(
@IdParam IdType theId,
PatchTypeEnum thePatchType,
@ResourceParam String theBody,
@ConditionalUrlParam String theConditional
) {
ourLastMethod = "patientPatch";
ourLastBody = theBody;
ourLastId = theId;
ourLastPatchType = thePatchType;
ourLastConditional = theConditional;
OperationOutcome retVal = new OperationOutcome();
retVal.getText().setDivAsString("<div>OK</div>");
return retVal;
}
}
@AfterClass
public static void afterClassClearContext() throws Exception {
JettyUtil.closeServer(ourServer);
@ -149,7 +210,7 @@ public class PatchDstu3Test {
proxyHandler.addServletWithMapping(servletHolder, "/*");
ourServer.setHandler(proxyHandler);
JettyUtil.startServer(ourServer);
ourPort = JettyUtil.getPortForStartedServer(ourServer);
ourPort = JettyUtil.getPortForStartedServer(ourServer);
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(5000, TimeUnit.MILLISECONDS);
HttpClientBuilder builder = HttpClientBuilder.create();
@ -158,28 +219,4 @@ public class PatchDstu3Test {
}
private static String ourLastBody;
private static IdType ourLastId;
public static class DummyPatientResourceProvider implements IResourceProvider {
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
@Patch
public OperationOutcome patientPatch(@IdParam IdType theId, PatchTypeEnum thePatchType, @ResourceParam String theBody) {
ourLastMethod = "patientPatch";
ourLastBody = theBody;
ourLastId = theId;
ourLastPatchType = thePatchType;
OperationOutcome retVal = new OperationOutcome();
retVal.getText().setDivAsString("<div>OK</div>");
return retVal;
}
}
}

View File

@ -74,6 +74,11 @@
assign a free port. This should theoretically result in fewer failed builds resulting from
port conflicts. Thanks to Stig Døssing for the pull request!
</action>
<action type="add" issue="1348">
JPA server now supports conditional PATCH operation (i.e. performing a patch
with a syntax such as
<![CDATA[<code>/Patient?identifier=sys|val</code>]]>)
</action>
</release>
<release version="3.8.0" date="2019-05-30" description="Hippo">
<action type="fix">