Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-parent</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<packaging>pom</packaging>

<name>Spring Data REST</name>
Expand Down
2 changes: 1 addition & 1 deletion spring-data-rest-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-parent</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion spring-data-rest-distribution/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-parent</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion spring-data-rest-hal-explorer/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-parent</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
</parent>

<artifactId>spring-data-rest-hal-explorer</artifactId>
Expand Down
2 changes: 1 addition & 1 deletion spring-data-rest-tests/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-parent</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
4 changes: 2 additions & 2 deletions spring-data-rest-tests/spring-data-rest-tests-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-tests</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand All @@ -21,7 +21,7 @@
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-webmvc</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
</dependency>

</dependencies>
Expand Down
4 changes: 2 additions & 2 deletions spring-data-rest-tests/spring-data-rest-tests-jpa/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-tests</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand All @@ -21,7 +21,7 @@
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-tests-core</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<type>test-jar</type>
</dependency>

Expand Down
4 changes: 2 additions & 2 deletions spring-data-rest-tests/spring-data-rest-tests-mongodb/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-tests</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down Expand Up @@ -33,7 +33,7 @@
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-tests-core</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<type>test-jar</type>
</dependency>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-tests</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand All @@ -22,7 +22,7 @@
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-tests-core</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<type>test-jar</type>
</dependency>

Expand Down
2 changes: 1 addition & 1 deletion spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-tests</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
</parent>
<name>Spring Data REST Tests - Shop</name>
<artifactId>spring-data-rest-tests-shop</artifactId>
Expand Down
2 changes: 1 addition & 1 deletion spring-data-rest-webmvc/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-parent</artifactId>
<version>5.2.0-SNAPSHOT</version>
<version>5.2.0-1726-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* 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.
*
* Modifications copyright (C) 2026 Steve Rutherford
*/
package org.springframework.data.rest.webmvc.config;

Expand All @@ -22,7 +24,9 @@

import java.io.IOException;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.jspecify.annotations.Nullable;
Expand All @@ -40,8 +44,10 @@
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.mapping.context.PersistentEntities;
import org.springframework.data.rest.webmvc.json.BindContextFactory;
import org.springframework.data.rest.webmvc.json.DomainObjectReader;
import org.springframework.data.rest.webmvc.json.MappedJacksonProperties;
import org.springframework.data.rest.webmvc.support.BackendIdHandlerMethodArgumentResolver;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.http.MediaType;
Expand All @@ -63,6 +69,7 @@
* @author Jon Brisbin
* @author Oliver Gierke
* @author Mark Paluch
* @author Steve Rutherford
*/
public class PersistentEntityResourceHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {

Expand All @@ -75,24 +82,38 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha
private final PluginRegistry<EntityLookup<?>, Class<?>> lookups;
private final ConversionService conversionService = new DefaultConversionService();
private final JsonPatchHandler jsonPatchHandler;
private final PersistentEntities persistentEntities;

public PersistentEntityResourceHandlerMethodArgumentResolver(
List<HttpMessageConverter<?>> messageConverters,
RootResourceInformationHandlerMethodArgumentResolver resourceInformationResolver,
BackendIdHandlerMethodArgumentResolver idResolver, DomainObjectReader reader,
PluginRegistry<EntityLookup<?>, Class<?>> lookups, BindContextFactory factory) {

this(messageConverters, resourceInformationResolver, idResolver, reader, lookups, factory,
PersistentEntities.of());
}

public PersistentEntityResourceHandlerMethodArgumentResolver(
List<HttpMessageConverter<?>> messageConverters,
RootResourceInformationHandlerMethodArgumentResolver resourceInformationResolver,
BackendIdHandlerMethodArgumentResolver idResolver, DomainObjectReader reader,
PluginRegistry<EntityLookup<?>, Class<?>> lookups, BindContextFactory factory,
PersistentEntities persistentEntities) {

Assert.notNull(messageConverters, "HttpMessageConverters must not be null");
Assert.notNull(resourceInformationResolver, "RootResourceInformation resolver must not be null");
Assert.notNull(idResolver, "IdResolver must not be null");
Assert.notNull(reader, "DomainObjectReader must not be null");
Assert.notNull(lookups, "EntityLookups must not be null");
Assert.notNull(persistentEntities, "PersistentEntities must not be null");

this.messageConverters = messageConverters;
this.resourceInformationResolver = resourceInformationResolver;
this.idResolver = idResolver;
this.lookups = lookups;
this.jsonPatchHandler = new JsonPatchHandler(mapper -> factory.getBindContextFor(mapper), reader);
this.persistentEntities = persistentEntities;
}

@Override
Expand Down Expand Up @@ -241,13 +262,55 @@ private Object readPutForUpdate(IncomingRequest request, ObjectMapper mapper, Ob
}
}

/**
* Reads a new (POST/create) domain object from the request body. For Jackson-based converters, the request body is
* first parsed as an {@link ObjectNode} and any fields that are not writable persistent properties are stripped
* before deserialization. This prevents Jackson from attempting to set read-only or inherited fields such as the
* {@code links} field on {@link RepresentationModel} subclasses, which would cause an
* {@link UnsupportedOperationException}.
*
* @see <a href="https://github.com/spring-projects/spring-data-rest/issues/1726">GH-1726</a>
*/
private Object read(IncomingRequest request, HttpMessageConverter<Object> converter,
RootResourceInformation information) {

Class<?> domainType = information.getDomainType();

// For Jackson converters, strip non-writable fields (e.g. "_links" from RepresentationModel)
// before handing the body to Jackson for deserialization. See GH-1726.
if (converter instanceof AbstractJacksonHttpMessageConverter jacksonConverter) {

try {

ObjectMapper mapper = jacksonConverter.getMapper();
ObjectNode root = (ObjectNode) mapper.readTree(request.getBody());

persistentEntities.getPersistentEntity(domainType).ifPresent(entity -> {

MappedJacksonProperties mappedProperties = MappedJacksonProperties.forDeserialization(entity, mapper);

// Collect field names to remove first to avoid ConcurrentModificationException
List<String> toRemove = new ArrayList<>();
for (Map.Entry<String, JsonNode> entry : root.properties()) {
if (!mappedProperties.isKnownJacksonProperty(entry.getKey())) {
toRemove.add(entry.getKey());
}
}
toRemove.forEach(root::remove);
});

return mapper.treeToValue(root, domainType);

} catch (IOException o_O) {
throw new HttpMessageNotReadableException(String.format(ERROR_MESSAGE, domainType), o_O,
request.getServerHttpRequest());
}
}

try {
return converter.read(information.getDomainType(), request.getServerHttpRequest());
return converter.read(domainType, request.getServerHttpRequest());
} catch (IOException o_O) {
throw new HttpMessageNotReadableException(String.format(ERROR_MESSAGE, information.getDomainType()), o_O,
throw new HttpMessageNotReadableException(String.format(ERROR_MESSAGE, domainType), o_O,
request.getServerHttpRequest());
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ public PersistentEntityResourceHandlerMethodArgumentResolver persistentEntityArg
BindContextFactory factory = new PersistentEntitiesBindContextFactory(entities, defaultConversionService);

return new PersistentEntityResourceHandlerMethodArgumentResolver(defaultMessageConverters,
repoRequestArgumentResolver, backendIdHandlerMethodArgumentResolver, reader, lookups, factory);
repoRequestArgumentResolver, backendIdHandlerMethodArgumentResolver, reader, lookups, factory, entities);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* 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.
*
* Modifications copyright (C) 2026 Steve Rutherford
*/
package org.springframework.data.rest.webmvc.json;

Expand Down Expand Up @@ -65,6 +67,7 @@
* @author Mathias Düsterhöft
* @author Thomas Mrozinski
* @author Lars Vierbergen
* @author Steve Rutherford
* @since 2.2
*/
@SuppressWarnings("NullAway")
Expand Down Expand Up @@ -120,10 +123,44 @@ public <T> T readPut(final ObjectNode source, T target, final ObjectMapper mappe

retainIdentifierAndVersion(source, target, mapper);

Object intermediate = mapper.readerFor(target.getClass()).readValue(source);
// Strip fields that are not writable persistent properties (e.g. "_links" from RepresentationModel
// subclasses) before handing the node to Jackson for intermediate deserialization. Without this,
// Jackson would attempt to set the private, setter-less "links" field on RepresentationModel and
// throw an UnsupportedOperationException. See https://github.com/spring-projects/spring-data-rest/issues/1726
ObjectNode filteredSource = stripNonWritableFields(source, target.getClass(), mapper);

Object intermediate = mapper.readerFor(target.getClass()).readValue(filteredSource);
return (T) mergeForPut(intermediate, target, mapper);
}

/**
* Returns a copy of the given {@link ObjectNode} with all fields removed that are not writable persistent properties
* of the given type. This prevents Jackson from attempting to set read-only or inherited fields (such as the
* {@code links} field from {@link org.springframework.hateoas.RepresentationModel}) during deserialization.
*
* @param source must not be {@literal null}.
* @param type must not be {@literal null}.
* @param mapper must not be {@literal null}.
* @return a filtered copy of the source node, never {@literal null}.
*/
private ObjectNode stripNonWritableFields(ObjectNode source, Class<?> type, ObjectMapper mapper) {

return entities.getPersistentEntity(type).map(entity -> {

MappedJacksonProperties mappedProperties = MappedJacksonProperties.forDeserialization(entity, mapper);
ObjectNode copy = source.deepCopy();

for (Iterator<Entry<String, JsonNode>> it = copy.properties().iterator(); it.hasNext();) {
if (!mappedProperties.isKnownJacksonProperty(it.next().getKey())) {
it.remove();
}
}

return copy;

}).orElse(source);
}

/**
* Overwrites the identifier and version fields in the given request {@link ObjectNode} with the values of the
* persisted {@code target} before the body is deserialized. This makes sure clients cannot mutate identifier or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,38 @@ public boolean isExposedProperty(String name) {

return property != null ? property.isWritable() : anySetterFound;
}

/**
* Returns whether the given field name is known to Jackson — either as a mapped persistent property, as an unmapped
* Jackson property (e.g. a {@link org.springframework.data.annotation.Transient} field that Jackson can still
* deserialize), or because there is a catch-all {@link com.fasterxml.jackson.annotation.JsonAnySetter} method.
* <p>
* This is used to strip fields from an incoming {@link tools.jackson.databind.node.ObjectNode} that Jackson has no
* knowledge of and would therefore attempt to set via reflection on inherited private fields (e.g. {@code _links}
* from {@link org.springframework.hateoas.RepresentationModel}), which causes an
* {@link UnsupportedOperationException}.
*
* @param name must not be {@literal null} or empty.
* @return {@literal true} if the field is known to Jackson and should be kept in the request body.
* @since 5.2
* @see <a href="https://github.com/spring-projects/spring-data-rest/issues/1726">GH-1726</a>
*/
public boolean isKnownJacksonProperty(String name) {

Assert.hasText(name, "Property name must not be null or empty");

if (ignoredPropertyNames.contains(name)) {
return false;
}

if (fieldNameToProperty.containsKey(name)) {
return true;
}

if (anySetterFound) {
return true;
}

return unmappedProperties.stream().anyMatch(p -> p.getName().equals(name));
}
}
Loading