Skip to content

Commit 2ad081f

Browse files
committed
Improve JSON Patch implementation.
Refactor JSON Patch application implementation to improve the property detection for which values are supposed to be set. Fixes #2177.
1 parent 5a4923a commit 2ad081f

35 files changed

+888
-192
lines changed

spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/webmvc/config/JsonPatchHandlerUnitTests.java

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import static org.mockito.Mockito.*;
2020
import static org.springframework.data.rest.tests.mongodb.TestUtils.*;
2121

22+
import lombok.Data;
23+
2224
import java.util.ArrayList;
2325
import java.util.Arrays;
2426
import java.util.Collections;
@@ -36,21 +38,28 @@
3638
import org.springframework.data.rest.tests.mongodb.Address;
3739
import org.springframework.data.rest.tests.mongodb.User;
3840
import org.springframework.data.rest.webmvc.RestMediaTypes;
41+
import org.springframework.data.rest.webmvc.json.BindContextFactory;
3942
import org.springframework.data.rest.webmvc.json.DomainObjectReader;
43+
import org.springframework.data.rest.webmvc.json.PersistentEntitiesBindContextFactory;
44+
import org.springframework.data.rest.webmvc.json.patch.PatchException;
4045
import org.springframework.data.rest.webmvc.mapping.Associations;
4146
import org.springframework.http.converter.HttpMessageNotReadableException;
4247

48+
import com.fasterxml.jackson.annotation.JsonIgnore;
49+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4350
import com.fasterxml.jackson.databind.ObjectMapper;
4451

4552
/**
4653
* Unit tests for {@link JsonPatchHandler}.
4754
*
4855
* @author Oliver Gierke
56+
* @author Mark Paluch
4957
*/
5058
@ExtendWith(MockitoExtension.class)
5159
class JsonPatchHandlerUnitTests {
5260

5361
JsonPatchHandler handler;
62+
ObjectMapper mapper = new ObjectMapper();
5463
User user;
5564

5665
@Mock ResourceMappings mappings;
@@ -63,12 +72,13 @@ void setUp() {
6372
MongoMappingContext context = new MongoMappingContext();
6473
context.setSimpleTypeHolder(conversions.getSimpleTypeHolder());
6574
context.getPersistentEntity(User.class);
75+
context.getPersistentEntity(WithIgnoredProperties.class);
6676

6777
PersistentEntities entities = new PersistentEntities(Arrays.asList(context));
68-
6978
Associations associations = new Associations(mappings, mock(RepositoryRestConfiguration.class));
79+
BindContextFactory factory = new PersistentEntitiesBindContextFactory(entities);
7080

71-
this.handler = new JsonPatchHandler(new ObjectMapper(), new DomainObjectReader(entities, associations));
81+
this.handler = new JsonPatchHandler(factory, new DomainObjectReader(entities, associations));
7282

7383
Address address = new Address();
7484
address.street = "Foo";
@@ -86,7 +96,7 @@ void appliesRemoveOperationCorrectly() throws Exception {
8696
String input = "[{ \"op\": \"replace\", \"path\": \"/address/zipCode\", \"value\": \"ZIP\" },"
8797
+ "{ \"op\": \"remove\", \"path\": \"/lastname\" }]";
8898

89-
User result = handler.applyPatch(asStream(input), user);
99+
User result = handler.applyPatch(asStream(input), user, mapper);
90100

91101
assertThat(result.lastname).isNull();
92102
assertThat(result.address.zipCode).isEqualTo("ZIP");
@@ -97,7 +107,7 @@ void appliesMergePatchCorrectly() throws Exception {
97107

98108
String input = "{ \"address\" : { \"zipCode\" : \"ZIP\"}, \"lastname\" : null }";
99109

100-
User result = handler.applyMergePatch(asStream(input), user);
110+
User result = handler.applyMergePatch(asStream(input), user, mapper);
101111

102112
assertThat(result.lastname).isNull();
103113
assertThat(result.address.zipCode).isEqualTo("ZIP");
@@ -119,7 +129,7 @@ void removesArrayItemCorrectly() throws Exception {
119129

120130
String input = "[{ \"op\": \"remove\", \"path\": \"/colleagues/0\" }]";
121131

122-
handler.applyPatch(asStream(input), user);
132+
handler.applyPatch(asStream(input), user, mapper);
123133

124134
assertThat(user.colleagues).hasSize(1);
125135
assertThat(user.colleagues.get(0).firstname).isEqualTo(christoph.firstname);
@@ -129,7 +139,49 @@ void removesArrayItemCorrectly() throws Exception {
129139
void hintsToMediaTypeIfBodyCantBeRead() throws Exception {
130140

131141
assertThatExceptionOfType(HttpMessageNotReadableException.class)
132-
.isThrownBy(() -> handler.applyPatch(asStream("{ \"foo\" : \"bar\" }"), new User()))
142+
.isThrownBy(() -> handler.applyPatch(asStream("{ \"foo\" : \"bar\" }"), new User(), mapper))
133143
.withMessageContaining(RestMediaTypes.JSON_PATCH_JSON.toString());
134144
}
145+
146+
@Test
147+
void skipsReplaceConditionally() throws Exception {
148+
149+
WithIgnoredProperties object = new WithIgnoredProperties();
150+
assertThatExceptionOfType(PatchException.class).isThrownBy(() -> {
151+
handler.applyPatch(asStream("[{ \"op\": \"replace\", \"path\": \"/password\", \"value\": \"hello\" }]"), object,
152+
mapper);
153+
});
154+
155+
WithIgnoredProperties result = handler
156+
.applyPatch(asStream("[{ \"op\": \"replace\", \"path\": \"/name\", \"value\": \"hello\" }]"), object, mapper);
157+
158+
assertThat(result.name).isEqualTo("hello");
159+
}
160+
161+
@Test
162+
void skipsCopyConditionally() throws Exception {
163+
164+
WithIgnoredProperties object = new WithIgnoredProperties();
165+
object.setName("hello");
166+
167+
assertThatExceptionOfType(PatchException.class).isThrownBy(() -> {
168+
handler.applyPatch(asStream("[{ \"op\": \"copy\", \"path\": \"/password\", \"from\": \"/name\" }]"), object,
169+
mapper);
170+
});
171+
172+
WithIgnoredProperties result = handler
173+
.applyPatch(asStream("[{ \"op\": \"copy\", \"path\": \"/lastname\", \"from\": \"/name\" }]"), object, mapper);
174+
175+
assertThat(result.lastname).isEqualTo("hello");
176+
}
177+
178+
@JsonIgnoreProperties("password")
179+
@Data
180+
static class WithIgnoredProperties {
181+
182+
String name, lastname, password;
183+
184+
@JsonIgnore String ssn;
185+
186+
}
135187
}

spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/JsonPatchHandler.java

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@
1919

2020
import org.springframework.data.rest.webmvc.IncomingRequest;
2121
import org.springframework.data.rest.webmvc.RestMediaTypes;
22+
import org.springframework.data.rest.webmvc.json.BindContextFactory;
2223
import org.springframework.data.rest.webmvc.json.DomainObjectReader;
24+
import org.springframework.data.rest.webmvc.json.patch.BindContext;
2325
import org.springframework.data.rest.webmvc.json.patch.JsonPatchPatchConverter;
2426
import org.springframework.data.rest.webmvc.json.patch.Patch;
2527
import org.springframework.data.rest.webmvc.util.InputStreamHttpInputMessage;
2628
import org.springframework.http.converter.HttpMessageNotReadableException;
2729
import org.springframework.util.Assert;
2830

29-
import com.fasterxml.jackson.annotation.JsonInclude.Include;
3031
import com.fasterxml.jackson.databind.ObjectMapper;
3132
import com.fasterxml.jackson.databind.node.ObjectNode;
3233

@@ -44,26 +45,23 @@
4445
*/
4546
class JsonPatchHandler {
4647

47-
private final ObjectMapper mapper;
48-
private final ObjectMapper sourceMapper;
48+
private final BindContextFactory factory;
4949
private final DomainObjectReader reader;
5050

5151
/**
52-
* Creates a new {@link JsonPatchHandler} with the given {@link ObjectMapper} and {@link DomainObjectReader}.
52+
* Creates a new {@link JsonPatchHandler} with the given {@link JacksonBindContextFactory} and
53+
* {@link DomainObjectReader}.
5354
*
54-
* @param mapper must not be {@literal null}.
55+
* @param factory must not be {@literal null}.
5556
* @param reader must not be {@literal null}.
5657
*/
57-
public JsonPatchHandler(ObjectMapper mapper, DomainObjectReader reader) {
58+
public JsonPatchHandler(BindContextFactory factory, DomainObjectReader reader) {
5859

59-
Assert.notNull(mapper, "ObjectMapper must not be null!");
60-
Assert.notNull(reader, "DomainObjectReader must not be null!");
60+
Assert.notNull(factory, "BindContextFactory must not be null");
61+
Assert.notNull(reader, "DomainObjectReader must not be null");
6162

62-
this.mapper = mapper;
63+
this.factory = factory;
6364
this.reader = reader;
64-
65-
this.sourceMapper = mapper.copy();
66-
this.sourceMapper.setSerializationInclusion(Include.NON_NULL);
6765
}
6866

6967
/**
@@ -74,43 +72,48 @@ public JsonPatchHandler(ObjectMapper mapper, DomainObjectReader reader) {
7472
* @return
7573
* @throws Exception
7674
*/
77-
public <T> T apply(IncomingRequest request, T target) throws Exception {
75+
public <T> T apply(IncomingRequest request, T target, ObjectMapper mapper) throws Exception {
7876

7977
Assert.notNull(request, "Request must not be null!");
8078
Assert.isTrue(request.isPatchRequest(), "Cannot handle non-PATCH request!");
8179
Assert.notNull(target, "Target must not be null!");
8280

8381
if (request.isJsonPatchRequest()) {
84-
return applyPatch(request.getBody(), target);
82+
return applyPatch(request.getBody(), target, mapper);
8583
} else {
86-
return applyMergePatch(request.getBody(), target);
84+
return applyMergePatch(request.getBody(), target, mapper);
8785
}
8886
}
8987

9088
@SuppressWarnings("unchecked")
91-
<T> T applyPatch(InputStream source, T target) throws Exception {
92-
return getPatchOperations(source).apply(target, (Class<T>) target.getClass());
89+
<T> T applyPatch(InputStream source, T target, ObjectMapper mapper) throws Exception {
90+
91+
Class<?> type = target.getClass();
92+
BindContext context = factory.getBindContextFor(mapper);
93+
94+
return getPatchOperations(source, mapper, context).apply(target, (Class<T>) target.getClass());
9395
}
9496

95-
<T> T applyMergePatch(InputStream source, T existingObject) throws Exception {
97+
<T> T applyMergePatch(InputStream source, T existingObject, ObjectMapper mapper) throws Exception {
9698
return reader.read(source, existingObject, mapper);
9799
}
98100

99-
<T> T applyPut(ObjectNode source, T existingObject) throws Exception {
101+
<T> T applyPut(ObjectNode source, T existingObject, ObjectMapper mapper) throws Exception {
100102
return reader.readPut(source, existingObject, mapper);
101103
}
102104

103105
/**
104106
* Returns all {@link JsonPatchOperation}s to be applied.
105107
*
106108
* @param source must not be {@literal null}.
109+
* @param mapper must not be {@literal null}.
107110
* @return
108111
* @throws HttpMessageNotReadableException in case the payload can't be read.
109112
*/
110-
private Patch getPatchOperations(InputStream source) {
113+
private Patch getPatchOperations(InputStream source, ObjectMapper mapper, BindContext context) {
111114

112115
try {
113-
return new JsonPatchPatchConverter(mapper).convert(mapper.readTree(source));
116+
return new JsonPatchPatchConverter(mapper, context).convert(mapper.readTree(source));
114117
} catch (Exception o_O) {
115118
throw new HttpMessageNotReadableException(
116119
String.format("Could not read PATCH operations! Expected %s!", RestMediaTypes.JSON_PATCH_JSON), o_O,

spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.springframework.data.rest.webmvc.PersistentEntityResource.Builder;
3737
import org.springframework.data.rest.webmvc.ResourceNotFoundException;
3838
import org.springframework.data.rest.webmvc.RootResourceInformation;
39+
import org.springframework.data.rest.webmvc.json.BindContextFactory;
3940
import org.springframework.data.rest.webmvc.json.DomainObjectReader;
4041
import org.springframework.data.rest.webmvc.support.BackendIdHandlerMethodArgumentResolver;
4142
import org.springframework.http.MediaType;
@@ -69,15 +70,15 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha
6970
private final List<HttpMessageConverter<?>> messageConverters;
7071
private final RootResourceInformationHandlerMethodArgumentResolver resourceInformationResolver;
7172
private final BackendIdHandlerMethodArgumentResolver idResolver;
72-
private final DomainObjectReader reader;
7373
private final PluginRegistry<EntityLookup<?>, Class<?>> lookups;
7474
private final ConversionService conversionService = new DefaultConversionService();
75+
private final JsonPatchHandler jsonPatchHandler;
7576

7677
public PersistentEntityResourceHandlerMethodArgumentResolver(
7778
List<HttpMessageConverter<?>> messageConverters,
7879
RootResourceInformationHandlerMethodArgumentResolver resourceInformationResolver,
7980
BackendIdHandlerMethodArgumentResolver idResolver, DomainObjectReader reader,
80-
PluginRegistry<EntityLookup<?>, Class<?>> lookups) {
81+
PluginRegistry<EntityLookup<?>, Class<?>> lookups, BindContextFactory factory) {
8182

8283
Assert.notNull(messageConverters, "HttpMessageConverters must not be null!");
8384
Assert.notNull(resourceInformationResolver, "RootResourceInformation resolver must not be null!");
@@ -88,8 +89,8 @@ public PersistentEntityResourceHandlerMethodArgumentResolver(
8889
this.messageConverters = messageConverters;
8990
this.resourceInformationResolver = resourceInformationResolver;
9091
this.idResolver = idResolver;
91-
this.reader = reader;
9292
this.lookups = lookups;
93+
this.jsonPatchHandler = new JsonPatchHandler(mapper -> factory.getBindContextFor(mapper), reader);
9394
}
9495

9596
/*
@@ -210,8 +211,7 @@ private Object readPatch(IncomingRequest request, ObjectMapper mapper, Object ex
210211

211212
try {
212213

213-
JsonPatchHandler handler = new JsonPatchHandler(mapper, reader);
214-
return handler.apply(request, existingObject);
214+
return jsonPatchHandler.apply(request, existingObject, mapper);
215215

216216
} catch (Exception o_O) {
217217

@@ -228,10 +228,9 @@ private Object readPutForUpdate(IncomingRequest request, ObjectMapper mapper, Ob
228228

229229
try {
230230

231-
JsonPatchHandler handler = new JsonPatchHandler(mapper, reader);
232231
JsonNode jsonNode = mapper.readTree(request.getBody());
233232

234-
return handler.applyPut((ObjectNode) jsonNode, existingObject);
233+
return jsonPatchHandler.applyPut((ObjectNode) jsonNode, existingObject, mapper);
235234

236235
} catch (Exception o_O) {
237236
throw new HttpMessageNotReadableException(String.format(ERROR_MESSAGE, existingObject.getClass()), o_O,

spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -495,13 +495,15 @@ public PersistentEntityResourceHandlerMethodArgumentResolver persistentEntityArg
495495
@Qualifier("defaultMessageConverters") List<HttpMessageConverter<?>> defaultMessageConverters,
496496
RootResourceInformationHandlerMethodArgumentResolver repoRequestArgumentResolver, Associations associationLinks,
497497
BackendIdHandlerMethodArgumentResolver backendIdHandlerMethodArgumentResolver,
498-
PersistentEntities persistentEntities) {
498+
PersistentEntities entities) {
499499

500500
PluginRegistry<EntityLookup<?>, Class<?>> lookups = PluginRegistry.of(getEntityLookups());
501+
DomainObjectReader reader = new DomainObjectReader(entities, associationLinks);
502+
BindContextFactory factory = new PersistentEntitiesBindContextFactory(entities);
501503

502504
return new PersistentEntityResourceHandlerMethodArgumentResolver(defaultMessageConverters,
503505
repoRequestArgumentResolver, backendIdHandlerMethodArgumentResolver,
504-
new DomainObjectReader(persistentEntities, associationLinks), lookups);
506+
reader, lookups, factory);
505507
}
506508

507509
/**
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright 2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.rest.webmvc.json;
17+
18+
import org.springframework.data.rest.webmvc.json.patch.BindContext;
19+
20+
import com.fasterxml.jackson.databind.ObjectMapper;
21+
22+
/**
23+
* Factory to create {@link BindContext} instances.
24+
*
25+
* @author Oliver Drotbohm
26+
*/
27+
public interface BindContextFactory {
28+
29+
/**
30+
* Creates a {@link BindContext} for the given {@link ObjectMapper}.
31+
*
32+
* @param mapper must not be {@literal null}.
33+
* @return will never be {@literal null}.
34+
*/
35+
BindContext getBindContextFor(ObjectMapper mapper);
36+
}

spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ <T> T doMerge(ObjectNode root, T target, ObjectMapper mapper) throws Exception {
246246
JsonNode child = entry.getValue();
247247
String fieldName = entry.getKey();
248248

249-
if (!mappedProperties.isWritableProperty(fieldName)) {
249+
if (!mappedProperties.isWritableField(fieldName)) {
250250

251251
i.remove();
252252
continue;

0 commit comments

Comments
 (0)