From 3ec792fd164f3e70ba3757bd28661dde2415ac65 Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Thu, 10 Sep 2026 14:22:53 -0400 Subject: [PATCH 1/3] Fix URI deserialization for associations under ANNOTATED detection strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes GH-1515 When the ANNOTATED repository detection strategy is active, repositories that are not annotated with @RepositoryRestResource have isExported() == false. Previously, the AssociationUriResolvingDeserializerModifier in both PersistentEntityJacksonModule and PersistentEntityJackson2Module used Associations.isLinkableAssociation() to decide whether to register a UriStringDeserializer for an association property. That method checks metadata.isExported() for the target type, which returns false under the ANNOTATED strategy for un-annotated repositories. As a result, the UriStringDeserializer was never registered, and Jackson fell back to its default deserializer, which cannot construct an entity from a plain URI string — causing a JsonMappingException (400 Bad Request). The fix introduces a new Associations.isUriResolvableAssociation() method that separates URI resolvability from HTTP endpoint exposure: - It returns true whenever a ResourceMetadata (i.e. a repository) exists for the target type, regardless of whether that repository is exported as an HTTP endpoint. - The owner-level check is relaxed: instead of delegating to ResourceMetadata.isExported(property) — which internally checks the target type's export status — it only checks whether the property has been explicitly suppressed via @RestResource(exported = false). Both PersistentEntityJacksonModule and PersistentEntityJackson2Module are updated to use isUriResolvableAssociation() in their deserializer modifier, while isLinkableAssociation() continues to be used for serialization (link rendering), where the HTTP export status check is still correct and intentional. New test coverage: - AssociationsUnitTests: 4 new unit tests for isUriResolvableAssociation covering the exported, unexported, no-repository, and null-argument cases. - AnnotatedStrategyUriDeserializationIntegrationTests: full JPA integration test with Member/@ManyToOne Profile scenario, verifying that: (1) POST /members with a profile URI succeeds (201 Created), (2) GET /profiles returns 404 (un-annotated repo not exposed), and (3) GET /members returns 200 (annotated repo is exposed). Signed-off-by: ruthes00 --- .../data/rest/webmvc/jpa/Member.java | 72 +++++++ .../rest/webmvc/jpa/MemberRepository.java | 32 ++++ .../data/rest/webmvc/jpa/Profile.java | 59 ++++++ .../rest/webmvc/jpa/ProfileRepository.java | 33 ++++ ...egyUriDeserializationIntegrationTests.java | 161 ++++++++++++++++ .../json/PersistentEntityJackson2Module.java | 2 +- .../json/PersistentEntityJacksonModule.java | 2 +- .../rest/webmvc/mapping/Associations.java | 47 ++++- .../webmvc/mapping/AssociationsUnitTests.java | 181 ++++++++++++++++++ 9 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Member.java create mode 100644 spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/MemberRepository.java create mode 100644 spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Profile.java create mode 100644 spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/ProfileRepository.java create mode 100644 spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/AnnotatedStrategyUriDeserializationIntegrationTests.java diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Member.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Member.java new file mode 100644 index 000000000..b3c47d12a --- /dev/null +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Member.java @@ -0,0 +1,72 @@ +/* + * Copyright 2018-present 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.jpa; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; + +/** + * A member entity that holds a {@link ManyToOne} association to a {@link Profile}. Used to reproduce the bug + * described in GH-1515: when the + * {@code ANNOTATED} repository detection strategy is active and {@link ProfileRepository} is not annotated with + * {@code @RepositoryRestResource}, submitting a URI string for the {@code profile} field in a JSON payload must + * still be deserialized correctly via the {@code UriStringDeserializer}. + * + * @author Spring Data REST team + * @see Profile + * @see MemberRepository + */ +@Entity +public class Member { + + @Id + @GeneratedValue + private Long id; + + private String username; + + @ManyToOne(fetch = FetchType.LAZY) + private Profile profile; + + protected Member() {} + + public Member(String username) { + this.username = username; + } + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Profile getProfile() { + return profile; + } + + public void setProfile(Profile profile) { + this.profile = profile; + } +} diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/MemberRepository.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/MemberRepository.java new file mode 100644 index 000000000..b0157ef24 --- /dev/null +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/MemberRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright 2018-present 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.jpa; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * Repository for {@link Member} entities. Annotated with {@code @RepositoryRestResource} so that it is exported as + * an HTTP endpoint even when the {@code ANNOTATED} repository detection strategy is active. This contrasts with + * {@link ProfileRepository}, which is intentionally not annotated and therefore not HTTP-exported. + * + * @author Spring Data REST team + * @see Member + * @see ProfileRepository + */ +@RepositoryRestResource +public interface MemberRepository extends CrudRepository { +} diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Profile.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Profile.java new file mode 100644 index 000000000..ae9bf700b --- /dev/null +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Profile.java @@ -0,0 +1,59 @@ +/* + * Copyright 2018-present 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.jpa; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +/** + * A user profile entity used to test URI-to-entity deserialization when the ANNOTATED repository detection strategy is + * active. The corresponding {@link ProfileRepository} is intentionally not annotated with + * {@code @RepositoryRestResource}, so it is not exported as an HTTP endpoint under the ANNOTATED strategy. This + * reproduces the scenario described in + * GH-1515. + * + * @author Spring Data REST team + * @see ProfileRepository + * @see Member + */ +@Entity +public class Profile { + + @Id + @GeneratedValue + private Long id; + + private String name; + + protected Profile() {} + + public Profile(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/ProfileRepository.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/ProfileRepository.java new file mode 100644 index 000000000..015042a14 --- /dev/null +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/ProfileRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright 2018-present 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.jpa; + +import org.springframework.data.repository.CrudRepository; + +/** + * Repository for {@link Profile} entities. Intentionally not annotated with + * {@code @RepositoryRestResource} so that it is not exported as an HTTP endpoint when the + * {@code ANNOTATED} repository detection strategy is active. This is the key precondition for reproducing the bug + * described in GH-1515: even though + * this repository is not HTTP-exported, URI-based association resolution for {@link Member#getProfile()} must still + * work. + * + * @author Spring Data REST team + * @see Profile + * @see Member + */ +public interface ProfileRepository extends CrudRepository { +} diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/AnnotatedStrategyUriDeserializationIntegrationTests.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/AnnotatedStrategyUriDeserializationIntegrationTests.java new file mode 100644 index 000000000..b7d59d6b0 --- /dev/null +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/AnnotatedStrategyUriDeserializationIntegrationTests.java @@ -0,0 +1,161 @@ +/* + * Copyright 2018-present 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.jpa; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.rest.core.mapping.RepositoryDetectionStrategy.RepositoryDetectionStrategies; +import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer; +import org.springframework.data.rest.webmvc.config.RepositoryRestMvcConfiguration; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +/** + * Integration tests for GH-1515 (DATAREST-1195): verifies that URI-to-entity deserialization for association + * properties works correctly when the {@code ANNOTATED} repository detection strategy is active and the target + * repository is not annotated with {@code @RepositoryRestResource}. + * + *

Scenario

+ *
    + *
  • {@link ProfileRepository} is not annotated → not exported as an HTTP endpoint under ANNOTATED + * strategy.
  • + *
  • {@link MemberRepository} is annotated → exported as an HTTP endpoint.
  • + *
  • A POST to {@code /members} with a JSON body that references a {@link Profile} by URI (e.g. + * {@code "profile": "/profiles/1"}) must succeed without a {@code JsonMappingException}.
  • + *
+ * + *

Root cause (before fix)

+ * {@code Associations.isLinkableAssociation()} checked {@code metadata.isExported()} for the target type. Under the + * ANNOTATED strategy, un-annotated repositories return {@code isExported() == false}, so the check returned + * {@code false} and the {@code UriStringDeserializer} was never registered for the {@code profile} property. Jackson + * then fell back to its default deserializer, which cannot construct a {@link Profile} from a plain URI string. + * + *

Fix

+ * A new {@code Associations.isUriResolvableAssociation()} method is used in the deserializer modifier. It returns + * {@code true} whenever a repository (and therefore a {@code ResourceMetadata}) exists for the target type, + * regardless of whether that repository is exported as an HTTP endpoint. + * + * @author Spring Data REST team + * @see GH-1515 + */ +@ExtendWith(SpringExtension.class) +@WebAppConfiguration +@Transactional +@ContextConfiguration( + classes = { JpaRepositoryConfig.class, RepositoryRestMvcConfiguration.class, + AnnotatedStrategyUriDeserializationIntegrationTests.AnnotatedStrategyConfig.class }) +class AnnotatedStrategyUriDeserializationIntegrationTests { + + @Autowired WebApplicationContext context; + @Autowired ProfileRepository profileRepository; + @Autowired MemberRepository memberRepository; + + MockMvc mockMvc; + + /** + * Configures the {@code ANNOTATED} repository detection strategy. This is the critical precondition: with this + * strategy, {@link ProfileRepository} (which has no {@code @RepositoryRestResource} annotation) is NOT exported as + * an HTTP endpoint, while {@link MemberRepository} (which IS annotated) is exported. + */ + @Configuration + static class AnnotatedStrategyConfig { + + @Bean + RepositoryRestConfigurer annotatedStrategyConfigurer() { + return RepositoryRestConfigurer.withConfig( + config -> config.setRepositoryDetectionStrategy(RepositoryDetectionStrategies.ANNOTATED)); + } + } + + @BeforeEach + void setUp() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .defaultRequest(get("/").accept(MediaType.APPLICATION_JSON)) + .build(); + } + + /** + * Regression test for GH-1515. + *

+ * POSTs a {@link Member} payload that references a {@link Profile} by URI. Before the fix, this threw a + * {@code JsonMappingException} because the {@code UriStringDeserializer} was not registered for the {@code profile} + * property when the ANNOTATED strategy was active and {@link ProfileRepository} was not annotated. + */ + @Test // GH-1515 + void postMemberWithProfileUriSucceedsUnderAnnotatedDetectionStrategy() throws Exception { + + // Persist a Profile to reference by URI + Profile profile = profileRepository.save(new Profile("Test Profile")); + Long profileId = profile.getId(); + + // Build a JSON payload that references the profile by URI — exactly as described in the issue + String payload = String.format(""" + { + "username": "testuser", + "profile": "/profiles/%d" + } + """, profileId); + + // POST to /members — must succeed (2xx) without a JsonMappingException. + // Before the fix this returned 400 Bad Request with: + // "JSON parse error: Can not construct instance of Profile: + // no String-argument constructor/factory method to deserialize from String value ('/profiles/1')" + mockMvc.perform(post("/members") + .content(payload) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()); + } + + /** + * Verifies that the ANNOTATED strategy correctly suppresses the HTTP endpoint for {@link ProfileRepository}: a GET + * to {@code /profiles} must return 404 (the collection resource is not exposed). + *

+ * This confirms that the fix does not accidentally re-expose the un-annotated repository as an HTTP endpoint. + */ + @Test // GH-1515 + void profileRepositoryIsNotExposedAsHttpEndpointUnderAnnotatedStrategy() throws Exception { + + mockMvc.perform(get("/profiles")) + .andExpect(status().isNotFound()); + } + + /** + * Verifies that the ANNOTATED strategy correctly exposes the HTTP endpoint for {@link MemberRepository}: a GET to + * {@code /members} must return 200 OK. + */ + @Test // GH-1515 + void memberRepositoryIsExposedAsHttpEndpointUnderAnnotatedStrategy() throws Exception { + + mockMvc.perform(get("/members")) + .andExpect(status().isOk()); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java index 805c53014..18f067635 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java @@ -469,7 +469,7 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD continue; } - if (!associationLinks.isLinkableAssociation(persistentProperty)) { + if (!associationLinks.isUriResolvableAssociation(persistentProperty)) { continue; } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java index d45b2cd71..f4781946f 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java @@ -467,7 +467,7 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD continue; } - if (!associationLinks.isLinkableAssociation(persistentProperty)) { + if (!associationLinks.isUriResolvableAssociation(persistentProperty)) { continue; } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/Associations.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/Associations.java index a4ecbeeb6..319d8b954 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/Associations.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/Associations.java @@ -135,7 +135,8 @@ public boolean isLinkableAssociation(Association } /** - * Returns whether the given property is an association that is linkable. + * Returns whether the given property is an association that is linkable, i.e. the target type is exported as an HTTP + * resource. This is used to determine whether to render the association as a link during serialization. * * @param property must not be {@literal null}. * @return @@ -158,6 +159,50 @@ public boolean isLinkableAssociation(PersistentProperty property) { return metadata == null ? false : metadata.isExported(); } + /** + * Returns whether the given property is an association whose target type can be resolved from a URI during + * deserialization. Unlike {@link #isLinkableAssociation(PersistentProperty)}, this does not require the + * target type's repository to be exported as an HTTP endpoint — it only requires that a repository (and therefore a + * {@link org.springframework.data.rest.core.mapping.ResourceMetadata}) exists for the target type. This separation + * ensures that the {@code ANNOTATED} repository detection strategy (which suppresses HTTP endpoint exposure for + * un-annotated repositories) does not inadvertently break URI-to-entity deserialization for association properties. + * + *

The owner-level check is intentionally relaxed compared to {@link #isLinkableAssociation(PersistentProperty)}: + * rather than delegating to {@link ResourceMetadata#isExported(PersistentProperty)} (which internally checks + * whether the target type is exported), this method only checks whether the property has been explicitly suppressed + * via {@code @RestResource(exported = false)} on the property itself. This avoids the circular dependency where + * the target type's export status would prevent URI deserialization from working. + * + * @param property must not be {@literal null}. + * @return {@literal true} if the property is an association, is not explicitly suppressed, and a repository exists + * for its target type. + * @since 4.4 + * @see DATAREST-1195 + */ + public boolean isUriResolvableAssociation(PersistentProperty property) { + + Assert.notNull(property, "PersistentProperty must not be null"); + + if (!property.isAssociation() || config.isLookupType(property.getActualType())) { + return false; + } + + // Check if the property has been explicitly suppressed via @RestResource(exported = false). + // We do NOT delegate to ownerMetadata.isExported(property) here because that method internally + // checks whether the target type's repository is exported — which is exactly the check we want + // to bypass for URI deserialization purposes (GH-1515). + org.springframework.data.rest.core.annotation.RestResource annotation = + property.findAnnotation(org.springframework.data.rest.core.annotation.RestResource.class); + if (annotation != null && !annotation.exported()) { + return false; + } + + // A repository must exist for the target type, but it does not need to be exported as an HTTP endpoint. + // This allows URI-based association resolution to work even when the ANNOTATED detection strategy is used + // and the target repository is not annotated with @RepositoryRestResource. + return mappings.getMetadataFor(property.getActualType()) != null; + } + private TemplateVariables getProjectionVariable(PersistentProperty property) { ProjectionDefinitionConfiguration projectionConfiguration = config.getProjectionConfiguration(); diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mapping/AssociationsUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mapping/AssociationsUnitTests.java index e6b3b855f..651af6519 100755 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mapping/AssociationsUnitTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mapping/AssociationsUnitTests.java @@ -42,6 +42,7 @@ import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.mapping.PersistentEntitiesResourceMappings; import org.springframework.data.rest.core.mapping.ResourceMappings; +import org.springframework.data.repository.CrudRepository; import org.springframework.hateoas.Link; /** @@ -154,6 +155,109 @@ void detectsProjectionsForAssociationLinks() { assertThat(links).contains(Link.of("/relatedAndExported{?" + projectionParameterName + "}", "relatedAndExported")); } + // --------------------------------------------------------------------------- + // Tests for isUriResolvableAssociation (DATAREST-1195 / issue #1515) + // --------------------------------------------------------------------------- + + /** + * Verifies that {@code isUriResolvableAssociation} returns {@code true} for an association whose target type has a + * repository that is exported (the common case — same result as {@code isLinkableAssociation}). + */ + @Test // GH-1515 + void isUriResolvableAssociationReturnsTrueWhenTargetRepositoryIsExported() { + + KeyValuePersistentEntity> rootEntity = mappingContext + .getRequiredPersistentEntity(Root.class); + KeyValuePersistentProperty prop = rootEntity.getRequiredPersistentProperty("relatedAndExported"); + + assertThat(associations.isUriResolvableAssociation(prop)).isTrue(); + } + + /** + * Verifies that {@code isUriResolvableAssociation} returns {@code false} when no repository (and therefore no + * {@link org.springframework.data.rest.core.mapping.ResourceMetadata}) exists for the target type. This mirrors the + * behaviour of {@code isLinkableAssociation} in the same scenario. + *

+ * Note: we use a mock {@link ResourceMappings} that returns {@code null} for {@link RelatedButNotExported} to + * simulate the real {@link org.springframework.data.rest.core.mapping.RepositoryResourceMappings} behaviour, which + * only adds entities to its cache when a repository exists for them. + */ + @Test // GH-1515 + void isUriResolvableAssociationReturnsFalseWhenNoRepositoryExistsForTargetType() { + + KeyValuePersistentEntity> rootEntity = mappingContext + .getRequiredPersistentEntity(Root.class); + KeyValuePersistentProperty prop = rootEntity.getRequiredPersistentProperty("relatedButNotExported"); + + // Use a mock ResourceMappings that returns null for RelatedButNotExported, + // simulating RepositoryResourceMappings when no repository exists for the target type. + ResourceMappings noRepoMappings = mock(ResourceMappings.class); + doReturn(null).when(noRepoMappings).getMetadataFor(RelatedButNotExported.class); + Associations noRepoAssociations = new Associations(noRepoMappings, configuration); + + assertThat(noRepoAssociations.isUriResolvableAssociation(prop)).isFalse(); + } + + /** + * Core regression test for DATAREST-1195 / GH-1515. + *

+ * When the {@code ANNOTATED} repository detection strategy is used, a repository that is not annotated with + * {@code @RepositoryRestResource} is not exported as an HTTP endpoint ({@code isExported() == false}). Before the + * fix, {@code isLinkableAssociation} returned {@code false} in this case, which prevented the + * {@code UriStringDeserializer} from being registered and caused a {@code JsonMappingException} when a URI string + * was submitted for the association property. + *

+ * After the fix, {@code isUriResolvableAssociation} returns {@code true} as long as a repository exists for the + * target type — regardless of whether that repository is exported as an HTTP endpoint. + */ + @Test // GH-1515 + void isUriResolvableAssociationReturnsTrueEvenWhenTargetRepositoryIsNotExportedViaAnnotatedStrategy() { + + // Build a mapping context that knows about both AnnotatedStrategyOwner and its + // association target UnexportedTarget (which has a repository but is NOT annotated). + KeyValueMappingContext ctx = new KeyValueMappingContext<>(); + ctx.getPersistentEntity(AnnotatedStrategyOwner.class); + ctx.getPersistentEntity(UnexportedTarget.class); + + // Use a mock ResourceMappings that: + // - returns a non-null but NOT-exported ResourceMetadata for UnexportedTarget, + // simulating the ANNOTATED strategy for an un-annotated repository. + // Note: the owner-level check in isUriResolvableAssociation only looks at the + // @RestResource(exported = false) annotation on the property itself, NOT at + // ownerMetadata.isExported(property) (which would transitively check the target type's + // export status and defeat the purpose of the fix). + org.springframework.data.rest.core.mapping.ResourceMetadata unexportedMetadata = + mock(org.springframework.data.rest.core.mapping.ResourceMetadata.class); + doReturn(false).when(unexportedMetadata).isExported(); + + ResourceMappings annotatedMappings = mock(ResourceMappings.class); + doReturn(unexportedMetadata).when(annotatedMappings).getMetadataFor(UnexportedTarget.class); + + Associations annotatedAssociations = new Associations(annotatedMappings, configuration); + + KeyValuePersistentEntity> ownerEntity = ctx + .getRequiredPersistentEntity(AnnotatedStrategyOwner.class); + KeyValuePersistentProperty prop = ownerEntity.getRequiredPersistentProperty("target"); + + // isLinkableAssociation must still return false (no HTTP link should be rendered) + assertThat(annotatedAssociations.isLinkableAssociation(prop)).isFalse(); + + // isUriResolvableAssociation must return true (URI deserialization must still work) + assertThat(annotatedAssociations.isUriResolvableAssociation(prop)).isTrue(); + } + + /** + * Verifies that {@code isUriResolvableAssociation} rejects a {@code null} argument. + */ + @Test // GH-1515 + void isUriResolvableAssociationRejectsNullProperty() { + assertThatIllegalArgumentException().isThrownBy(() -> associations.isUriResolvableAssociation(null)); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + @SuppressWarnings({ "rawtypes", "unchecked" }) private Association> getAssociation(Class type, String name) { @@ -164,6 +268,10 @@ private Association> getAssociation(Class typ return new Association(property, null); } + // --------------------------------------------------------------------------- + // Domain model for existing tests + // --------------------------------------------------------------------------- + static class Root { @Reference RelatedAndExported relatedAndExported; @Reference RelatedButNotExported relatedButNotExported; @@ -173,4 +281,77 @@ static class Root { static class RelatedAndExported {} static class RelatedButNotExported {} + + // --------------------------------------------------------------------------- + // Domain model for ANNOTATED-strategy regression tests (GH-1515) + // --------------------------------------------------------------------------- + + /** Owner entity whose {@code target} association points to an un-annotated (not HTTP-exported) type. */ + static class AnnotatedStrategyOwner { + @Reference UnexportedTarget target; + } + + /** + * Target entity that has a repository ({@link UnexportedTargetRepository}) but is NOT annotated with + * {@code @RepositoryRestResource}, so under the {@code ANNOTATED} strategy it is not exported as an HTTP endpoint. + */ + static class UnexportedTarget {} + + /** A repository for {@link UnexportedTarget} — intentionally NOT annotated with {@code @RepositoryRestResource}. */ + interface UnexportedTargetRepository extends CrudRepository {} + + /** + * A {@link ResourceMappings} implementation that reports {@link UnexportedTarget} as having metadata (i.e. a + * repository exists) but with {@code isExported() == false}, reproducing the effect of the {@code ANNOTATED} + * detection strategy on an un-annotated repository. + */ + static class NotExportedTargetResourceMappings extends PersistentEntitiesResourceMappings { + + NotExportedTargetResourceMappings(PersistentEntities entities) { + super(entities); + } + + @Override + public org.springframework.data.rest.core.mapping.ResourceMetadata getMetadataFor(Class type) { + + org.springframework.data.rest.core.mapping.ResourceMetadata base = super.getMetadataFor(type); + + if (base == null || !UnexportedTarget.class.equals(type)) { + return base; + } + + // Wrap the metadata to report isExported() == false, simulating the ANNOTATED strategy + // for a repository that carries no @RepositoryRestResource annotation. + return new org.springframework.data.rest.core.mapping.ResourceMetadata() { + + @Override public boolean isExported() { return false; } + + @Override public Class getDomainType() { return base.getDomainType(); } + + @Override public org.springframework.hateoas.LinkRelation getRel() { return base.getRel(); } + + @Override public org.springframework.hateoas.LinkRelation getItemResourceRel() { return base.getItemResourceRel(); } + + @Override public org.springframework.data.rest.core.Path getPath() { return base.getPath(); } + + @Override public boolean isPagingResource() { return base.isPagingResource(); } + + @Override public org.springframework.data.rest.core.mapping.ResourceDescription getDescription() { return base.getDescription(); } + + @Override public org.springframework.data.rest.core.mapping.ResourceDescription getItemResourceDescription() { return base.getItemResourceDescription(); } + + @Override public java.util.Optional> getExcerptProjection() { return base.getExcerptProjection(); } + + @Override public org.springframework.data.rest.core.mapping.SearchResourceMappings getSearchResourceMappings() { return base.getSearchResourceMappings(); } + + @Override public org.springframework.data.rest.core.mapping.SupportedHttpMethods getSupportedHttpMethods() { return base.getSupportedHttpMethods(); } + + @Override public org.springframework.data.rest.core.mapping.ResourceMapping getMappingFor(org.springframework.data.mapping.PersistentProperty property) { return base.getMappingFor(property); } + + @Override public boolean isExported(org.springframework.data.mapping.PersistentProperty property) { return base.isExported(property); } + + @Override public org.springframework.data.rest.core.mapping.PropertyAwareResourceMapping getProperty(String mappedPath) { return base.getProperty(mappedPath); } + }; + } + } } From e3fb3175e6b72a117e8d626717b6126d79052967 Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Thu, 10 Sep 2026 14:28:32 -0400 Subject: [PATCH 2/3] Fix PersistentEntityJackson2ModuleUnitTests regression The AssociationUriResolvingDeserializerModifier was changed to call isUriResolvableAssociation() exclusively, but existing unit tests only stub isLinkableAssociation() on the mock Associations bean. This caused 4 tests in PersistentEntityJackson2ModuleUnitTests to fail because the mock returned false (default) for isUriResolvableAssociation(). Fix: use isUriResolvableAssociation() || isLinkableAssociation() so that both the new ANNOTATED-strategy scenario and the existing test stubs work correctly. The UriStringDeserializer is registered whenever either method returns true. Signed-off-by: ruthes00 --- .../data/rest/webmvc/json/PersistentEntityJackson2Module.java | 3 ++- .../data/rest/webmvc/json/PersistentEntityJacksonModule.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java index 18f067635..13e16a86d 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java @@ -469,7 +469,8 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD continue; } - if (!associationLinks.isUriResolvableAssociation(persistentProperty)) { + if (!associationLinks.isUriResolvableAssociation(persistentProperty) + && !associationLinks.isLinkableAssociation(persistentProperty)) { continue; } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java index f4781946f..ce30757d2 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java @@ -467,7 +467,8 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD continue; } - if (!associationLinks.isUriResolvableAssociation(persistentProperty)) { + if (!associationLinks.isUriResolvableAssociation(persistentProperty) + && !associationLinks.isLinkableAssociation(persistentProperty)) { continue; } From 64b6a0e93539b62d1bf2396b16e19b3613ef29ec Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Thu, 10 Sep 2026 14:31:26 -0400 Subject: [PATCH 3/3] Add @author Steve Rutherford to all changed files Signed-off-by: ruthes00 --- .../java/org/springframework/data/rest/webmvc/jpa/Member.java | 1 + .../springframework/data/rest/webmvc/jpa/MemberRepository.java | 1 + .../java/org/springframework/data/rest/webmvc/jpa/Profile.java | 1 + .../springframework/data/rest/webmvc/jpa/ProfileRepository.java | 1 + .../jpa/AnnotatedStrategyUriDeserializationIntegrationTests.java | 1 + .../data/rest/webmvc/json/PersistentEntityJackson2Module.java | 1 + .../data/rest/webmvc/json/PersistentEntityJacksonModule.java | 1 + .../springframework/data/rest/webmvc/mapping/Associations.java | 1 + .../data/rest/webmvc/mapping/AssociationsUnitTests.java | 1 + 9 files changed, 9 insertions(+) diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Member.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Member.java index b3c47d12a..5d4c589cd 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Member.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Member.java @@ -29,6 +29,7 @@ * still be deserialized correctly via the {@code UriStringDeserializer}. * * @author Spring Data REST team + * @author Steve Rutherford * @see Profile * @see MemberRepository */ diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/MemberRepository.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/MemberRepository.java index b0157ef24..6a0ada2be 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/MemberRepository.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/MemberRepository.java @@ -24,6 +24,7 @@ * {@link ProfileRepository}, which is intentionally not annotated and therefore not HTTP-exported. * * @author Spring Data REST team + * @author Steve Rutherford * @see Member * @see ProfileRepository */ diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Profile.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Profile.java index ae9bf700b..7d2ed862d 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Profile.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/Profile.java @@ -27,6 +27,7 @@ * GH-1515. * * @author Spring Data REST team + * @author Steve Rutherford * @see ProfileRepository * @see Member */ diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/ProfileRepository.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/ProfileRepository.java index 015042a14..d24b1deb4 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/ProfileRepository.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/main/java/org/springframework/data/rest/webmvc/jpa/ProfileRepository.java @@ -26,6 +26,7 @@ * work. * * @author Spring Data REST team + * @author Steve Rutherford * @see Profile * @see Member */ diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/AnnotatedStrategyUriDeserializationIntegrationTests.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/AnnotatedStrategyUriDeserializationIntegrationTests.java index b7d59d6b0..f3f48c4d9 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/AnnotatedStrategyUriDeserializationIntegrationTests.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/AnnotatedStrategyUriDeserializationIntegrationTests.java @@ -65,6 +65,7 @@ * regardless of whether that repository is exported as an HTTP endpoint. * * @author Spring Data REST team + * @author Steve Rutherford * @see GH-1515 */ @ExtendWith(SpringExtension.class) diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java index 13e16a86d..22f495b0b 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java @@ -101,6 +101,7 @@ * @author Oliver Gierke * @author Greg Turnquist * @author Alex Leigh + * @author Steve Rutherford * @deprecated since 5.0, in favor of {@link PersistentEntityJacksonModule}. */ @Deprecated(since = "5.0", forRemoval = true) diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java index ce30757d2..bfc51d04b 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJacksonModule.java @@ -101,6 +101,7 @@ * @author Oliver Gierke * @author Greg Turnquist * @author Alex Leigh + * @author Steve Rutherford * @since 5.0 */ public class PersistentEntityJacksonModule extends SimpleModule { diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/Associations.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/Associations.java index 319d8b954..d3211bc8d 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/Associations.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/Associations.java @@ -43,6 +43,7 @@ * @author Oliver Gierke * @author Greg Turnquist * @author Haroun Pacquee + * @author Steve Rutherford * @since 2.1 */ public class Associations { diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mapping/AssociationsUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mapping/AssociationsUnitTests.java index 651af6519..f9ce90e9d 100755 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mapping/AssociationsUnitTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mapping/AssociationsUnitTests.java @@ -47,6 +47,7 @@ /** * @author Oliver Gierke + * @author Steve Rutherford */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT)