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; package ca.uhn.fhir.i18n;
import ca.uhn.fhir.context.ConfigurationException; import ca.uhn.fhir.context.ConfigurationException;
import ca.uhn.fhir.util.UrlUtil;
import java.text.MessageFormat; import java.text.MessageFormat;
import java.util.*; import java.util.*;
@ -92,6 +93,20 @@ public class HapiLocalizer {
return getMessage(toKey(theType, theKey), theParameters); 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) { public String getMessage(String theQualifiedKey, Object... theParameters) {
if (theParameters != null && theParameters.length > 0) { if (theParameters != null && theParameters.length > 0) {
MessageFormat format = myKeyToMessageFormat.get(theQualifiedKey); MessageFormat format = myKeyToMessageFormat.get(theQualifiedKey);

View File

@ -123,7 +123,7 @@ public class UrlUtil {
return value.startsWith("http://") || value.startsWith("https://"); return value.startsWith("http://") || value.startsWith("https://");
} }
public static boolean isNeedsSanitization(String theString) { public static boolean isNeedsSanitization(CharSequence theString) {
if (theString != null) { if (theString != null) {
for (int i = 0; i < theString.length(); i++) { for (int i = 0; i < theString.length(); i++) {
char nextChar = theString.charAt(i); char nextChar = theString.charAt(i);
@ -302,7 +302,7 @@ public class UrlUtil {
* This method specifically HTML-encodes the &quot; and * This method specifically HTML-encodes the &quot; and
* &lt; characters in order to prevent injection attacks * &lt; characters in order to prevent injection attacks
*/ */
public static String sanitizeUrlPart(String theString) { public static String sanitizeUrlPart(CharSequence theString) {
if (theString == null) { if (theString == null) {
return null; return null;
} }
@ -316,6 +316,9 @@ public class UrlUtil {
char nextChar = theString.charAt(j); char nextChar = theString.charAt(j);
switch (nextChar) { switch (nextChar) {
case '\'':
buffer.append("&apos;");
break;
case '"': case '"':
buffer.append("&quot;"); buffer.append("&quot;");
break; break;
@ -332,7 +335,7 @@ public class UrlUtil {
return buffer.toString(); return buffer.toString();
} }
return theString; return theString.toString();
} }
private static Map<String, String[]> toQueryStringMap(HashMap<String, List<String>> map) { 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.io.IOException;
import java.util.*; import java.util.*;
import static ca.uhn.fhir.util.UrlUtil.sanitizeUrlPart;
import static org.apache.commons.lang3.StringUtils.isNotBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank;
@Transactional(propagation = Propagation.REQUIRED) @Transactional(propagation = Propagation.REQUIRED)
@ -151,7 +152,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
if (isNotBlank(theResource.getIdElement().getIdPart())) { if (isNotBlank(theResource.getIdElement().getIdPart())) {
if (getContext().getVersion().getVersion().isOlderThan(FhirVersionEnum.DSTU3)) { 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")); throw new InvalidRequestException(message, createErrorOperationOutcome(message, "processing"));
} else { } else {
// As of DSTU3, ID and version in the body should be ignored for a create/update // 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); Set<Long> resourceIds = myMatchResourceUrlService.processMatchUrl(theUrl, myResourceType);
if (resourceIds.size() > 1) { if (resourceIds.size() > 1) {
if (myDaoConfig.isAllowMultipleDelete() == false) { 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; IBaseOperationOutcome oo;
if (deletedResources.isEmpty()) { if (deletedResources.isEmpty()) {
oo = OperationOutcomeUtil.newInstance(getContext()); 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 severity = "warning";
String code = "not-found"; String code = "not-found";
OperationOutcomeUtil.addIssue(getContext(), oo, severity, message, null, code); OperationOutcomeUtil.addIssue(getContext(), oo, severity, message, null, code);
@ -384,7 +385,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
if (isNotBlank(theIfNoneExist)) { if (isNotBlank(theIfNoneExist)) {
Set<Long> match = myMatchResourceUrlService.processMatchUrl(theIfNoneExist, myResourceType); Set<Long> match = myMatchResourceUrlService.processMatchUrl(theIfNoneExist, myResourceType);
if (match.size() > 1) { 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); throw new PreconditionFailedException(msg);
} else if (match.size() == 1) { } else if (match.size() == 1) {
Long pid = match.iterator().next(); Long pid = match.iterator().next();
@ -400,11 +401,11 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
switch (myDaoConfig.getResourceClientIdStrategy()) { switch (myDaoConfig.getResourceClientIdStrategy()) {
case NOT_ALLOWED: case NOT_ALLOWED:
throw new ResourceNotFoundException( throw new ResourceNotFoundException(
getContext().getLocalizer().getMessage(BaseHapiFhirResourceDao.class, "failedToCreateWithClientAssignedIdNotAllowed", theResource.getIdElement().getIdPart())); getContext().getLocalizer().getMessageSanitized(BaseHapiFhirResourceDao.class, "failedToCreateWithClientAssignedIdNotAllowed", theResource.getIdElement().getIdPart()));
case ALPHANUMERIC: case ALPHANUMERIC:
if (theResource.getIdElement().isIdPartValidLong()) { if (theResource.getIdElement().isIdPartValidLong()) {
throw new InvalidRequestException( 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); createForcedIdIfNeeded(entity, theResource.getIdElement(), false);
break; break;
@ -480,7 +481,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
outcome.setId(theResource.getIdElement()); 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)); outcome.setOperationOutcome(createInfoOperationOutcome(msg));
ourLog.debug(msg); ourLog.debug(msg);
@ -775,13 +776,31 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
} }
@Override @Override
public DaoMethodOutcome patch(IIdType theId, PatchTypeEnum thePatchType, String thePatchBody, RequestDetails theRequestDetails) { public DaoMethodOutcome patch(IIdType theId, String theConditionalUrl, PatchTypeEnum thePatchType, String thePatchBody, RequestDetails theRequestDetails) {
ResourceTable entityToUpdate = readEntityLatestVersion(theId);
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.hasVersionIdPart()) {
if (theId.getVersionIdPartAsLong() != entityToUpdate.getVersion()) { if (theId.getVersionIdPartAsLong() != entityToUpdate.getVersion()) {
throw new ResourceVersionConflictException("Version " + theId.getVersionIdPart() + " is not the most recent version of this resource, unable to apply patch"); throw new ResourceVersionConflictException("Version " + theId.getVersionIdPart() + " is not the most recent version of this resource, unable to apply patch");
} }
} }
}
validateResourceType(entityToUpdate); validateResourceType(entityToUpdate);
@ -831,12 +850,12 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
protected void preProcessResourceForStorage(T theResource) { protected void preProcessResourceForStorage(T theResource) {
String type = getContext().getResourceDefinition(theResource).getName(); String type = getContext().getResourceDefinition(theResource).getName();
if (!getResourceName().equals(type)) { 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().hasIdPart()) {
if (!theResource.getIdElement().isIdPartValid()) { 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.hasVersionIdPart()) {
if (theId.isVersionIdPartValidLong() == false) { 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()) { if (entity.getVersion() != theId.getVersionIdPartAsLong()) {
entity = null; entity = null;
@ -961,7 +980,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
try { try {
entity = q.getSingleResult(); entity = q.getSingleResult();
} catch (NoResultException e) { } 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); QualifierDetails qualifiedParamName = SearchMethodBinding.extractQualifiersFromParameterName(nextParamName);
RuntimeSearchParam param = searchParams.get(qualifiedParamName.getParamName()); RuntimeSearchParam param = searchParams.get(qualifiedParamName.getParamName());
if (param == null) { 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); throw new InvalidRequestException(msg);
} }
@ -1268,7 +1287,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
if (isNotBlank(theMatchUrl)) { if (isNotBlank(theMatchUrl)) {
Set<Long> match = myMatchResourceUrlService.processMatchUrl(theMatchUrl, myResourceType); Set<Long> match = myMatchResourceUrlService.processMatchUrl(theMatchUrl, myResourceType);
if (match.size() > 1) { 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); throw new PreconditionFailedException(msg);
} else if (match.size() == 1) { } else if (match.size() == 1) {
Long pid = match.iterator().next(); Long pid = match.iterator().next();
@ -1340,7 +1359,7 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
outcome.setId(theResource.getIdElement()); 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)); outcome.setOperationOutcome(createInfoOperationOutcome(msg));
ourLog.debug(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); <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); Set<Long> processMatchUrl(String theMatchUrl);

View File

@ -115,10 +115,10 @@ public abstract class BaseJpaResourceProvider<T extends IBaseResource> extends B
} }
@Patch @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); startRequest(theRequest);
try { try {
return myDao.patch(theId, thePatchType, theBody, theRequestDetails); return myDao.patch(theId, theConditionalUrl, thePatchType, theBody, theRequestDetails);
} finally { } finally {
endRequest(theRequest); 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_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_REPRESENTATION);
patch.addHeader(Constants.HEADER_ACCEPT, Constants.CT_FHIR_JSON); patch.addHeader(Constants.HEADER_ACCEPT, Constants.CT_FHIR_JSON);
CloseableHttpResponse response = ourHttpClient.execute(patch); try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
try {
assertEquals(200, response.getStatusLine().getStatusCode()); assertEquals(200, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info("Response:\n{}", responseString); ourLog.info("Response:\n{}", responseString);
assertThat(responseString, containsString("\"derivedFrom\":[{\"reference\":\"Media/465eb73a-bce3-423a-b86e-5d0d267638f4\"}]")); 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.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); patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
CloseableHttpResponse response = ourHttpClient.execute(patch); try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
try {
assertEquals(200, response.getStatusLine().getStatusCode()); assertEquals(200, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome")); assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("INFORMATION")); assertThat(responseString, containsString("INFORMATION"));
} finally {
response.close();
} }
Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute(); Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
@ -99,6 +93,92 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
assertEquals(false, newPt.getActive()); 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 * Pass in an invalid JSON Patch and make sure the error message
* that is returned is useful * 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.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); patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
CloseableHttpResponse response = ourHttpClient.execute(patch); try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
try {
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome")); assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("was expecting double-quote to start field name")); assertThat(responseString, containsString("was expecting double-quote to start field name"));
assertEquals(400, response.getStatusLine().getStatusCode()); 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.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\""); patch.addHeader("If-Match", "W/\"9\"");
CloseableHttpResponse response = ourHttpClient.execute(patch); try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
try {
assertEquals(409, response.getStatusLine().getStatusCode()); assertEquals(409, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome")); assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("<diagnostics value=\"Version 9 is not the most recent version of this resource, unable to apply patch\"/>")); 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(); 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("If-Match", "W/\"1\"");
patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME); patch.addHeader(Constants.HEADER_PREFER, Constants.HEADER_PREFER_RETURN + "=" + Constants.HEADER_PREFER_RETURN_OPERATION_OUTCOME);
CloseableHttpResponse response = ourHttpClient.execute(patch); try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
try {
assertEquals(200, response.getStatusLine().getStatusCode()); assertEquals(200, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome")); assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("INFORMATION")); assertThat(responseString, containsString("INFORMATION"));
} finally {
response.close();
} }
Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute(); 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.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))); patch.setEntity(new StringEntity(patchString, ContentType.parse(Constants.CT_XML_PATCH + Constants.CHARSET_UTF8_CTSUFFIX)));
CloseableHttpResponse response = ourHttpClient.execute(patch); try (CloseableHttpResponse response = ourHttpClient.execute(patch)) {
try {
assertEquals(200, response.getStatusLine().getStatusCode()); assertEquals(200, response.getStatusLine().getStatusCode());
String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseString, containsString("<OperationOutcome")); assertThat(responseString, containsString("<OperationOutcome"));
assertThat(responseString, containsString("INFORMATION")); assertThat(responseString, containsString("INFORMATION"));
} finally {
response.close();
} }
Patient newPt = ourClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute(); 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.IServerInterceptor;
import ca.uhn.fhir.rest.server.interceptor.ResponseHighlighterInterceptor; import ca.uhn.fhir.rest.server.interceptor.ResponseHighlighterInterceptor;
import org.apache.commons.dbcp2.BasicDataSource; import org.apache.commons.dbcp2.BasicDataSource;
import org.hibernate.jpa.HibernatePersistenceProvider;
import org.hibernate.search.elasticsearch.cfg.ElasticsearchEnvironment; import org.hibernate.search.elasticsearch.cfg.ElasticsearchEnvironment;
import org.springframework.beans.factory.annotation.Autowire; import org.springframework.beans.factory.annotation.Autowire;
import org.springframework.context.annotation.Bean; 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 * This interceptor adds some pretty syntax highlighting in responses when a browser is detected
* @return
*/ */
@Bean(autowire = Autowire.BY_TYPE) @Bean(autowire = Autowire.BY_TYPE)
public IServerInterceptor responseHighlighterInterceptor() { public ResponseHighlighterInterceptor responseHighlighterInterceptor() {
ResponseHighlighterInterceptor retVal = new ResponseHighlighterInterceptor(); ResponseHighlighterInterceptor retVal = new ResponseHighlighterInterceptor();
return retVal; return retVal;
} }

View File

@ -113,7 +113,8 @@ public abstract class RequestDetails {
* @return Returns the <b>conditional URL</b> if this request has one, or <code>null</code> otherwise * @return Returns the <b>conditional URL</b> if this request has one, or <code>null</code> otherwise
*/ */
public String getConditionalUrl(RestOperationTypeEnum theOperationType) { public String getConditionalUrl(RestOperationTypeEnum theOperationType) {
if (theOperationType == RestOperationTypeEnum.CREATE) { switch (theOperationType) {
case CREATE:
String retVal = this.getHeader(Constants.HEADER_IF_NONE_EXIST); String retVal = this.getHeader(Constants.HEADER_IF_NONE_EXIST);
if (isBlank(retVal)) { if (isBlank(retVal)) {
return null; return null;
@ -122,10 +123,9 @@ public abstract class RequestDetails {
retVal = retVal.substring(this.getFhirServerBase().length()); retVal = retVal.substring(this.getFhirServerBase().length());
} }
return retVal; return retVal;
} else if (theOperationType != RestOperationTypeEnum.DELETE && theOperationType != RestOperationTypeEnum.UPDATE) { case DELETE:
return null; case UPDATE:
} case PATCH:
if (this.getId() != null && this.getId().hasIdPart()) { if (this.getId() != null && this.getId().hasIdPart()) {
return null; return null;
} }
@ -136,6 +136,9 @@ public abstract class RequestDetails {
} }
return this.getResourceName() + this.getCompleteUrl().substring(questionMarkIndex); return this.getResourceName() + this.getCompleteUrl().substring(questionMarkIndex);
default:
return null;
}
} }
/** /**

View File

@ -1,10 +1,14 @@
package ca.uhn.fhir.rest.server; package ca.uhn.fhir.rest.server;
import static org.junit.Assert.assertEquals; import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.ConditionalUrlParam;
import java.nio.charset.StandardCharsets; import ca.uhn.fhir.rest.annotation.IdParam;
import java.util.concurrent.TimeUnit; 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.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPatch; 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.server.Server;
import org.eclipse.jetty.servlet.ServletHandler; import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder; 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.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 java.nio.charset.StandardCharsets;
import ca.uhn.fhir.rest.annotation.*; import java.util.concurrent.TimeUnit;
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;
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 CloseableHttpClient ourClient;
private static FhirContext ourCtx = FhirContext.forDstu3(); 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 int ourPort;
private static Server ourServer; private static Server ourServer;
private static String ourLastMethod; private static String ourLastMethod;
private static PatchTypeEnum ourLastPatchType; private static PatchTypeEnum ourLastPatchType;
private static String ourLastBody;
private static IdType ourLastId;
private static String ourLastConditional;
@Before @Before
public void before() { public void before() {
ourLastMethod = null; ourLastMethod = null;
ourLastBody = null; ourLastBody = null;
ourLastId = null; ourLastId = null;
ourLastConditional = null;
} }
@Test @Test
@ -67,6 +78,30 @@ public class PatchDstu3Test {
assertEquals(PatchTypeEnum.JSON_PATCH, ourLastPatchType); 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 @Test
public void testPatchValidXml() throws Exception { public void testPatchValidXml() throws Exception {
String requestContents = "<root/>"; 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 @AfterClass
public static void afterClassClearContext() throws Exception { public static void afterClassClearContext() throws Exception {
JettyUtil.closeServer(ourServer); JettyUtil.closeServer(ourServer);
@ -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 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! port conflicts. Thanks to Stig Døssing for the pull request!
</action> </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>
<release version="3.8.0" date="2019-05-30" description="Hippo"> <release version="3.8.0" date="2019-05-30" description="Hippo">
<action type="fix"> <action type="fix">