DATAREST-787 - Remove dependency to JSON Patch library.

Switched to own JSON Patch implementation built by Craig Walls for Spring Sync back in the days.
This commit is contained in:
Oliver Gierke
2015-09-30 20:47:09 -04:00
parent 6075f3052a
commit 4430e78966
31 changed files with 1932 additions and 78 deletions

View File

@@ -20,6 +20,7 @@ import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
import static org.springframework.data.rest.tests.mongodb.TestUtils.*;
import java.util.ArrayList;
import java.util.Arrays;
import org.junit.Before;
@@ -119,7 +120,7 @@ public class JsonPatchHandlerUnitTests {
User christoph = new User();
christoph.firstname = "Christoph";
this.user.colleagues = Arrays.asList(thomas, christoph);
this.user.colleagues = new ArrayList<User>(Arrays.asList(thomas, christoph));
String input = "[{ \"op\": \"remove\", \"path\": \"/colleagues/0\" }]";

View File

@@ -79,14 +79,6 @@
<version>${jackson}</version>
<optional>true</optional>
</dependency>
<!-- JSON patch -->
<dependency>
<groupId>com.github.fge</groupId>
<artifactId>json-patch</artifactId>
<version>1.7</version>
</dependency>
<!-- Querydsl -->

View File

@@ -16,26 +16,18 @@
package org.springframework.data.rest.webmvc.config;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.util.List;
import org.springframework.data.rest.webmvc.IncomingRequest;
import org.springframework.data.rest.webmvc.RestMediaTypes;
import org.springframework.data.rest.webmvc.json.DomainObjectReader;
import org.springframework.data.rest.webmvc.json.patch.JsonPatchPatchConverter;
import org.springframework.data.rest.webmvc.json.patch.Patch;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.github.fge.jackson.jsonpointer.JsonPointer;
import com.github.fge.jsonpatch.JsonPatchOperation;
import com.github.fge.jsonpatch.RemoveOperation;
import com.github.fge.jsonpatch.ReplaceOperation;
/**
* Component to apply JSON Patch and JSON Merge Patch payloads to existing domain objects. The implementation uses the
@@ -49,16 +41,6 @@ import com.github.fge.jsonpatch.ReplaceOperation;
*/
class JsonPatchHandler {
private static final Field PATH_FIELD;
static {
Field field = ReflectionUtils.findField(JsonPatchOperation.class, "path");
ReflectionUtils.makeAccessible(field);
PATH_FIELD = field;
}
private final ObjectMapper mapper;
private final ObjectMapper sourceMapper;
private final DomainObjectReader reader;
@@ -104,26 +86,7 @@ class JsonPatchHandler {
<T> T applyPatch(InputStream source, T target) throws Exception {
List<JsonPatchOperation> readValue = getPatchOperations(source);
JsonNode existingAsNode = mapper.readTree(sourceMapper.writeValueAsBytes(target));
JsonNode patchedNode = existingAsNode;
for (JsonPatchOperation operation : readValue) {
if (operation instanceof RemoveOperation) {
// Replace remove operation with replace operation and a value of null.
JsonPointer path = (JsonPointer) ReflectionUtils.getField(PATH_FIELD, operation);
patchedNode = isCollectionElementReference(path) ? operation.apply(patchedNode)
: new ReplaceOperation(path, NullNode.getInstance()).apply(patchedNode);
} else {
patchedNode = operation.apply(patchedNode);
}
}
return reader.merge((ObjectNode) patchedNode, target, mapper);
return getPatchOperations(source).apply(target, (Class<T>) target.getClass());
}
<T> T applyMergePatch(InputStream source, T existingObject) throws Exception {
@@ -141,40 +104,13 @@ class JsonPatchHandler {
* @return
* @throws HttpMessageNotReadableException in case the payload can't be read.
*/
private List<JsonPatchOperation> getPatchOperations(InputStream source) {
CollectionType listOfOperationsType = mapper.getTypeFactory().constructCollectionType(List.class,
JsonPatchOperation.class);
private Patch getPatchOperations(InputStream source) {
try {
return mapper.readValue(source, listOfOperationsType);
return new JsonPatchPatchConverter().convert(mapper.readTree(source));
} catch (Exception o_O) {
throw new HttpMessageNotReadableException(
String.format("Could not read PATCH operations! Expected %s!", RestMediaTypes.JSON_PATCH_JSON), o_O);
}
}
/**
* Returns whether the trailing element of the given {@link JsonPointer} is a pointer into an array or collection.
*
* @param pointer must not be {@literal null}.
* @return
*/
private static boolean isCollectionElementReference(JsonPointer pointer) {
String[] segments = pointer.toString().split("/");
if (segments.length == 0) {
return false;
}
String trailing = segments[segments.length - 1];
try {
Integer.parseInt(trailing);
return true;
} catch (NumberFormatException o_O) {
return false;
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
/**
* Operation to add a new value to the given "path".
* Will throw a {@link PatchException} if the path is invalid or if the given value
* is not assignable to the given path.
*
* @author Craig Walls
*/
public class AddOperation extends PatchOperation {
/**
* Constructs the add operation
* @param path The path where the value will be added. (e.g., '/foo/bar/4')
* @param value The value to add.
*/
public AddOperation(String path, Object value) {
super("add", path, value);
}
@Override
<T> void perform(Object targetObject, Class<T> type) {
addValue(targetObject, evaluateValueFromTarget(targetObject, type));
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import static org.springframework.data.rest.webmvc.json.patch.PathToSpEL.*;
/**
* <p>
* Operation to copy a value from the given "from" path to the given "path". Will throw a {@link PatchException} if
* either path is invalid or if the object at the from path is not assignable to the given path.
* </p>
* <p>
* NOTE: When dealing with lists, the copy operation may yield undesirable results. If a list is produced from a
* database query, it's likely that the list contains items with a unique ID. Copying an item in the list will result
* with a list that has a duplicate object, with the same ID. The best case post-patch scenario is that each copy of the
* item will be saved, unchanged, to the database and later queries for the list will not include the duplicate. The
* worst case post-patch scenario is that a following operation changes some properties of the copy, but does not change
* the ID. When saved, both the original and the copy will be saved, but the last one saved will overwrite the first.
* Effectively only one copy will survive post-save.
* </p>
* <p>
* In light of this, it's probably a good idea to perform a "replace" after a "copy" to set the ID property (which may
* or may not be "id").
* </p>
*
* @author Craig Walls
*/
public class CopyOperation extends FromOperation {
/**
* Constructs the copy operation
*
* @param path The path to copy the source value to. (e.g., '/foo/bar/4')
* @param from The source path from which a value will be copied. (e.g., '/foo/bar/5')
*/
public CopyOperation(String path, String from) {
super("copy", path, from);
}
@Override
<T> void perform(Object target, Class<T> type) {
addValue(target, pathToExpression(from).getValue(target));
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
/**
* Abstract base class for operations requiring a source property, such as "copy" and "move".
* (e.g., copy <i>from</i> here to there.
* @author Craig Walls
*/
public abstract class FromOperation extends PatchOperation {
protected String from;
/**
* Constructs the operation
* @param op The name of the operation to perform. (e.g., 'copy')
* @param path The operation's target path. (e.g., '/foo/bar/4')
* @param from The operation's source path. (e.g., '/foo/bar/5')
*/
public FromOperation(String op, String path, String from) {
super(op, path);
this.from = from;
}
public String getFrom() {
return from;
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* {@link LateObjectEvaluator} implementation that assumes values represented as JSON objects.
*
* @author Craig Walls
*/
class JsonLateObjectEvaluator implements LateObjectEvaluator {
private static final ObjectMapper MAPPER = new ObjectMapper();
private JsonNode valueNode;
public JsonLateObjectEvaluator(JsonNode valueNode) {
this.valueNode = valueNode;
}
@Override
public <T> Object evaluate(Class<T> type) {
try {
return MAPPER.readValue(valueNode.traverse(), type);
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,156 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import com.fasterxml.jackson.core.JsonPointer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* Convert {@link JsonNode}s containing JSON Patch to/from {@link Patch} objects.
*
* @author Craig Walls
*/
public class JsonPatchPatchConverter implements PatchConverter<JsonNode> {
private static final ObjectMapper MAPPER = new ObjectMapper();
/**
* Constructs a {@link Patch} object given a JsonNode.
*
* @param jsonNode a JsonNode containing the JSON Patch
* @return a {@link Patch}
*/
public Patch convert(JsonNode jsonNode) {
if (!(jsonNode instanceof ArrayNode)) {
throw new IllegalArgumentException("JsonNode must be an instance of ArrayNode");
}
ArrayNode opNodes = (ArrayNode) jsonNode;
List<PatchOperation> ops = new ArrayList<PatchOperation>(opNodes.size());
for (Iterator<JsonNode> elements = opNodes.elements(); elements.hasNext();) {
JsonNode opNode = elements.next();
String opType = opNode.get("op").textValue();
String path = opNode.get("path").textValue();
JsonNode valueNode = opNode.get("value");
Object value = valueFromJsonNode(path, valueNode);
String from = opNode.has("from") ? opNode.get("from").textValue() : null;
if (opType.equals("test")) {
ops.add(new TestOperation(path, value));
} else if (opType.equals("replace")) {
ops.add(new ReplaceOperation(path, value));
} else if (opType.equals("remove")) {
ops.add(new RemoveOperation(path));
} else if (opType.equals("add")) {
ops.add(new AddOperation(path, value));
} else if (opType.equals("copy")) {
ops.add(new CopyOperation(path, from));
} else if (opType.equals("move")) {
ops.add(new MoveOperation(path, from));
} else {
throw new PatchException("Unrecognized operation type: " + opType);
}
}
return new Patch(ops);
}
/**
* Renders a {@link Patch} as a {@link JsonNode}.
*
* @param patch the patch
* @return a {@link JsonNode} containing JSON Patch.
*/
public JsonNode convert(Patch patch) {
List<PatchOperation> operations = patch.getOperations();
JsonNodeFactory nodeFactory = JsonNodeFactory.instance;
ArrayNode patchNode = nodeFactory.arrayNode();
for (PatchOperation operation : operations) {
ObjectNode opNode = nodeFactory.objectNode();
opNode.set("op", nodeFactory.textNode(operation.getOp()));
opNode.set("path", nodeFactory.textNode(operation.getPath()));
if (operation instanceof FromOperation) {
FromOperation fromOp = (FromOperation) operation;
opNode.set("from", nodeFactory.textNode(fromOp.getFrom()));
}
Object value = operation.getValue();
if (value != null) {
opNode.set("value", MAPPER.valueToTree(value));
}
patchNode.add(opNode);
}
return patchNode;
}
private Object valueFromJsonNode(String path, JsonNode valueNode) {
if (valueNode == null || valueNode.isNull()) {
return null;
} else if (valueNode.isTextual()) {
return valueNode.asText();
} else if (valueNode.isFloatingPointNumber()) {
return valueNode.asDouble();
} else if (valueNode.isBoolean()) {
return valueNode.asBoolean();
} else if (valueNode.isInt()) {
return valueNode.asInt();
} else if (valueNode.isLong()) {
return valueNode.asLong();
} else if (valueNode.isObject()) {
return new JsonLateObjectEvaluator(valueNode);
} else if (valueNode.isArray()) {
// TODO: Convert valueNode to array
}
return null;
}
/**
* Returns whether the trailing element of the given {@link JsonPointer} is a pointer into an array or collection.
*
* @param pointer must not be {@literal null}.
* @return
*/
private static boolean isCollectionElementReference(String pointer) {
String[] segments = pointer.split("/");
if (segments.length == 0) {
return false;
}
String trailing = segments[segments.length - 1];
try {
Integer.parseInt(trailing);
return true;
} catch (NumberFormatException o_O) {
return false;
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
/**
* <p>
* Strategy interface for resolving values from an operation definition.
* </p>
*
* <p>
* {@link Patch} implementation generically defines a patch without being tied to any particular
* patch specification. But it's important to know the patch format when resolving the value of
* an operation, as the value format will likely be tied to the patch specification. For example,
* the <code>value</code> attribute of a JSON Patch operation will contain a JSON object. A different
* patch specification may define values in some non-JSON format.
* </p>
*
* <p>
* This interface allows for pluggable evaluation of values, allowing {@link Patch} to remain
* independent of any specific patch representation.
* </p>
*
* @author Craig Walls
*/
public interface LateObjectEvaluator {
<T> Object evaluate(Class<T> type);
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
/**
* <p>
* Operation that moves a value from the given "from" path to the given "path".
* Will throw a {@link PatchException} if either path is invalid or if the from path is non-nullable.
* </p>
*
* <p>
* NOTE: When dealing with lists, the move operation may effectively be a no-op.
* That's because the order of a list is probably dictated by a database query that produced the list.
* Moving things around in the list will have no bearing on the values of each item in the list.
* When the same list resource is retrieved again later, the order will again be decided by the query,
* effectively undoing any previous move operation.
* </p>
*
* @author Craig Walls
*/
public class MoveOperation extends FromOperation {
/**
* Constructs the move operation.
* @param path The path to move the source value to. (e.g., '/foo/bar/4')
* @param from The source path from which a value will be moved. (e.g., '/foo/bar/5')
*/
public MoveOperation(String path, String from) {
super("move", path, from);
}
@Override
<T> void perform(Object target, Class<T> type) {
addValue(target, popValueAtPath(target, from));
}
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import java.util.List;
/**
* <p>
* Represents a Patch.
* </p>
* <p>
* This class (and {@link PatchOperation} capture the definition of a patch, but are not coupled to any specific patch
* representation.
* </p>
*
* @author Craig Walls
*/
public class Patch {
private final List<PatchOperation> operations;
public Patch(List<PatchOperation> operations) {
this.operations = operations;
}
/**
* @return the number of operations that make up this patch.
*/
public int size() {
return operations.size();
}
public List<PatchOperation> getOperations() {
return operations;
}
/**
* Applies the Patch to a given Object graph. Makes a copy of the given object so that it will remain unchanged after
* application of the patch and in case any errors occur while performing the patch.
*
* @param in The object graph to apply the patch to.
* @param type The object type.
* @param <T> the object type.
* @return An object graph modified by the patch.
* @throws PatchException if there are any errors while applying the patch.
*/
public <T> T apply(T in, Class<T> type) throws PatchException {
for (PatchOperation operation : operations) {
operation.perform(in, type);
}
return in;
}
/**
* Applies the Patch to a given List of objects. Makes a copy of the given list so that it will remain unchanged after
* application of the patch and in case any errors occur while performing the patch.
*
* @param in The list to apply the patch to.
* @param type The list's generic type.
* @param <T> the list's generic type.
* @return An list modified by the patch.
* @throws PatchException if there are any errors while applying the patch.
*/
public <T> List<T> apply(List<T> in, Class<T> type) throws PatchException {
for (PatchOperation operation : operations) {
operation.perform(in, type);
}
return in;
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import com.fasterxml.jackson.databind.JsonNode;
/**
* <p>
* A strategy interface for producing {@link Patch} instances from a patch document representation (such as JSON Patch)
* and rendering a Patch to a patch document representation. This decouples the {@link Patch} class from any specific
* patch format or library that holds the representation.
* </p>
* <p>
* For example, if the {@link Patch} is to be represented as JSON Patch, the representation type could be
* {@link JsonNode} or some other JSON library's type that holds a JSON document.
* </p>
*
* @author Craig Walls
* @param <T> A type holding a representation of the patch. For example, a JsonNode if working with JSON Patch.
*/
public interface PatchConverter<T> {
/**
* Convert a patch document representation to a {@link Patch}.
*
* @param patchRepresentation the representation of a patch.
* @return the {@link Patch} object that the document represents.
*/
Patch convert(T patchRepresentation);
/**
* Convert a {@link Patch} to a representation object.
*
* @param patch the {@link Patch} to convert.
* @return the patch representation object.
*/
T convert(Patch patch);
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
/**
* Exception thrown if an error occurs in the course of applying a Patch.
*
* @author Craig Walls
*/
public class PatchException extends RuntimeException {
private static final long serialVersionUID = 1L;
public PatchException(String message) {
super(message);
}
public PatchException(String message, Exception e) {
super(message, e);
}
}

View File

@@ -0,0 +1,195 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import static org.springframework.data.rest.webmvc.json.patch.PathToSpEL.*;
import java.util.List;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionException;
/**
* Abstract base class representing and providing support methods for patch operations.
*
* @author Craig Walls
*/
public abstract class PatchOperation {
protected final String op;
protected final String path;
protected final Object value;
protected final Expression spelExpression;
/**
* Constructs the operation.
*
* @param op the operation name. (e.g., 'move')
* @param path the path to perform the operation on. (e.g., '/1/description')
*/
public PatchOperation(String op, String path) {
this(op, path, null);
}
/**
* Constructs the operation.
*
* @param op the operation name. (e.g., 'move')
* @param path the path to perform the operation on. (e.g., '/1/description')
* @param value the value to apply in the operation. Could be an actual value or an implementation of
* {@link LateObjectEvaluator}.
*/
public PatchOperation(String op, String path, Object value) {
this.op = op;
this.path = path;
this.value = value;
this.spelExpression = pathToExpression(path);
}
/**
* @return the operation name
*/
public String getOp() {
return op;
}
/**
* @return the operation path
*/
public String getPath() {
return path;
}
/**
* @return the operation's value (or {@link LateObjectEvaluator})
*/
public Object getValue() {
return value;
}
/**
* Pops a value from the given path.
*
* @param target the target from which to pop a value.
* @param removePath the path from which to pop a value. Must be a list.
* @return the value popped from the list
*/
protected Object popValueAtPath(Object target, String removePath) {
Integer listIndex = targetListIndex(removePath);
Expression expression = pathToExpression(removePath);
Object value = expression.getValue(target);
if (listIndex == null) {
try {
expression.setValue(target, null);
return value;
} catch (NullPointerException e) {
throw new PatchException("Path '" + removePath + "' is not nullable.");
}
} else {
Expression parentExpression = pathToParentExpression(removePath);
List<?> list = (List<?>) parentExpression.getValue(target);
list.remove(listIndex >= 0 ? listIndex.intValue() : list.size() - 1);
return value;
}
}
/**
* Adds a value to the operation's path. If the path references a list index, the value is added to the list at the
* given index. If the path references an object property, the property is set to the value.
*
* @param target The target object.
* @param value The value to add.
*/
protected void addValue(Object target, Object value) {
Expression parentExpression = pathToParentExpression(path);
Object parent = parentExpression != null ? parentExpression.getValue(target) : null;
Integer listIndex = targetListIndex(path);
if (parent == null || !(parent instanceof List) || listIndex == null) {
spelExpression.setValue(target, value);
} else {
@SuppressWarnings("unchecked")
List<Object> list = (List<Object>) parentExpression.getValue(target);
int addAtIndex = listIndex >= 0 ? listIndex.intValue() : list.size();
list.add(addAtIndex, value);
}
}
/**
* Sets a value to the operation's path.
*
* @param target The target object.
* @param value The value to set.
*/
protected void setValueOnTarget(Object target, Object value) {
spelExpression.setValue(target, value);
}
/**
* Retrieves a value from the operation's path.
*
* @param target the target object.
* @return the value at the path on the given target object.
*/
protected Object getValueFromTarget(Object target) {
try {
return spelExpression.getValue(target);
} catch (ExpressionException e) {
throw new PatchException("Unable to get value from target", e);
}
}
/**
* Performs late-value evaluation on the operation value if the value is a {@link LateObjectEvaluator}.
*
* @param targetObject the target object, used as assistance in determining the evaluated object's type.
* @param entityType the entityType
* @param <T> the entity type
* @return the result of late-value evaluation if the value is a {@link LateObjectEvaluator}; the value itself
* otherwise.
*/
protected <T> Object evaluateValueFromTarget(Object targetObject, Class<T> entityType) {
return value instanceof LateObjectEvaluator ? ((LateObjectEvaluator) value).evaluate(entityType) : value;
}
/**
* Perform the operation.
*
* @param target the target of the operation.
*/
abstract <T> void perform(Object target, Class<T> type);
// private helpers
private Integer targetListIndex(String path) {
String[] pathNodes = path.split("\\/");
String lastNode = pathNodes[pathNodes.length - 1];
if ("~".equals(lastNode)) {
return -1;
}
try {
return Integer.parseInt(lastNode);
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright 2014-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import java.util.Arrays;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
/**
* Utilities for converting patch paths to/from SpEL expressions. For example, "/foo/bars/1/baz" becomes
* "foo.bars[1].baz".
*
* @author Craig Walls
* @author Oliver Gierke
*/
public class PathToSpEL {
private static final SpelExpressionParser SPEL_EXPRESSION_PARSER = new SpelExpressionParser();
/**
* Converts a patch path to an {@link Expression}.
*
* @param path the patch path to convert.
* @return an {@link Expression}
*/
public static Expression pathToExpression(String path) {
return SPEL_EXPRESSION_PARSER.parseExpression(pathToSpEL(path));
}
/**
* Convenience method to convert a SpEL String to an {@link Expression}.
*
* @param spel the SpEL expression as a String
* @return an {@link Expression}
*/
public static Expression spelToExpression(String spel) {
return SPEL_EXPRESSION_PARSER.parseExpression(spel);
}
/**
* Produces an expression targeting the parent of the object that the given path targets.
*
* @param path the path to find a parent expression for.
* @return an {@link Expression} targeting the parent of the object specifed by path.
*/
public static Expression pathToParentExpression(String path) {
return spelToExpression(pathNodesToSpEL(copyOf(path.split("\\/"), path.split("\\/").length - 1)));
}
// private helpers
private static String pathToSpEL(String path) {
return pathNodesToSpEL(path.split("\\/"));
}
private static String pathNodesToSpEL(String[] pathNodes) {
StringBuilder spelBuilder = new StringBuilder();
for (int i = 0; i < pathNodes.length; i++) {
String pathNode = pathNodes[i];
if (pathNode.length() == 0) {
continue;
}
if ("~".equals(pathNode)) {
spelBuilder.append("[size() - 1]");
continue;
}
try {
int index = Integer.parseInt(pathNode);
spelBuilder.append('[').append(index).append(']');
} catch (NumberFormatException e) {
if (spelBuilder.length() > 0) {
spelBuilder.append('.');
}
spelBuilder.append(pathNode);
}
}
String spel = spelBuilder.toString();
if (spel.length() == 0) {
spel = "#this";
}
return spel;
}
@SuppressWarnings("unchecked")
private static <T> T[] copyOf(T[] original, int newLength) {
return (T[]) Arrays.copyOf(original, newLength, original.getClass());
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
/**
* Operation that removes the value at the given path.
* Will throw a {@link PatchException} if the given path isn't valid or if the path is non-nullable.
*
* @author Craig Walls
*/
public class RemoveOperation extends PatchOperation {
/**
* Constructs the remove operation
* @param path The path of the value to be removed. (e.g., '/foo/bar/4')
*/
public RemoveOperation(String path) {
super("remove", path);
}
@Override
<T> void perform(Object target, Class<T> type) {
popValueAtPath(target, path);
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
/**
* Operation that replaces the value at the given path with a new value.
*
* @author Craig Walls
*/
public class ReplaceOperation extends PatchOperation {
/**
* Constructs the replace operation
* @param path The path whose value is to be replaced. (e.g., '/foo/bar/4')
* @param value The value that will replace the current path value.
*/
public ReplaceOperation(String path, Object value) {
super("replace", path, value);
}
@Override
<T> void perform(Object target, Class<T> type) {
setValueOnTarget(target, evaluateValueFromTarget(target, type));
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import java.math.BigDecimal;
import java.math.BigInteger;
import org.springframework.util.ObjectUtils;
/**
* <p>
* Operation to test values on a given target.
* </p>
* <p>
* If the value given matches the value given at the path, the operation completes as a no-op. On the other hand, if the
* values do not match or if there are any errors interpreting the path, a {@link PatchException} will be thrown.
* </p>
*
* @author Craig Walls
*/
class TestOperation extends PatchOperation {
/**
* Constructs the test operation
*
* @param path The path to test. (e.g., '/foo/bar/4')
* @param value The value to test the path against.
*/
public TestOperation(String path, Object value) {
super("test", path, value);
}
@Override
<T> void perform(Object target, Class<T> type) {
Object expected = normalizeIfNumber(evaluateValueFromTarget(target, type));
Object actual = normalizeIfNumber(getValueFromTarget(target));
if (!ObjectUtils.nullSafeEquals(expected, actual)) {
throw new PatchException("Test against path '" + path + "' failed.");
}
}
private Object normalizeIfNumber(Object expected) {
if (expected instanceof Double || expected instanceof Float) {
expected = BigDecimal.valueOf(((Number) expected).doubleValue());
} else if (expected instanceof Number) {
expected = BigInteger.valueOf(((Number) expected).longValue());
}
return expected;
}
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import static org.junit.Assert.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
public class AddOperationTest {
@Test
public void addBooleanPropertyValue() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
AddOperation add = new AddOperation("/1/complete", true);
add.perform(todos, Todo.class);
assertTrue(todos.get(1).isComplete());
}
@Test
public void addStringPropertyValue() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
AddOperation add = new AddOperation("/1/description", "BBB");
add.perform(todos, Todo.class);
assertEquals("BBB", todos.get(1).getDescription());
}
@Test
public void addItemToList() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
AddOperation add = new AddOperation("/1", new Todo(null, "D", true));
add.perform(todos, Todo.class);
assertEquals(4, todos.size());
assertEquals("A", todos.get(0).getDescription());
assertFalse(todos.get(0).isComplete());
assertEquals("D", todos.get(1).getDescription());
assertTrue(todos.get(1).isComplete());
assertEquals("B", todos.get(2).getDescription());
assertFalse(todos.get(2).isComplete());
assertEquals("C", todos.get(3).getDescription());
assertFalse(todos.get(3).isComplete());
}
}

View File

@@ -0,0 +1,150 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import static org.junit.Assert.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
public class CopyOperationTest {
@Test
public void copyBooleanPropertyValue() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
CopyOperation copy = new CopyOperation("/1/complete", "/0/complete");
copy.perform(todos, Todo.class);
assertTrue(todos.get(1).isComplete());
}
@Test
public void copyStringPropertyValue() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
CopyOperation copy = new CopyOperation("/1/description", "/0/description");
copy.perform(todos, Todo.class);
assertEquals("A", todos.get(1).getDescription());
}
@Test
public void copyBooleanPropertyValueIntoStringProperty() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
CopyOperation copy = new CopyOperation("/1/description", "/0/complete");
copy.perform(todos, Todo.class);
assertEquals("true", todos.get(1).getDescription());
}
@Test
public void copyListElementToBeginningOfList() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", true));
todos.add(new Todo(3L, "C", false));
CopyOperation copy = new CopyOperation("/0", "/1");
copy.perform(todos, Todo.class);
assertEquals(4, todos.size());
assertEquals(2L, todos.get(0).getId().longValue()); // NOTE: This could be problematic if you try to save it to a DB because there'll be duplicate IDs
assertEquals("B", todos.get(0).getDescription());
assertTrue(todos.get(0).isComplete());
}
@Test
public void copyListElementToMiddleOfList() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
CopyOperation copy = new CopyOperation("/2", "/0");
copy.perform(todos, Todo.class);
assertEquals(4, todos.size());
assertEquals(1L, todos.get(2).getId().longValue()); // NOTE: This could be problematic if you try to save it to a DB because there'll be duplicate IDs
assertEquals("A", todos.get(2).getDescription());
assertTrue(todos.get(2).isComplete());
}
@Test
public void copyListElementToEndOfList_usingIndex() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
CopyOperation copy = new CopyOperation("/3", "/0");
copy.perform(todos, Todo.class);
assertEquals(4, todos.size());
assertEquals(1L, todos.get(3).getId().longValue()); // NOTE: This could be problematic if you try to save it to a DB because there'll be duplicate IDs
assertEquals("A", todos.get(3).getDescription());
assertTrue(todos.get(3).isComplete());
}
@Test
public void copyListElementToEndOfList_usingTilde() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
CopyOperation copy = new CopyOperation("/~", "/0");
copy.perform(todos, Todo.class);
assertEquals(4, todos.size());
assertEquals(new Todo(1L, "A", true), todos.get(3)); // NOTE: This could be problematic if you try to save it to a DB because there'll be duplicate IDs
}
@Test
public void copyListElementFromEndOfList_usingTilde() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
CopyOperation copy = new CopyOperation("/0", "/~");
copy.perform(todos, Todo.class);
assertEquals(4, todos.size());
assertEquals(new Todo(3L, "C", false), todos.get(0)); // NOTE: This could be problematic if you try to save it to a DB because there'll be duplicate IDs
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import static org.junit.Assert.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class JsonPatchTest {
@Test
public void manySuccessfulOperations() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
todos.add(new Todo(4L, "D", false));
todos.add(new Todo(5L, "E", false));
todos.add(new Todo(6L, "F", false));
Patch patch = readJsonPatch("patch-many-successful-operations.json");
assertEquals(6, patch.size());
List<Todo> patchedTodos = patch.apply(todos, Todo.class);
assertEquals(6, todos.size());
assertTrue(patchedTodos.get(1).isComplete());
assertEquals("C", patchedTodos.get(3).getDescription());
assertEquals("A", patchedTodos.get(4).getDescription());
}
@Test
public void failureAtBeginning() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
todos.add(new Todo(4L, "D", false));
todos.add(new Todo(5L, "E", false));
todos.add(new Todo(6L, "F", false));
Patch patch = readJsonPatch("patch-failing-operation-first.json");
try {
patch.apply(todos, Todo.class);
fail();
} catch (PatchException e) {
assertEquals("Test against path '/5/description' failed.", e.getMessage());
}
// nothing should have changed
assertEquals(6, todos.size());
assertFalse(todos.get(1).isComplete());
assertEquals("D", todos.get(3).getDescription());
assertEquals("E", todos.get(4).getDescription());
assertEquals("F", todos.get(5).getDescription());
}
@Test
public void failureInMiddle() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
todos.add(new Todo(4L, "D", false));
todos.add(new Todo(5L, "E", false));
todos.add(new Todo(6L, "F", false));
Patch patch = readJsonPatch("patch-failing-operation-in-middle.json");
try {
patch.apply(todos, Todo.class);
fail();
} catch (PatchException e) {
assertEquals("Test against path '/5/description' failed.", e.getMessage());
}
// nothing should have changed
assertEquals(6, todos.size());
assertFalse(todos.get(1).isComplete());
assertEquals("D", todos.get(3).getDescription());
assertEquals("E", todos.get(4).getDescription());
assertEquals("F", todos.get(5).getDescription());
}
private Patch readJsonPatch(String jsonPatchFile) throws IOException, JsonParseException, JsonMappingException {
ClassPathResource resource = new ClassPathResource(jsonPatchFile, getClass());
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readValue(resource.getInputStream(), JsonNode.class);
Patch patch = new JsonPatchPatchConverter().convert(node);
return patch;
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import static org.junit.Assert.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
public class MoveOperationTest {
@Test
public void moveBooleanPropertyValue() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
try {
MoveOperation move = new MoveOperation("/1/complete", "/0/complete");
move.perform(todos, Todo.class);
fail();
} catch (PatchException e) {
assertEquals("Path '/0/complete' is not nullable.", e.getMessage());
}
assertFalse(todos.get(1).isComplete());
}
@Test
public void moveStringPropertyValue() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
MoveOperation move = new MoveOperation("/1/description", "/0/description");
move.perform(todos, Todo.class);
assertEquals("A", todos.get(1).getDescription());
}
@Test
public void moveBooleanPropertyValueIntoStringProperty() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
try {
MoveOperation move = new MoveOperation("/1/description", "/0/complete");
move.perform(todos, Todo.class);
fail();
} catch (PatchException e) {
assertEquals("Path '/0/complete' is not nullable.", e.getMessage());
}
assertEquals("B", todos.get(1).getDescription());
}
//
// NOTE: Moving an item about in a list probably has zero effect, as the order of the list is
// usually determined by the DB query that produced the list. Moving things around in a
// java.util.List and then saving those items really means nothing to the DB, as the
// properties that determined the original order are still the same and will result in
// the same order when the objects are queries again.
//
@Test
public void moveListElementToBeginningOfList() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", true));
todos.add(new Todo(3L, "C", false));
MoveOperation move = new MoveOperation("/0", "/1");
move.perform(todos, Todo.class);
assertEquals(3, todos.size());
assertEquals(2L, todos.get(0).getId().longValue());
assertEquals("B", todos.get(0).getDescription());
assertTrue(todos.get(0).isComplete());
}
@Test
public void moveListElementToMiddleOfList() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
MoveOperation move = new MoveOperation("/2", "/0");
move.perform(todos, Todo.class);
assertEquals(3, todos.size());
assertEquals(1L, todos.get(2).getId().longValue());
assertEquals("A", todos.get(2).getDescription());
assertTrue(todos.get(2).isComplete());
}
@Test
public void moveListElementToEndOfList_usingIndex() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
MoveOperation move = new MoveOperation("/2", "/0");
move.perform(todos, Todo.class);
assertEquals(3, todos.size());
assertEquals(1L, todos.get(2).getId().longValue());
assertEquals("A", todos.get(2).getDescription());
assertTrue(todos.get(2).isComplete());
}
@Test
public void moveListElementToBeginningOfList_usingTilde() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(3L, "C", false));
todos.add(new Todo(4L, "E", false));
todos.add(new Todo(2L, "G", false));
List<Todo> expected = new ArrayList<Todo>();
expected.add(new Todo(1L, "A", true));
expected.add(new Todo(2L, "G", false));
expected.add(new Todo(3L, "C", false));
expected.add(new Todo(4L, "E", false));
MoveOperation move = new MoveOperation("/1", "/~");
move.perform(todos, Todo.class);
assertEquals(expected, todos);
}
@Test
public void moveListElementToEndOfList_usingTilde() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", true));
todos.add(new Todo(2L, "G", false));
todos.add(new Todo(3L, "C", false));
todos.add(new Todo(4L, "E", false));
List<Todo> expected = new ArrayList<Todo>();
expected.add(new Todo(1L, "A", true));
expected.add(new Todo(3L, "C", false));
expected.add(new Todo(4L, "E", false));
expected.add(new Todo(2L, "G", false));
MoveOperation move = new MoveOperation("/~", "/1");
move.perform(todos, Todo.class);
assertEquals(expected, todos);
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import static org.junit.Assert.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
import org.springframework.expression.Expression;
public class PathToSpelTest {
@Test
public void listIndex() {
Expression expr = PathToSpEL.pathToExpression("/1/description");
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
assertEquals("B", (String) expr.getValue(todos));
}
@Test
public void listTilde() {
Expression expr = PathToSpEL.pathToExpression("/~/description");
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
assertEquals("C", (String) expr.getValue(todos));
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import static org.junit.Assert.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
public class RemoveOperationTest {
@Test
public void removePropertyFromObject() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
new RemoveOperation("/1/description").perform(todos, Todo.class);
assertNull(todos.get(1).getDescription());
}
@Test
public void removeItemFromList() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
new RemoveOperation("/1").perform(todos, Todo.class);
assertEquals(2, todos.size());
assertEquals("A", todos.get(0).getDescription());
assertEquals("C", todos.get(1).getDescription());
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import static org.junit.Assert.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
public class ReplaceOperationTest {
@Test
public void replaceBooleanPropertyValue() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
ReplaceOperation replace = new ReplaceOperation("/1/complete", true);
replace.perform(todos, Todo.class);
assertTrue(todos.get(1).isComplete());
}
@Test
public void replaceTextPropertyValue() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
ReplaceOperation replace = new ReplaceOperation("/1/description", "BBB");
replace.perform(todos, Todo.class);
assertEquals("BBB", todos.get(1).getDescription());
}
@Test
public void replaceTextPropertyValueWithANumber() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", false));
todos.add(new Todo(3L, "C", false));
ReplaceOperation replace = new ReplaceOperation("/1/description", 22);
replace.perform(todos, Todo.class);
assertEquals("22", todos.get(1).getDescription());
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
public class TestOperationTest {
@Test
public void testPropertyValueEquals() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", true));
todos.add(new Todo(3L, "C", false));
TestOperation test = new TestOperation("/0/complete", false);
test.perform(todos, Todo.class);
TestOperation test2 = new TestOperation("/1/complete", true);
test2.perform(todos, Todo.class);
}
@Test(expected=PatchException.class)
public void testPropertyValueNotEquals() throws Exception {
// initial Todo list
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", true));
todos.add(new Todo(3L, "C", false));
TestOperation test = new TestOperation("/0/complete", true);
test.perform(todos, Todo.class);
}
@Test
public void testListElementEquals() throws Exception {
List<Todo> todos = new ArrayList<Todo>();
todos.add(new Todo(1L, "A", false));
todos.add(new Todo(2L, "B", true));
todos.add(new Todo(3L, "C", false));
TestOperation test = new TestOperation("/1", new Todo(2L, "B", true));
test.perform(todos, Todo.class);
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @author Roy Clarkson
* @author Craig Walls
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
class Todo {
private Long id;
private String description;
private boolean complete;
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.webmvc.json.patch;
import java.io.Serializable;
import java.util.List;
public class TodoList implements Serializable {
private static final long serialVersionUID = 1L;
private List<Todo> todos;
private Todo[] todoArray;
private String name;
public List<Todo> getTodos() {
return todos;
}
public void setTodos(List<Todo> todos) {
this.todos = todos;
}
public Todo[] getTodoArray() {
return todoArray;
}
public void setTodoArray(Todo[] todoArray) {
this.todoArray = todoArray;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,7 @@
[
{"op":"test", "path":"/5/description", "value":"A"},
{"op":"remove", "path":"/5"},
{"op":"replace", "path":"/1/complete", "value":true},
{"op":"copy", "path":"/3/description", "from":"/2/description"},
{"op":"copy", "path":"/4", "from":"/0"}
]

View File

@@ -0,0 +1,8 @@
[
{"op":"test", "path":"/5/description", "value":"A"},
{"op":"remove", "path":"/5"},
{"op":"replace", "path":"/1/complete", "value":true},
{"op":"test", "path":"/0/description", "value":"HUH"},
{"op":"copy", "path":"/3/description", "from":"/2/description"},
{"op":"copy", "path":"/4", "from":"/0"}
]

View File

@@ -0,0 +1,8 @@
[
{"op":"test", "path":"/0", "value":{"id":1,"description":"A","complete":true}},
{"op":"test", "path":"/5/description", "value":"F"},
{"op":"remove", "path":"/5"},
{"op":"replace", "path":"/1/complete", "value":true},
{"op":"copy", "path":"/3/description", "from":"/2/description"},
{"op":"copy", "path":"/4", "from":"/0"}
]