Improve JSON Patch implementation.

Refactor JSON Patch application implementation to improve the property detection for which values are supposed to be set.

Fixes #2177.
This commit is contained in:
Oliver Drotbohm
2022-09-01 11:09:03 -05:00
parent 5a4923a849
commit 2ad081f75b
35 changed files with 893 additions and 197 deletions

View File

@@ -19,14 +19,15 @@ import java.io.InputStream;
import org.springframework.data.rest.webmvc.IncomingRequest;
import org.springframework.data.rest.webmvc.RestMediaTypes;
import org.springframework.data.rest.webmvc.json.BindContextFactory;
import org.springframework.data.rest.webmvc.json.DomainObjectReader;
import org.springframework.data.rest.webmvc.json.patch.BindContext;
import org.springframework.data.rest.webmvc.json.patch.JsonPatchPatchConverter;
import org.springframework.data.rest.webmvc.json.patch.Patch;
import org.springframework.data.rest.webmvc.util.InputStreamHttpInputMessage;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.util.Assert;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -44,26 +45,23 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
*/
class JsonPatchHandler {
private final ObjectMapper mapper;
private final ObjectMapper sourceMapper;
private final BindContextFactory factory;
private final DomainObjectReader reader;
/**
* Creates a new {@link JsonPatchHandler} with the given {@link ObjectMapper} and {@link DomainObjectReader}.
* Creates a new {@link JsonPatchHandler} with the given {@link JacksonBindContextFactory} and
* {@link DomainObjectReader}.
*
* @param mapper must not be {@literal null}.
* @param factory must not be {@literal null}.
* @param reader must not be {@literal null}.
*/
public JsonPatchHandler(ObjectMapper mapper, DomainObjectReader reader) {
public JsonPatchHandler(BindContextFactory factory, DomainObjectReader reader) {
Assert.notNull(mapper, "ObjectMapper must not be null!");
Assert.notNull(reader, "DomainObjectReader must not be null!");
Assert.notNull(factory, "BindContextFactory must not be null");
Assert.notNull(reader, "DomainObjectReader must not be null");
this.mapper = mapper;
this.factory = factory;
this.reader = reader;
this.sourceMapper = mapper.copy();
this.sourceMapper.setSerializationInclusion(Include.NON_NULL);
}
/**
@@ -74,29 +72,33 @@ class JsonPatchHandler {
* @return
* @throws Exception
*/
public <T> T apply(IncomingRequest request, T target) throws Exception {
public <T> T apply(IncomingRequest request, T target, ObjectMapper mapper) throws Exception {
Assert.notNull(request, "Request must not be null!");
Assert.isTrue(request.isPatchRequest(), "Cannot handle non-PATCH request!");
Assert.notNull(target, "Target must not be null!");
if (request.isJsonPatchRequest()) {
return applyPatch(request.getBody(), target);
return applyPatch(request.getBody(), target, mapper);
} else {
return applyMergePatch(request.getBody(), target);
return applyMergePatch(request.getBody(), target, mapper);
}
}
@SuppressWarnings("unchecked")
<T> T applyPatch(InputStream source, T target) throws Exception {
return getPatchOperations(source).apply(target, (Class<T>) target.getClass());
<T> T applyPatch(InputStream source, T target, ObjectMapper mapper) throws Exception {
Class<?> type = target.getClass();
BindContext context = factory.getBindContextFor(mapper);
return getPatchOperations(source, mapper, context).apply(target, (Class<T>) target.getClass());
}
<T> T applyMergePatch(InputStream source, T existingObject) throws Exception {
<T> T applyMergePatch(InputStream source, T existingObject, ObjectMapper mapper) throws Exception {
return reader.read(source, existingObject, mapper);
}
<T> T applyPut(ObjectNode source, T existingObject) throws Exception {
<T> T applyPut(ObjectNode source, T existingObject, ObjectMapper mapper) throws Exception {
return reader.readPut(source, existingObject, mapper);
}
@@ -104,13 +106,14 @@ class JsonPatchHandler {
* Returns all {@link JsonPatchOperation}s to be applied.
*
* @param source must not be {@literal null}.
* @param mapper must not be {@literal null}.
* @return
* @throws HttpMessageNotReadableException in case the payload can't be read.
*/
private Patch getPatchOperations(InputStream source) {
private Patch getPatchOperations(InputStream source, ObjectMapper mapper, BindContext context) {
try {
return new JsonPatchPatchConverter(mapper).convert(mapper.readTree(source));
return new JsonPatchPatchConverter(mapper, context).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,

View File

@@ -36,6 +36,7 @@ import org.springframework.data.rest.webmvc.PersistentEntityResource;
import org.springframework.data.rest.webmvc.PersistentEntityResource.Builder;
import org.springframework.data.rest.webmvc.ResourceNotFoundException;
import org.springframework.data.rest.webmvc.RootResourceInformation;
import org.springframework.data.rest.webmvc.json.BindContextFactory;
import org.springframework.data.rest.webmvc.json.DomainObjectReader;
import org.springframework.data.rest.webmvc.support.BackendIdHandlerMethodArgumentResolver;
import org.springframework.http.MediaType;
@@ -69,15 +70,15 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha
private final List<HttpMessageConverter<?>> messageConverters;
private final RootResourceInformationHandlerMethodArgumentResolver resourceInformationResolver;
private final BackendIdHandlerMethodArgumentResolver idResolver;
private final DomainObjectReader reader;
private final PluginRegistry<EntityLookup<?>, Class<?>> lookups;
private final ConversionService conversionService = new DefaultConversionService();
private final JsonPatchHandler jsonPatchHandler;
public PersistentEntityResourceHandlerMethodArgumentResolver(
List<HttpMessageConverter<?>> messageConverters,
RootResourceInformationHandlerMethodArgumentResolver resourceInformationResolver,
BackendIdHandlerMethodArgumentResolver idResolver, DomainObjectReader reader,
PluginRegistry<EntityLookup<?>, Class<?>> lookups) {
PluginRegistry<EntityLookup<?>, Class<?>> lookups, BindContextFactory factory) {
Assert.notNull(messageConverters, "HttpMessageConverters must not be null!");
Assert.notNull(resourceInformationResolver, "RootResourceInformation resolver must not be null!");
@@ -88,8 +89,8 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha
this.messageConverters = messageConverters;
this.resourceInformationResolver = resourceInformationResolver;
this.idResolver = idResolver;
this.reader = reader;
this.lookups = lookups;
this.jsonPatchHandler = new JsonPatchHandler(mapper -> factory.getBindContextFor(mapper), reader);
}
/*
@@ -210,8 +211,7 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha
try {
JsonPatchHandler handler = new JsonPatchHandler(mapper, reader);
return handler.apply(request, existingObject);
return jsonPatchHandler.apply(request, existingObject, mapper);
} catch (Exception o_O) {
@@ -228,10 +228,9 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha
try {
JsonPatchHandler handler = new JsonPatchHandler(mapper, reader);
JsonNode jsonNode = mapper.readTree(request.getBody());
return handler.applyPut((ObjectNode) jsonNode, existingObject);
return jsonPatchHandler.applyPut((ObjectNode) jsonNode, existingObject, mapper);
} catch (Exception o_O) {
throw new HttpMessageNotReadableException(String.format(ERROR_MESSAGE, existingObject.getClass()), o_O,

View File

@@ -495,13 +495,15 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon
@Qualifier("defaultMessageConverters") List<HttpMessageConverter<?>> defaultMessageConverters,
RootResourceInformationHandlerMethodArgumentResolver repoRequestArgumentResolver, Associations associationLinks,
BackendIdHandlerMethodArgumentResolver backendIdHandlerMethodArgumentResolver,
PersistentEntities persistentEntities) {
PersistentEntities entities) {
PluginRegistry<EntityLookup<?>, Class<?>> lookups = PluginRegistry.of(getEntityLookups());
DomainObjectReader reader = new DomainObjectReader(entities, associationLinks);
BindContextFactory factory = new PersistentEntitiesBindContextFactory(entities);
return new PersistentEntityResourceHandlerMethodArgumentResolver(defaultMessageConverters,
repoRequestArgumentResolver, backendIdHandlerMethodArgumentResolver,
new DomainObjectReader(persistentEntities, associationLinks), lookups);
reader, lookups, factory);
}
/**

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2022 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
*
* https://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;
import org.springframework.data.rest.webmvc.json.patch.BindContext;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Factory to create {@link BindContext} instances.
*
* @author Oliver Drotbohm
*/
public interface BindContextFactory {
/**
* Creates a {@link BindContext} for the given {@link ObjectMapper}.
*
* @param mapper must not be {@literal null}.
* @return will never be {@literal null}.
*/
BindContext getBindContextFor(ObjectMapper mapper);
}

View File

@@ -246,7 +246,7 @@ public class DomainObjectReader {
JsonNode child = entry.getValue();
String fieldName = entry.getKey();
if (!mappedProperties.isWritableProperty(fieldName)) {
if (!mappedProperties.isWritableField(fieldName)) {
i.remove();
continue;

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2022 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
*
* https://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;
import java.util.Optional;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.rest.webmvc.json.patch.BindContext;
import org.springframework.util.Assert;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* A {@link BindContext} that uses a Jackson {@link ObjectMapper} to inspect its metadata to decide whether segments are
* exposed or not.
*
* @author Oliver Drotbohm
*/
class JacksonBindContext implements BindContext {
private final PersistentEntities entities;
private final ObjectMapper mapper;
/**
* Creates a new {@link JacksonBindContext} for the given {@link PersistentEntities} and {@link ObjectMapper}.
*
* @param entities must not be {@literal null}.
* @param mapper must not be {@literal null}.
*/
public JacksonBindContext(PersistentEntities entities, ObjectMapper mapper) {
Assert.notNull(entities, "PersistentEntities must not be null");
Assert.notNull(mapper, "ObjectMapper must not be null");
this.entities = entities;
this.mapper = mapper;
}
@Override
public Optional<String> getReadableProperty(String segment, Class<?> type) {
return getProperty(entities.getPersistentEntity(type)
.map(it -> MappedProperties.forSerialization(it, mapper))
.filter(it -> it.isReadableField(segment)), segment);
}
@Override
public Optional<String> getWritableProperty(String segment, Class<?> type) {
return getProperty(entities.getPersistentEntity(type)
.map(it -> MappedProperties.forDeserialization(it, mapper))
.filter(it -> it.isWritableField(segment)), segment);
}
private static Optional<String> getProperty(Optional<MappedProperties> properties, String segment) {
return properties.map(it -> it.getPersistentProperty(segment))
.map(PersistentProperty::getName);
}
}

View File

@@ -26,9 +26,11 @@ import java.util.Set;
import org.springframework.data.mapping.PersistentEntity;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -83,7 +85,15 @@ class MappedProperties {
// collection of ignored properties in the first place. See
// https://github.com/FasterXML/jackson-databind/issues/2531
this.ignoredPropertyNames = description.getIgnoredPropertyNames();
this.ignoredPropertyNames = new HashSet<>(description.getIgnoredPropertyNames());
JsonIgnoreProperties annotation = entity.findAnnotation(JsonIgnoreProperties.class);
if (annotation != null) {
for (String property : annotation.value()) {
ignoredPropertyNames.add(property);
}
}
for (BeanPropertyDefinition property : description.findProperties()) {
@@ -172,6 +182,7 @@ class MappedProperties {
* @param fieldName must not be empty or {@literal null}.
* @return the {@link PersistentProperty} backing the field with the field name.
*/
@Nullable
public PersistentProperty<?> getPersistentProperty(String fieldName) {
Assert.hasText(fieldName, "Field name must not be null or empty!");
@@ -229,7 +240,31 @@ class MappedProperties {
* @param name must not be {@literal null} or empty.
* @return
*/
public boolean isWritableProperty(String name) {
public boolean isWritableField(String name) {
Assert.hasText(name, "Property name must not be null or empty");
if (ignoredPropertyNames.contains(name)) {
return false;
}
PersistentProperty<?> property = fieldNameToProperty.get(name);
return property != null ? property.isWritable() : anySetterFound;
}
public boolean isReadableField(String name) {
Assert.hasText(name, "Property name must not be null or empty");
if (ignoredPropertyNames.contains(name)) {
return false;
}
return fieldNameToProperty.get(name) != null;
}
public boolean isExposedProperty(String name) {
Assert.hasText(name, "Property name must not be null or empty!");

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2022 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
*
* https://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;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.rest.webmvc.json.patch.BindContext;
import org.springframework.util.Assert;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* A {@link BindContextFactory} based on {@link PersistentEntities}.
*
* @author Oliver Drotbohm
*/
public class PersistentEntitiesBindContextFactory implements BindContextFactory {
private final PersistentEntities entities;
/**
* Creates a new {@link PersistentEntitiesBindContextFactory} for the given {@link PersistentEntities}.
*
* @param entities must not be {@literal null}.
*/
public PersistentEntitiesBindContextFactory(PersistentEntities entities) {
Assert.notNull(entities, "PersistentEntities must not be null!");
this.entities = entities;
}
@Override
public BindContext getBindContextFor(ObjectMapper mapper) {
return new JacksonBindContext(entities, mapper);
}
}

View File

@@ -42,11 +42,12 @@ class AddOperation extends PatchOperation {
/*
* (non-Javadoc)
* @see org.springframework.data.rest.webmvc.json.patch.PatchOperation#perform(java.lang.Object, java.lang.Class)
* @see org.springframework.data.rest.webmvc.json.patch.PatchOperation#perform(java.lang.Object, java.lang.Class, org.springframework.data.rest.webmvc.json.PropertyFilter)
*/
@Override
void perform(Object targetObject, Class<?> type) {
path.bindTo(type).addValue(targetObject, evaluateValueFromTarget(targetObject, type));
void perform(Object target, Class<?> type, BindContext context) {
path.bindForWrite(type, context).addValue(target, evaluateValueFromTarget(target, type, context));
}
/*
@@ -54,12 +55,12 @@ class AddOperation extends PatchOperation {
* @see org.springframework.data.rest.webmvc.json.patch.PatchOperation#evaluateValueFromTarget(java.lang.Object, java.lang.Class)
*/
@Override
protected Object evaluateValueFromTarget(Object targetObject, Class<?> entityType) {
protected Object evaluateValueFromTarget(Object targetObject, Class<?> entityType, BindContext context) {
if (!path.isAppend()) {
return super.evaluateValueFromTarget(targetObject, entityType);
return super.evaluateValueFromTarget(targetObject, entityType, context);
}
return evaluate(path.bindTo(entityType).getLeafType());
return evaluate(path.bindForWrite(entityType, context).getLeafType());
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2022 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
*
* https://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.Optional;
/**
* Contextual mapping for he translation of JSON Pointer segments into property references on persistent types.
*
* @author Oliver Drotbohm
*/
public interface BindContext {
/**
* Returns the name of the writable property for the given JSON pointer segment.
*
* @param segment must not be {@literal null} or empty.
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
*/
Optional<String> getWritableProperty(String segment, Class<?> type);
/**
* Return the name of the readable property for the given JSON pointer segment.
*
* @param segment must not be {@literal null} or empty.
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
*/
Optional<String> getReadableProperty(String segment, Class<?> type);
}

View File

@@ -82,7 +82,9 @@ class CopyOperation extends PatchOperation {
* @see org.springframework.data.rest.webmvc.json.patch.PatchOperation#perform(java.lang.Object, java.lang.Class)
*/
@Override
void perform(Object target, Class<?> type) {
path.bindTo(type).copyFrom(from, target);
void perform(Object target, Class<?> type, BindContext context) {
path.bindForWrite(type, context) //
.copyFrom(from, target, context);
}
}

View File

@@ -36,12 +36,14 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
public class JsonPatchPatchConverter implements PatchConverter<JsonNode> {
private final ObjectMapper mapper;
private final BindContext context;
public JsonPatchPatchConverter(ObjectMapper mapper) {
public JsonPatchPatchConverter(ObjectMapper mapper, BindContext context) {
Assert.notNull(mapper, "ObjectMapper must not be null!");
this.mapper = mapper;
this.context = context;
}
/**
@@ -87,7 +89,7 @@ public class JsonPatchPatchConverter implements PatchConverter<JsonNode> {
}
}
return new Patch(ops);
return new Patch(ops, context);
}
private Object valueFromJsonNode(String path, JsonNode valueNode) {

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2022 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
*
* https://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.RequiredArgsConstructor;
import java.util.Optional;
import java.util.function.BiFunction;
import org.springframework.data.mapping.PropertyPath;
import org.springframework.data.mapping.PropertyReferenceException;
import org.springframework.data.util.ClassTypeInformation;
import org.springframework.data.util.TypeInformation;
import org.springframework.util.StringUtils;
/**
* @author Oliver Drotbohm
*/
@RequiredArgsConstructor
class JsonPointerMapping {
private final BiFunction<String, Class<?>, Optional<String>> reader, writer;
public JsonPointerMapping(BindContext context) {
this.reader = context::getReadableProperty;
this.writer = context::getWritableProperty;
}
/**
* Maps the given JSON Pointer to the given type to ultimately read the attribute pointed to.
*
* @param pointer must not be {@literal null}.
* @param type must not be {@literal null}.
* @return a JSON Pointer with the segments translated into the matching property references.
*/
public String forRead(String pointer, Class<?> type) {
return verify(pointer, type, reader, "readable");
}
/**
* Maps the given JSON Pointer to the given type to ultimately write the attribute pointed to.
*
* @param pointer must not be {@literal null}.
* @param type must not be {@literal null}.
* @return a JSON Pointer with the segments translated into the matching property references.
*/
public String forWrite(String pointer, Class<?> type) {
return verify(pointer, type, writer, "writable");
}
private String verify(String pointer, Class<?> type, BiFunction<String, Class<?>, Optional<String>> filter,
String qualifier) {
String[] strings = pointer.split("/");
if (strings.length == 0) {
return pointer;
}
PropertyPath base = null;
StringBuilder result = new StringBuilder();
TypeInformation<?> currentType = ClassTypeInformation.from(type);
for (int i = 0; i < strings.length; i++) {
String segment = strings[i];
if (!StringUtils.hasText(segment)) {
continue;
}
if (currentType != null && currentType.isMap()) {
result.append("/").append(segment);
currentType = currentType.getActualType();
continue;
}
if (segment.equals("-") || segment.matches("\\d+")) {
result.append("/").append(segment);
currentType = currentType.getActualType();
continue;
}
TypeInformation<?> rejectType = currentType;
// Use given filter for final segment, reader otherwise
String property = (i == strings.length - 1 ? filter : reader) //
.apply(segment, currentType.getType()) //
.orElseThrow(() -> reject(segment, rejectType, pointer, qualifier));
try {
base = base == null ? PropertyPath.from(property, type) : base.nested(segment);
} catch (PropertyReferenceException o_O) {
throw reject(segment, rejectType, pointer, qualifier);
}
currentType = base.getTypeInformation();
result.append("/").append(property);
}
return result.toString();
}
private static PatchException reject(String segment, TypeInformation<?> type, String pointer, String qualifier) {
return new PatchException(
String.format("Couldn't find %s property for pointer segment %s on %s in %s", qualifier, segment,
type.getType(), pointer));
}
}

View File

@@ -75,7 +75,7 @@ class MoveOperation extends PatchOperation {
* @see org.springframework.data.rest.webmvc.json.patch.PatchOperation#perform(java.lang.Object, java.lang.Class)
*/
@Override
void perform(Object target, Class<?> type) {
path.bindTo(type).moveFrom(from, target);
void perform(Object target, Class<?> type, BindContext context) {
path.bindForWrite(type, context).moveFrom(from, target, context);
}
}

View File

@@ -35,9 +35,12 @@ import org.springframework.data.util.Streamable;
public class Patch implements Streamable<PatchOperation> {
private final List<PatchOperation> operations;
private final BindContext context;
public Patch(List<PatchOperation> operations, BindContext context) {
public Patch(List<PatchOperation> operations) {
this.operations = operations;
this.context = context;
}
/**
@@ -60,7 +63,7 @@ public class Patch implements Streamable<PatchOperation> {
public <T> T apply(T in, Class<T> type) throws PatchException {
for (PatchOperation operation : operations) {
operation.perform(in, type);
operation.perform(in, type, context);
}
return in;
@@ -79,7 +82,7 @@ public class Patch implements Streamable<PatchOperation> {
public <T> List<T> apply(List<T> in, Class<T> type) throws PatchException {
for (PatchOperation operation : operations) {
operation.perform(in, type);
operation.perform(in, type, context);
}
return in;

View File

@@ -59,8 +59,8 @@ public abstract class PatchOperation {
* @return the result of late-value evaluation if the value is a {@link LateObjectEvaluator}; the value itself
* otherwise.
*/
protected Object evaluateValueFromTarget(Object targetObject, Class<?> entityType) {
return evaluate(path.bindTo(entityType).getType(targetObject));
protected Object evaluateValueFromTarget(Object targetObject, Class<?> entityType, BindContext context) {
return evaluate(path.bindForRead(entityType, context).getType(targetObject));
}
protected final Object evaluate(Class<?> type) {
@@ -73,5 +73,5 @@ public abstract class PatchOperation {
* @param target the target of the operation, must not be {@literal null}.
* @param type must not be {@literal null}.
*/
abstract void perform(Object target, Class<?> type);
abstract void perform(Object target, Class<?> type, BindContext context);
}

View File

@@ -44,7 +44,7 @@ class RemoveOperation extends PatchOperation {
* @see org.springframework.data.rest.webmvc.json.patch.PatchOperation#perform(java.lang.Object, java.lang.Class)
*/
@Override
void perform(Object target, Class<?> type) {
path.bindTo(type).removeFrom(target);
void perform(Object target, Class<?> type, BindContext context) {
path.bindForWrite(type, context).removeFrom(target);
}
}

View File

@@ -39,7 +39,6 @@ class ReplaceOperation extends PatchOperation {
return new ReplaceOperationBuilder(path);
}
static class ReplaceOperationBuilder {
private final String path;
@@ -58,7 +57,7 @@ class ReplaceOperation extends PatchOperation {
* @see org.springframework.data.rest.webmvc.json.patch.PatchOperation#perform(java.lang.Object, java.lang.Class)
*/
@Override
void perform(Object target, Class<?> type) {
path.bindTo(type).setValue(target, evaluateValueFromTarget(target, type));
void perform(Object target, Class<?> type, BindContext context) {
path.bindForWrite(type, context).setValue(target, evaluateValueFromTarget(target, type, context));
}
}

View File

@@ -42,10 +42,14 @@ import org.springframework.util.CollectionUtils;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Value object to represent a SpEL-backed patch path.
*
* @author Oliver Gierke
* @author Mark Paluch
* @author Greg Turnquist
*/
class SpelPath {
@@ -53,6 +57,8 @@ class SpelPath {
private static final String APPEND_CHARACTER = "-";
private static final Map<String, UntypedSpelPath> UNTYPED_PATHS = new ConcurrentReferenceHashMap<>(32);
private static final ObjectMapper objectMapper = new ObjectMapper();
protected final String path;
private SpelPath(String path) {
@@ -62,10 +68,6 @@ class SpelPath {
this.path = path;
}
public String getPath() {
return this.path;
}
/**
* Returns a {@link UntypedSpelPath} for the given source.
*
@@ -76,16 +78,6 @@ class SpelPath {
return UNTYPED_PATHS.computeIfAbsent(source, UntypedSpelPath::new);
}
/**
* Returns a {@link TypedSpelPath} for the given source and type.
*
* @param source must not be {@literal null}.
* @return
*/
public static TypedSpelPath typed(String source, Class<?> type) {
return untyped(source).bindTo(type);
}
/**
* Returns whether the current path represents an append path, i.e. is supposed to append to collection.
*
@@ -135,55 +127,61 @@ class SpelPath {
static class UntypedSpelPath extends SpelPath {
private static final Map<CacheKey, TypedSpelPath> READ_PATHS = new ConcurrentReferenceHashMap<>(256);
private static final Map<CacheKey, TypedSpelPath> WRITE_PATHS = new ConcurrentReferenceHashMap<>(256);
private UntypedSpelPath(String path) {
super(path);
}
public ReadingOperations bindForRead(Class<?> type, BindContext context) {
Assert.notNull(path, "Path must not be null");
Assert.notNull(type, "Type must not be null");
return READ_PATHS.computeIfAbsent(CacheKey.of(type, this, context),
key -> {
String mapped = new JsonPointerMapping(context).forRead(key.path.path, type);
return new TypedSpelPath(mapped, key.type);
});
}
/**
* Returns a {@link TypedSpelPath} binding the expression to the given type.
*
* @param type must not be {@literal null}.
* @return
*/
public TypedSpelPath bindTo(Class<?> type) {
public WritingOperations bindForWrite(Class<?> type, BindContext context) {
Assert.notNull(type, "Type must not be null!");
Assert.notNull(context, "BindContext must not be null");
Assert.notNull(type, "Type must not be null");
return TypedSpelPath.of(this, type);
return WRITE_PATHS.computeIfAbsent(CacheKey.of(type, this, context),
key -> {
String mapped = new JsonPointerMapping(context).forWrite(key.path.path, type);
return new TypedSpelPath(mapped, key.type);
});
}
}
/**
* A {@link SpelPath} that has typing information tied to it.
*
* @author Oliver Gierke
*/
static class TypedSpelPath extends SpelPath {
private static final String INVALID_PATH_REFERENCE = "Invalid path reference %s on type %s!";
private static final String INVALID_COLLECTION_INDEX = "Invalid collection index %s for collection of size %s. Use '…/-' or the collection's actual size as index to append to it!";
private static final Map<CacheKey, TypedSpelPath> TYPED_PATHS = new ConcurrentReferenceHashMap<>(32);
private static final EvaluationContext CONTEXT = SimpleEvaluationContext.forReadWriteDataBinding().build();
private final Expression expression;
private final Class<?> type;
private static final class CacheKey {
private final Class<?> type;
private final UntypedSpelPath path;
private final BindContext context;
private CacheKey(Class<?> type, UntypedSpelPath path) {
private CacheKey(Class<?> type, UntypedSpelPath path, BindContext context) {
Assert.notNull(type, "Type must not be null!");
Assert.notNull(path, "UntypedSpelPath must not be null!");
this.type = type;
this.path = path;
this.context = context;
}
public static CacheKey of(final Class<?> type, final UntypedSpelPath path) {
return new CacheKey(type, path);
public static CacheKey of(Class<?> type, UntypedSpelPath path, BindContext context) {
return new CacheKey(type, path, context);
}
/*
@@ -204,7 +202,8 @@ class SpelPath {
CacheKey that = (CacheKey) o;
return Objects.equals(type, that.type) //
&& Objects.equals(path, that.path);
&& Objects.equals(path, that.path) //
&& Objects.equals(context, that.context);
}
/*
@@ -213,40 +212,58 @@ class SpelPath {
*/
@Override
public int hashCode() {
return Objects.hash(type, path);
}
/*
* (non-Javadoc)
* @see java.lang.Object#toString()
*/
@Override
public java.lang.String toString() {
return "SpelPath.TypedSpelPath.CacheKey(type=" + type + ", path=" + path + ")";
return Objects.hash(type, path, context);
}
}
}
private TypedSpelPath(UntypedSpelPath path, Class<?> type) {
interface CommonOperations {
super(path.path);
String getExpressionString();
}
interface ReadingOperations extends CommonOperations {
<T> T getValue(Object target);
Class<?> getType(Object root);
}
interface WritingOperations extends CommonOperations {
Class<?> getLeafType();
Object removeFrom(Object target);
void addValue(Object target, Object value);
void setValue(Object target, @Nullable Object value);
void copyFrom(UntypedSpelPath path, Object source, BindContext context);
void moveFrom(UntypedSpelPath path, Object source, BindContext context);
}
/**
* A {@link SpelPath} that has typing information tied to it.
*
* @author Oliver Gierke
*/
static class TypedSpelPath extends SpelPath implements ReadingOperations, WritingOperations {
private static final String INVALID_PATH_REFERENCE = "Invalid path reference %s on type %s";
private static final String INVALID_COLLECTION_INDEX = "Invalid collection index %s for collection of size %s; Use '…/-' or the collection's actual size as index to append to it";
private static final EvaluationContext CONTEXT = SimpleEvaluationContext.forReadWriteDataBinding().build();
private final Expression expression;
private final Class<?> type;
private TypedSpelPath(String path, Class<?> type) {
super(path);
this.type = type;
this.expression = toSpel(path.path, type);
}
/**
* Returns the {@link TypedSpelPath} for the given {@link SpelPath} and type.
*
* @param path must not be {@literal null}.
* @param type must not be {@literal null}.
* @return
*/
public static TypedSpelPath of(UntypedSpelPath path, Class<?> type) {
Assert.notNull(path, "Path must not be null!");
Assert.notNull(type, "Type must not be null!");
return TYPED_PATHS.computeIfAbsent(CacheKey.of(type, path), key -> new TypedSpelPath(key.path, key.type));
this.expression = toSpel(path, type);
}
/**
@@ -334,12 +351,12 @@ class SpelPath {
* @param source the source object to look the value up from, must not be {@literal null}.
* @return
*/
public void copyFrom(UntypedSpelPath path, Object source) {
public void copyFrom(UntypedSpelPath path, Object source, BindContext context) {
Assert.notNull(path, "Source path must not be null!");
Assert.notNull(source, "Source value must not be null!");
addValue(source, path.bindTo(type).getValue(source));
addValue(source, path.bindForRead(type, context).getValue(source));
}
/**
@@ -350,12 +367,15 @@ class SpelPath {
* @param source the source object to look the value up from, must not be {@literal null}.
* @return
*/
public void moveFrom(UntypedSpelPath path, Object source) {
public void moveFrom(UntypedSpelPath path, Object source, BindContext context) {
Assert.notNull(path, "Source path must not be null!");
Assert.notNull(source, "Source value must not be null!");
addValue(source, path.bindTo(type).removeFrom(source));
// Verify we are allowed to read the source
path.bindForRead(type, context);
addValue(source, path.bindForWrite(type, context).removeFrom(source));
}
/**
@@ -377,7 +397,7 @@ class SpelPath {
setValue(target, null);
return value;
} catch (SpelEvaluationException o_O) {
throw new PatchException("Path '" + path + "' is not nullable.", o_O);
throw new PatchException("Path '" + path + "' is not nullable", o_O);
}
} else {
@@ -440,10 +460,7 @@ class SpelPath {
}
private TypedSpelPath getParent() {
return SpelPath //
.untyped(path.substring(0, path.lastIndexOf('/'))) //
.bindTo(type);
return new TypedSpelPath(path.substring(0, path.lastIndexOf('/')), type);
}
private TypeDescriptor getTypeDescriptor(Object target) {
@@ -658,6 +675,8 @@ class SpelPath {
? spelSegment.concat(".") //
: spelSegment;
Class<?> currentType = basePath == null ? type : basePath.getLeafType();
try {
PropertyPath path = basePath == null //

View File

@@ -71,10 +71,10 @@ class TestOperation extends PatchOperation {
* @see org.springframework.data.rest.webmvc.json.patch.PatchOperation#perform(java.lang.Object, java.lang.Class)
*/
@Override
void perform(Object target, Class<?> type) {
void perform(Object target, Class<?> type, BindContext context) {
Object expected = normalizeIfNumber(evaluateValueFromTarget(target, type));
Object actual = normalizeIfNumber(path.bindTo(type).getValue(target));
Object expected = normalizeIfNumber(evaluateValueFromTarget(target, type, context));
Object actual = normalizeIfNumber(path.bindForRead(type, context).getValue(target));
if (!ObjectUtils.nullSafeEquals(expected, actual)) {
throw new PatchException("Test against path '" + path + "' failed.");

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2013-2022 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
*
* https://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.
*/
@org.springframework.lang.NonNullApi
package org.springframework.data.rest.webmvc.json.patch;