diff --git a/pom.xml b/pom.xml index 9611feb28..276a83fc3 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT pom Spring Data REST diff --git a/spring-data-rest-core/pom.xml b/spring-data-rest-core/pom.xml index 9698fd36f..6868acedd 100644 --- a/spring-data-rest-core/pom.xml +++ b/spring-data-rest-core/pom.xml @@ -11,7 +11,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT ../pom.xml diff --git a/spring-data-rest-distribution/pom.xml b/spring-data-rest-distribution/pom.xml index 93104d637..1d14224d8 100644 --- a/spring-data-rest-distribution/pom.xml +++ b/spring-data-rest-distribution/pom.xml @@ -13,7 +13,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT ../pom.xml diff --git a/spring-data-rest-hal-explorer/pom.xml b/spring-data-rest-hal-explorer/pom.xml index d95562418..db9f612bc 100644 --- a/spring-data-rest-hal-explorer/pom.xml +++ b/spring-data-rest-hal-explorer/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT spring-data-rest-hal-explorer diff --git a/spring-data-rest-tests/pom.xml b/spring-data-rest-tests/pom.xml index 3c43fc597..1893e1b7d 100644 --- a/spring-data-rest-tests/pom.xml +++ b/spring-data-rest-tests/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT ../pom.xml diff --git a/spring-data-rest-tests/spring-data-rest-tests-core/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-core/pom.xml index 45b41e32b..7b7d5f4c9 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-core/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-core/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT ../pom.xml @@ -21,7 +21,7 @@ org.springframework.data spring-data-rest-webmvc - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-jpa/pom.xml index 5928e0aec..785e9b9c3 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT ../pom.xml @@ -21,7 +21,7 @@ org.springframework.data spring-data-rest-tests-core - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT test-jar diff --git a/spring-data-rest-tests/spring-data-rest-tests-mongodb/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-mongodb/pom.xml index 88169ef90..aab93a5b4 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-mongodb/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-mongodb/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT ../pom.xml @@ -33,7 +33,7 @@ org.springframework.data spring-data-rest-tests-core - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT test-jar diff --git a/spring-data-rest-tests/spring-data-rest-tests-security/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-security/pom.xml index e7b004aa8..9002b7a85 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-security/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-security/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT ../pom.xml @@ -22,7 +22,7 @@ org.springframework.data spring-data-rest-tests-core - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT test-jar diff --git a/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml index 10a860694..af242f3e5 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml @@ -4,7 +4,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT Spring Data REST Tests - Shop spring-data-rest-tests-shop diff --git a/spring-data-rest-webmvc/pom.xml b/spring-data-rest-webmvc/pom.xml index f789a323e..d2b40eff6 100644 --- a/spring-data-rest-webmvc/pom.xml +++ b/spring-data-rest-webmvc/pom.xml @@ -11,7 +11,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1726-SNAPSHOT ../pom.xml diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java index a72505656..94128258f 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java @@ -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; @@ -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; @@ -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; @@ -63,6 +69,7 @@ * @author Jon Brisbin * @author Oliver Gierke * @author Mark Paluch + * @author Steve Rutherford */ public class PersistentEntityResourceHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { @@ -75,6 +82,7 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha private final PluginRegistry, Class> lookups; private final ConversionService conversionService = new DefaultConversionService(); private final JsonPatchHandler jsonPatchHandler; + private final PersistentEntities persistentEntities; public PersistentEntityResourceHandlerMethodArgumentResolver( List> messageConverters, @@ -82,17 +90,30 @@ public PersistentEntityResourceHandlerMethodArgumentResolver( BackendIdHandlerMethodArgumentResolver idResolver, DomainObjectReader reader, PluginRegistry, Class> lookups, BindContextFactory factory) { + this(messageConverters, resourceInformationResolver, idResolver, reader, lookups, factory, + PersistentEntities.of()); + } + + public PersistentEntityResourceHandlerMethodArgumentResolver( + List> messageConverters, + RootResourceInformationHandlerMethodArgumentResolver resourceInformationResolver, + BackendIdHandlerMethodArgumentResolver idResolver, DomainObjectReader reader, + PluginRegistry, 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 @@ -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 GH-1726 + */ private Object read(IncomingRequest request, HttpMessageConverter 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 toRemove = new ArrayList<>(); + for (Map.Entry 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()); } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java index 3ae3e6e71..5e33ca06e 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java @@ -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); } /** diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java index df72cd43b..f16bd742f 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java @@ -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; @@ -65,6 +67,7 @@ * @author Mathias Düsterhöft * @author Thomas Mrozinski * @author Lars Vierbergen + * @author Steve Rutherford * @since 2.2 */ @SuppressWarnings("NullAway") @@ -120,10 +123,44 @@ public 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> 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 diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappedJacksonProperties.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappedJacksonProperties.java index dc4be882d..a92da4ba8 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappedJacksonProperties.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/MappedJacksonProperties.java @@ -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. + *

+ * 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 GH-1726 + */ + 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)); + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/DomainObjectReaderUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/DomainObjectReaderUnitTests.java index f50726c28..44ee618ba 100755 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/DomainObjectReaderUnitTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/DomainObjectReaderUnitTests.java @@ -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; @@ -55,6 +57,7 @@ import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.webmvc.mapping.Associations; +import org.springframework.hateoas.RepresentationModel; import org.springframework.util.ObjectUtils; import com.fasterxml.jackson.annotation.JsonAutoDetect; @@ -73,6 +76,7 @@ * @author Mathias Düsterhöft * @author Ken Dombeck * @author Thomas Mrozinski + * @author Steve Rutherford */ @ExtendWith(MockitoExtension.class) class DomainObjectReaderUnitTests { @@ -110,6 +114,7 @@ void setUp() { mappingContext.getPersistentEntity(BugModel.class); mappingContext.getPersistentEntity(ArrayListHolder.class); mappingContext.getPersistentEntity(MapWrapper.class); + mappingContext.getPersistentEntity(RepresentationModelEntity.class); mappingContext.afterPropertiesSet(); this.entities = new PersistentEntities(Collections.singleton(mappingContext)); @@ -1243,4 +1248,34 @@ public void setValues(Collection values) { static class MapWrapper { public Map map = new HashMap<>(); } + + // GH-1726 - entity that extends RepresentationModel, which has a private "links" field + // that Jackson would try to set when "_links" is present in the request body. + @JsonAutoDetect(fieldVisibility = Visibility.ANY) + static class RepresentationModelEntity extends RepresentationModel { + + @Id Long id; + String name; + } + + @Test // GH-1726 + void readPutWithLinksFieldDoesNotThrowForRepresentationModelSubclass() throws Exception { + + RepresentationModelEntity existing = new RepresentationModelEntity(); + existing.id = 1L; + existing.name = "original"; + + ObjectMapper mapper = new ObjectMapper(); + // Simulate a client sending back a response body that includes "_links" (as HAL clients typically do) + ObjectNode node = (ObjectNode) mapper.readTree( + "{ \"name\" : \"updated\", \"_links\" : { \"self\" : { \"href\" : \"http://localhost/entities/1\" } } }"); + + // Before the fix this would throw UnsupportedOperationException because Jackson tried to set + // the private "links" field on RepresentationModel via its unmodifiable list setter. + assertThatCode(() -> reader.readPut(node, existing, mapper)).doesNotThrowAnyException(); + + RepresentationModelEntity result = reader.readPut(node, existing, mapper); + assertThat(result.name).isEqualTo("updated"); + assertThat(result.id).isEqualTo(1L); + } }