+ * 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 extends PersistentProperty> } /** - * 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, ? extends KeyValuePersistentProperty>> 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, ? extends KeyValuePersistentProperty>> 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, ? extends KeyValuePersistentProperty>> 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 extends PersistentProperty>> getAssociation(Class> type, String name) {
@@ -164,6 +269,10 @@ private Association extends PersistentProperty>> 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