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..5d4c589cd --- /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,73 @@ +/* + * 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 + * @author Steve Rutherford + * @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..6a0ada2be --- /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,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; +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 + * @author Steve Rutherford + * @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..7d2ed862d --- /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,60 @@ +/* + * 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 + * @author Steve Rutherford + * @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..d24b1deb4 --- /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,34 @@ +/* + * 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 + * @author Steve Rutherford + * @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..f3f48c4d9 --- /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,162 @@ +/* + * 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

+ * + * + *

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 + * @author Steve Rutherford + * @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..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) @@ -469,7 +470,8 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD continue; } - if (!associationLinks.isLinkableAssociation(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 d45b2cd71..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 { @@ -467,7 +468,8 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD continue; } - if (!associationLinks.isLinkableAssociation(persistentProperty)) { + if (!associationLinks.isUriResolvableAssociation(persistentProperty) + && !associationLinks.isLinkableAssociation(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..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 { @@ -135,7 +136,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 +160,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..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 @@ -42,10 +42,12 @@ 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; /** * @author Oliver Gierke + * @author Steve Rutherford */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -154,6 +156,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 +269,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 +282,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); } + }; + } + } }