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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <a href="https://github.com/spring-projects/spring-data-rest/issues/1515">GH-1515</a>: 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;
}
}
Original file line number Diff line number Diff line change
@@ -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 <em>not</em> annotated and therefore not HTTP-exported.
*
* @author Spring Data REST team
* @author Steve Rutherford
* @see Member
* @see ProfileRepository
*/
@RepositoryRestResource
public interface MemberRepository extends CrudRepository<Member, Long> {
}
Original file line number Diff line number Diff line change
@@ -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 <em>not</em> annotated with
* {@code @RepositoryRestResource}, so it is not exported as an HTTP endpoint under the ANNOTATED strategy. This
* reproduces the scenario described in
* <a href="https://github.com/spring-projects/spring-data-rest/issues/1515">GH-1515</a>.
*
* @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;
}
}
Original file line number Diff line number Diff line change
@@ -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 <em>not</em> annotated with
* {@code @RepositoryRestResource} so that it is <em>not</em> 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 <a href="https://github.com/spring-projects/spring-data-rest/issues/1515">GH-1515</a>: 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<Profile, Long> {
}
Original file line number Diff line number Diff line change
@@ -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 <em>not</em> annotated with {@code @RepositoryRestResource}.
*
* <h3>Scenario</h3>
* <ul>
* <li>{@link ProfileRepository} is <em>not</em> annotated → not exported as an HTTP endpoint under ANNOTATED
* strategy.</li>
* <li>{@link MemberRepository} <em>is</em> annotated → exported as an HTTP endpoint.</li>
* <li>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}.</li>
* </ul>
*
* <h3>Root cause (before fix)</h3>
* {@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.
*
* <h3>Fix</h3>
* 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 <a href="https://github.com/spring-projects/spring-data-rest/issues/1515">GH-1515</a>
*/
@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.
* <p>
* 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).
* <p>
* 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -469,7 +470,8 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD
continue;
}

if (!associationLinks.isLinkableAssociation(persistentProperty)) {
if (!associationLinks.isUriResolvableAssociation(persistentProperty)
&& !associationLinks.isLinkableAssociation(persistentProperty)) {
continue;
}

Expand Down
Loading