diff --git a/pom.xml b/pom.xml index 9611feb28..ab0d04495 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT pom Spring Data REST diff --git a/spring-data-rest-core/pom.xml b/spring-data-rest-core/pom.xml index 9698fd36f..3212872e4 100644 --- a/spring-data-rest-core/pom.xml +++ b/spring-data-rest-core/pom.xml @@ -11,7 +11,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT ../pom.xml diff --git a/spring-data-rest-distribution/pom.xml b/spring-data-rest-distribution/pom.xml index 93104d637..0cb18b71c 100644 --- a/spring-data-rest-distribution/pom.xml +++ b/spring-data-rest-distribution/pom.xml @@ -13,7 +13,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT ../pom.xml diff --git a/spring-data-rest-hal-explorer/pom.xml b/spring-data-rest-hal-explorer/pom.xml index d95562418..7baf4388c 100644 --- a/spring-data-rest-hal-explorer/pom.xml +++ b/spring-data-rest-hal-explorer/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT spring-data-rest-hal-explorer diff --git a/spring-data-rest-tests/pom.xml b/spring-data-rest-tests/pom.xml index 3c43fc597..a18064c1e 100644 --- a/spring-data-rest-tests/pom.xml +++ b/spring-data-rest-tests/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT ../pom.xml diff --git a/spring-data-rest-tests/spring-data-rest-tests-core/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-core/pom.xml index 45b41e32b..625586e0a 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-core/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-core/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT ../pom.xml @@ -21,7 +21,7 @@ org.springframework.data spring-data-rest-webmvc - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-jpa/pom.xml index 5928e0aec..b67ea8fc6 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT ../pom.xml @@ -21,7 +21,7 @@ org.springframework.data spring-data-rest-tests-core - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT test-jar diff --git a/spring-data-rest-tests/spring-data-rest-tests-mongodb/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-mongodb/pom.xml index 88169ef90..3c8e97822 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-mongodb/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-mongodb/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT ../pom.xml @@ -33,7 +33,7 @@ org.springframework.data spring-data-rest-tests-core - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT test-jar diff --git a/spring-data-rest-tests/spring-data-rest-tests-security/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-security/pom.xml index e7b004aa8..02097c76e 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-security/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-security/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT ../pom.xml @@ -22,7 +22,7 @@ org.springframework.data spring-data-rest-tests-core - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT test-jar diff --git a/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml index 10a860694..1a79c434c 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml @@ -4,7 +4,7 @@ org.springframework.data spring-data-rest-tests - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT Spring Data REST Tests - Shop spring-data-rest-tests-shop diff --git a/spring-data-rest-webmvc/pom.xml b/spring-data-rest-webmvc/pom.xml index f789a323e..3d378ef2e 100644 --- a/spring-data-rest-webmvc/pom.xml +++ b/spring-data-rest-webmvc/pom.xml @@ -11,7 +11,7 @@ org.springframework.data spring-data-rest-parent - 5.2.0-SNAPSHOT + 5.2.0-1544-SNAPSHOT ../pom.xml diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/MappingJacksonValueResponseBodyAdvice.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/MappingJacksonValueResponseBodyAdvice.java new file mode 100644 index 000000000..a08f03f05 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/MappingJacksonValueResponseBodyAdvice.java @@ -0,0 +1,139 @@ +/* + * Copyright 2024-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.config; + +import tools.jackson.databind.ser.FilterProvider; + +import java.util.HashMap; +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +/** + * {@link ResponseBodyAdvice} that transparently handles + * {@link org.springframework.http.converter.json.MappingJacksonValue} return values from + * {@link org.springframework.data.rest.webmvc.RepositoryRestController} methods. In Spring Framework 7, + * {@code MappingJacksonValue} is no longer supported as a direct return type by the Jackson message converters + * (which now use Jackson 3 / {@code tools.jackson}). This advice unwraps the inner value so that the HAL + * message converters can serialize it correctly, producing proper {@code _links} output. + * + *

Any {@link FilterProvider} set on the {@code MappingJacksonValue} is propagated as a Jackson 3 converter + * hint provided it is already an instance of {@link tools.jackson.databind.ser.FilterProvider}. If a legacy + * Jackson 2 ({@code com.fasterxml.jackson.databind.ser.FilterProvider}) instance is detected, a warning is + * logged and the filter is skipped; callers should migrate to Jackson 3's + * {@link tools.jackson.databind.ser.std.SimpleFilterProvider} instead. + * + *

Serialization views set via {@code MappingJacksonValue#setSerializationView(Class)} are always forwarded + * as hints regardless of the Jackson version. + * + * @author Steve Rutherford + * @see ResponseBodyAdvice#determineWriteHints(Object, MethodParameter, MediaType, Class) + */ +@SuppressWarnings({ "deprecation", "removal" }) +class MappingJacksonValueResponseBodyAdvice implements ResponseBodyAdvice { + + private static final Logger logger = LoggerFactory.getLogger(MappingJacksonValueResponseBodyAdvice.class); + + /** + * Thread-local storage for hints extracted from a {@code MappingJacksonValue} during + * {@link #beforeBodyWrite}. These are consumed in {@link #determineWriteHints}, which is + * called immediately after on the same thread by the Spring MVC infrastructure. + */ + private static final ThreadLocal> PENDING_HINTS = new ThreadLocal<>(); + + /** + * The hint key used by {@code AbstractJacksonHttpMessageConverter} for a Jackson 3 + * {@link FilterProvider}. The key is the fully-qualified class name of + * {@link tools.jackson.databind.ser.FilterProvider}. + */ + private static final String FILTER_PROVIDER_HINT = FilterProvider.class.getName(); + + /** + * The hint key used by {@code AbstractJacksonHttpMessageConverter} for a JSON serialization view. + * The key is the fully-qualified class name of {@link Class}. + */ + private static final String JSON_VIEW_HINT = Class.class.getName(); + + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + return org.springframework.http.converter.json.MappingJacksonValue.class + .isAssignableFrom(returnType.getParameterType()) + || hasWrappedMappingJacksonValueGeneric(returnType); + } + + @Override + public @Nullable Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, + MediaType selectedContentType, Class> selectedConverterType, + ServerHttpRequest request, ServerHttpResponse response) { + + if (!(body instanceof org.springframework.http.converter.json.MappingJacksonValue wrapper)) { + return body; + } + + Map hints = new HashMap<>(); + + Object rawFilters = wrapper.getFilters(); + if (rawFilters != null) { + if (rawFilters instanceof FilterProvider jackson3Filters) { + // The filter is already a Jackson 3 FilterProvider — pass it as a hint + hints.put(FILTER_PROVIDER_HINT, jackson3Filters); + } else { + logger.warn("MappingJacksonValue contains a Jackson 2 FilterProvider ({}), which is incompatible " + + "with the Jackson 3 message converters used by Spring Data REST. The filter will be ignored. " + + "Migrate to tools.jackson.databind.ser.std.SimpleFilterProvider instead.", + rawFilters.getClass().getName()); + } + } + + Class serializationView = wrapper.getSerializationView(); + if (serializationView != null) { + hints.put(JSON_VIEW_HINT, serializationView); + } + + if (!hints.isEmpty()) { + PENDING_HINTS.set(hints); + } + + return wrapper.getValue(); + } + + @Override + public @Nullable Map determineWriteHints(@Nullable Object body, MethodParameter returnType, + MediaType selectedContentType, Class> selectedConverterType) { + + Map hints = PENDING_HINTS.get(); + PENDING_HINTS.remove(); + return hints; + } + + /** + * Checks whether the return type is a generic wrapper (e.g. {@code ResponseEntity}) + * whose type argument is {@code MappingJacksonValue}. + */ + private static boolean hasWrappedMappingJacksonValueGeneric(MethodParameter returnType) { + + Class nested = returnType.nested().getNestedParameterType(); + return org.springframework.http.converter.json.MappingJacksonValue.class.isAssignableFrom(nested); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java index 3ae3e6e71..e04b829ec 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java @@ -163,6 +163,7 @@ * @author Mark Paluch * @author Christoph Strobl * @author Will Fleury + * @author Steve Rutherford */ @Configuration(proxyBeanMethods = false) @EnableHypermediaSupport(type = { HypermediaType.HAL, HypermediaType.HAL_FORMS }) @@ -657,6 +658,7 @@ public RequestMappingHandlerAdapter repositoryExporterHandlerAdapter( handlerAdapter.setMessageConverters(defaultMessageConverters); List> advices = new ArrayList<>(); + advices.add(new MappingJacksonValueResponseBodyAdvice()); advices.add(new HalFormsAdaptingResponseBodyAdvice<>()); if (repositoryRestConfiguration.getMetadataConfiguration().alpsEnabled()) { diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/MappingJacksonValueResponseBodyAdviceTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/MappingJacksonValueResponseBodyAdviceTests.java new file mode 100644 index 000000000..6a4dd6635 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/MappingJacksonValueResponseBodyAdviceTests.java @@ -0,0 +1,200 @@ +/* + * Copyright 2024-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.config; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.server.mvc.TypeConstrainedJacksonJsonHttpMessageConverter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.converter.json.MappingJacksonValue; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; + +/** + * Unit tests for {@link MappingJacksonValueResponseBodyAdvice}. + * + * @author Steve Rutherford + */ +@SuppressWarnings({ "deprecation", "removal" }) +@ExtendWith(MockitoExtension.class) +class MappingJacksonValueResponseBodyAdviceTests { + + MappingJacksonValueResponseBodyAdvice advice = new MappingJacksonValueResponseBodyAdvice(); + + @Mock MethodParameter parameter; + @Mock ServerHttpRequest request; + @Mock ServerHttpResponse response; + + // ------------------------------------------------------------------------- + // supports() + // ------------------------------------------------------------------------- + + @Test // GH-1544 + void supportsDirectMappingJacksonValueReturnType() { + + when(parameter.getParameterType()).thenAnswer(inv -> MappingJacksonValue.class); + + assertThat(advice.supports(parameter, TypeConstrainedJacksonJsonHttpMessageConverter.class)).isTrue(); + } + + @Test // GH-1544 + void doesNotSupportUnrelatedReturnType() { + + when(parameter.getParameterType()).thenAnswer(inv -> RepresentationModel.class); + when(parameter.nested()).thenReturn(parameter); + when(parameter.getNestedParameterType()).thenAnswer(inv -> RepresentationModel.class); + + assertThat(advice.supports(parameter, TypeConstrainedJacksonJsonHttpMessageConverter.class)).isFalse(); + } + + @Test // GH-1544 + void doesNotSupportPlainJacksonConverter() { + + when(parameter.getParameterType()).thenAnswer(inv -> RepresentationModel.class); + when(parameter.nested()).thenReturn(parameter); + when(parameter.getNestedParameterType()).thenAnswer(inv -> RepresentationModel.class); + + assertThat(advice.supports(parameter, MappingJackson2HttpMessageConverter.class)).isFalse(); + } + + // ------------------------------------------------------------------------- + // beforeBodyWrite() — unwrapping + // ------------------------------------------------------------------------- + + @Test // GH-1544 + void unwrapsMappingJacksonValueAndReturnsInnerValue() { + + RepresentationModel model = new RepresentationModel<>(); + MappingJacksonValue wrapper = new MappingJacksonValue(model); + + Object result = advice.beforeBodyWrite(wrapper, parameter, MediaType.APPLICATION_JSON, + TypeConstrainedJacksonJsonHttpMessageConverter.class, request, response); + + assertThat(result).isSameAs(model); + } + + @Test // GH-1544 + void returnsNonMappingJacksonValueBodyUnchanged() { + + RepresentationModel model = new RepresentationModel<>(); + + Object result = advice.beforeBodyWrite(model, parameter, MediaType.APPLICATION_JSON, + TypeConstrainedJacksonJsonHttpMessageConverter.class, request, response); + + assertThat(result).isSameAs(model); + } + + @Test // GH-1544 + void returnsNullBodyUnchanged() { + + Object result = advice.beforeBodyWrite(null, parameter, MediaType.APPLICATION_JSON, + TypeConstrainedJacksonJsonHttpMessageConverter.class, request, response); + + assertThat(result).isNull(); + } + + // ------------------------------------------------------------------------- + // determineWriteHints() — filter propagation + // ------------------------------------------------------------------------- + + @Test // GH-1544 + void ignoresIncompatibleJackson2FilterProviderAndReturnsNoHints() { + + // Jackson 2 FilterProvider is incompatible with Jackson 3 — it should be skipped (with a warning) + com.fasterxml.jackson.databind.ser.FilterProvider jackson2Filter = + mock(com.fasterxml.jackson.databind.ser.FilterProvider.class); + MappingJacksonValue wrapper = new MappingJacksonValue(new RepresentationModel<>()); + wrapper.setFilters(jackson2Filter); + + advice.beforeBodyWrite(wrapper, parameter, MediaType.APPLICATION_JSON, + TypeConstrainedJacksonJsonHttpMessageConverter.class, request, response); + + Map hints = advice.determineWriteHints(new RepresentationModel<>(), parameter, + MediaType.APPLICATION_JSON, TypeConstrainedJacksonJsonHttpMessageConverter.class); + + // No filter hint should be present since the Jackson 2 filter cannot be used with Jackson 3 + assertThat(hints).isNull(); + } + + @Test // GH-1544 + void propagatesSerializationViewAsHint() { + + MappingJacksonValue wrapper = new MappingJacksonValue(new RepresentationModel<>()); + wrapper.setSerializationView(Object.class); + + advice.beforeBodyWrite(wrapper, parameter, MediaType.APPLICATION_JSON, + TypeConstrainedJacksonJsonHttpMessageConverter.class, request, response); + + Map hints = advice.determineWriteHints(new RepresentationModel<>(), parameter, + MediaType.APPLICATION_JSON, TypeConstrainedJacksonJsonHttpMessageConverter.class); + + assertThat(hints).isNotNull(); + assertThat(hints).containsEntry(Class.class.getName(), Object.class); + } + + @Test // GH-1544 + void returnsNullHintsWhenNoFilterOrViewSet() { + + MappingJacksonValue wrapper = new MappingJacksonValue(new RepresentationModel<>()); + + advice.beforeBodyWrite(wrapper, parameter, MediaType.APPLICATION_JSON, + TypeConstrainedJacksonJsonHttpMessageConverter.class, request, response); + + Map hints = advice.determineWriteHints(new RepresentationModel<>(), parameter, + MediaType.APPLICATION_JSON, TypeConstrainedJacksonJsonHttpMessageConverter.class); + + assertThat(hints).isNull(); + } + + @Test // GH-1544 + void hintsAreConsumedAfterDetermineWriteHints() { + + MappingJacksonValue wrapper = new MappingJacksonValue(new RepresentationModel<>()); + wrapper.setSerializationView(Object.class); + + advice.beforeBodyWrite(wrapper, parameter, MediaType.APPLICATION_JSON, + TypeConstrainedJacksonJsonHttpMessageConverter.class, request, response); + + // First call consumes the hints + advice.determineWriteHints(new RepresentationModel<>(), parameter, + MediaType.APPLICATION_JSON, TypeConstrainedJacksonJsonHttpMessageConverter.class); + + // Second call should return null (ThreadLocal was cleared) + Map secondCallHints = advice.determineWriteHints(new RepresentationModel<>(), parameter, + MediaType.APPLICATION_JSON, TypeConstrainedJacksonJsonHttpMessageConverter.class); + + assertThat(secondCallHints).isNull(); + } + + @Test // GH-1544 + void registeredInRepositoryExporterHandlerAdapter() { + + // Verify the advice is wired into the configuration by checking it's instantiable + // (integration-level verification is done via RepositoryRestMvConfigurationIntegrationTests) + assertThat(new MappingJacksonValueResponseBodyAdvice()).isNotNull(); + } + +}