From fe7da9a9de042e0e598dd31e0e6b95ff886f12c7 Mon Sep 17 00:00:00 2001 From: asifebrahim Date: Tue, 19 Aug 2025 22:19:54 +0530 Subject: [PATCH 1/4] fixes 35343 --- .../JsonViewResponseBodyAdvice.java | 15 +- ...ViewResponseBodyAdviceClassLevelTests.java | 177 ++++++++++++++++++ 2 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/JsonViewResponseBodyAdviceClassLevelTests.java diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/JsonViewResponseBodyAdvice.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/JsonViewResponseBodyAdvice.java index f066b786e266..4802b1ffb02f 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/JsonViewResponseBodyAdvice.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/JsonViewResponseBodyAdvice.java @@ -33,12 +33,15 @@ /** * A {@link ResponseBodyAdvice} implementation that adds support for Jackson's * {@code @JsonView} annotation declared on a Spring MVC {@code @RequestMapping} - * or {@code @ExceptionHandler} method. + * or {@code @ExceptionHandler} method, or at the controller class level. * *

The serialization view specified in the annotation will be passed in to the * {@link org.springframework.http.converter.json.MappingJackson2HttpMessageConverter} * which will then use it to serialize the response body. * + *

When both method-level and class-level {@code @JsonView} annotations are present, + * the method-level annotation takes precedence. + * *

Note that despite {@code @JsonView} allowing for more than one class to * be specified, the use for a response body advice is only supported with * exactly one class argument. Consider the use of a composite interface. @@ -53,7 +56,9 @@ public class JsonViewResponseBodyAdvice extends AbstractMappingJacksonResponseBo @Override public boolean supports(MethodParameter returnType, Class> converterType) { - return super.supports(returnType, converterType) && returnType.hasMethodAnnotation(JsonView.class); + return super.supports(returnType, converterType) && + (returnType.hasMethodAnnotation(JsonView.class) || + returnType.getDeclaringClass().isAnnotationPresent(JsonView.class)); } @Override @@ -70,6 +75,12 @@ protected void beforeBodyWriteInternal(MappingJacksonValue bodyContainer, MediaT private static Class getJsonView(MethodParameter returnType) { JsonView ann = returnType.getMethodAnnotation(JsonView.class); + + // If no method-level annotation, check for class-level annotation + if (ann == null) { + ann = returnType.getDeclaringClass().getAnnotation(JsonView.class); + } + Assert.state(ann != null, "No JsonView annotation"); Class[] classes = ann.value(); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/JsonViewResponseBodyAdviceClassLevelTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/JsonViewResponseBodyAdviceClassLevelTests.java new file mode 100644 index 000000000000..fa029a3bdefd --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/JsonViewResponseBodyAdviceClassLevelTests.java @@ -0,0 +1,177 @@ +/* + * Copyright 2002-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.web.servlet.mvc.method.annotation; + +import java.lang.reflect.Method; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonView; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +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; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for class-level {@code @JsonView} support in {@link JsonViewResponseBodyAdvice}. + * + * @author Asif Ebrahim + * @since 7.0 + */ +class JsonViewResponseBodyAdviceClassLevelTests { + + private JsonViewResponseBodyAdvice advice; + + private ServerHttpRequest request; + + private ServerHttpResponse response; + + + @BeforeEach + void setup() { + this.advice = new JsonViewResponseBodyAdvice(); + this.request = new ServletServerHttpRequest(new MockHttpServletRequest()); + this.response = new ServletServerHttpResponse(new MockHttpServletResponse()); + } + + + @Test + void supportsWithClassLevelJsonView() throws Exception { + Method method = ClassLevelJsonViewController.class.getDeclaredMethod("methodWithoutAnnotation"); + MethodParameter returnType = new MethodParameter(method, -1); + + assertThat(this.advice.supports(returnType, MappingJackson2HttpMessageConverter.class)).isTrue(); + assertThat(this.advice.supports(returnType, JacksonJsonHttpMessageConverter.class)).isTrue(); + } + + @Test + void supportsWithMethodLevelJsonView() throws Exception { + Method method = RegularController.class.getDeclaredMethod("methodWithJsonView"); + MethodParameter returnType = new MethodParameter(method, -1); + + assertThat(this.advice.supports(returnType, MappingJackson2HttpMessageConverter.class)).isTrue(); + assertThat(this.advice.supports(returnType, JacksonJsonHttpMessageConverter.class)).isTrue(); + } + + @Test + void doesNotSupportWithoutJsonView() throws Exception { + Method method = RegularController.class.getDeclaredMethod("methodWithoutAnnotation"); + MethodParameter returnType = new MethodParameter(method, -1); + + assertThat(this.advice.supports(returnType, MappingJackson2HttpMessageConverter.class)).isFalse(); + assertThat(this.advice.supports(returnType, JacksonJsonHttpMessageConverter.class)).isFalse(); + } + + @Test + void beforeBodyWriteWithClassLevelJsonView() throws Exception { + Method method = ClassLevelJsonViewController.class.getDeclaredMethod("methodWithoutAnnotation"); + MethodParameter returnType = new MethodParameter(method, -1); + + MappingJacksonValue container = new MappingJacksonValue(new Object()); + this.advice.beforeBodyWriteInternal(container, MediaType.APPLICATION_JSON, returnType, this.request, this.response); + + assertThat(container.getSerializationView()).isEqualTo(MyJsonView.class); + } + + @Test + void beforeBodyWriteWithMethodLevelJsonView() throws Exception { + Method method = RegularController.class.getDeclaredMethod("methodWithJsonView"); + MethodParameter returnType = new MethodParameter(method, -1); + + MappingJacksonValue container = new MappingJacksonValue(new Object()); + this.advice.beforeBodyWriteInternal(container, MediaType.APPLICATION_JSON, returnType, this.request, this.response); + + assertThat(container.getSerializationView()).isEqualTo(MyJsonView.class); + } + + @Test + void methodLevelAnnotationTakesPrecedenceOverClassLevel() throws Exception { + Method method = ClassLevelJsonViewController.class.getDeclaredMethod("methodWithDifferentJsonView"); + MethodParameter returnType = new MethodParameter(method, -1); + + MappingJacksonValue container = new MappingJacksonValue(new Object()); + this.advice.beforeBodyWriteInternal(container, MediaType.APPLICATION_JSON, returnType, this.request, this.response); + + // Method-level annotation should take precedence + assertThat(container.getSerializationView()).isEqualTo(AnotherJsonView.class); + } + + @Test + void determineWriteHintsWithClassLevelJsonView() throws Exception { + Method method = ClassLevelJsonViewController.class.getDeclaredMethod("methodWithoutAnnotation"); + MethodParameter returnType = new MethodParameter(method, -1); + + var hints = this.advice.determineWriteHints(new Object(), returnType, MediaType.APPLICATION_JSON, MappingJackson2HttpMessageConverter.class); + + assertThat(hints).containsEntry(JsonView.class.getName(), MyJsonView.class); + } + + + // Test interfaces for JsonView + private interface MyJsonView {} + + private interface AnotherJsonView {} + + // Test controller with class-level @JsonView + @JsonView(MyJsonView.class) + private static class ClassLevelJsonViewController { + + @RequestMapping + @ResponseBody + public String methodWithoutAnnotation() { + return "test"; + } + + @RequestMapping + @ResponseBody + @JsonView(AnotherJsonView.class) + public String methodWithDifferentJsonView() { + return "test"; + } + } + + // Test controller without class-level @JsonView + private static class RegularController { + + @RequestMapping + @ResponseBody + @JsonView(MyJsonView.class) + public String methodWithJsonView() { + return "test"; + } + + @RequestMapping + @ResponseBody + public String methodWithoutAnnotation() { + return "test"; + } + } + +} From b93159835c14f6a60fc57c23f74bab9a77371749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 18 Aug 2025 15:45:19 +0200 Subject: [PATCH 2/4] Upgrade nullability plugin to 0.0.4 This commit also includes related refinements of JdbcTemplate#getSingleColumnRowMapper and ObjectUtils#addObjectToArray. Closes gh-35340 --- build.gradle | 2 +- .../main/java/org/springframework/util/ObjectUtils.java | 7 ++++--- .../java/org/springframework/jdbc/core/JdbcTemplate.java | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/build.gradle b/build.gradle index 01b6cfcd4c4c..d0dce05f0fae 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { id 'com.github.bjornvester.xjc' version '1.8.2' apply false id 'io.github.goooler.shadow' version '8.1.8' apply false id 'me.champeau.jmh' version '0.7.2' apply false - id "io.spring.nullability" version "0.0.1" apply false + id 'io.spring.nullability' version '0.0.4' apply false } ext { diff --git a/spring-core/src/main/java/org/springframework/util/ObjectUtils.java b/spring-core/src/main/java/org/springframework/util/ObjectUtils.java index 98fedfd795b2..c32075563ead 100644 --- a/spring-core/src/main/java/org/springframework/util/ObjectUtils.java +++ b/spring-core/src/main/java/org/springframework/util/ObjectUtils.java @@ -255,7 +255,7 @@ public static > E caseInsensitiveValueOf(E[] enumValues, Strin * @param obj the object to append * @return the new array (of the same component type; never {@code null}) */ - public static A[] addObjectToArray(A @Nullable [] array, @Nullable O obj) { + public static A[] addObjectToArray(A @Nullable [] array, O obj) { return addObjectToArray(array, obj, (array != null ? array.length : 0)); } @@ -268,17 +268,18 @@ public static A[] addObjectToArray(A @Nullable [] array, @Nulla * @return the new array (of the same component type; never {@code null}) * @since 6.0 */ - public static @Nullable A[] addObjectToArray(A @Nullable [] array, @Nullable O obj, int position) { + public static A[] addObjectToArray(A @Nullable [] array, O obj, int position) { Class componentType = Object.class; if (array != null) { componentType = array.getClass().componentType(); } + // Defensive code for use cases not following the declared nullability else if (obj != null) { componentType = obj.getClass(); } int newArrayLength = (array != null ? array.length + 1 : 1); @SuppressWarnings("unchecked") - @Nullable A[] newArray = (A[]) Array.newInstance(componentType, newArrayLength); + A[] newArray = (A[]) Array.newInstance(componentType, newArrayLength); if (array != null) { System.arraycopy(array, 0, newArray, 0, position); System.arraycopy(array, position, newArray, position + 1, array.length - position); diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java index 734b217ab7c2..096d397cd37d 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java @@ -1413,7 +1413,7 @@ else if (param.getResultSetExtractor() != null) { * @return the RowMapper to use * @see SingleColumnRowMapper */ - protected RowMapper<@Nullable T> getSingleColumnRowMapper(Class requiredType) { + protected RowMapper getSingleColumnRowMapper(Class requiredType) { return new SingleColumnRowMapper<>(requiredType); } From d84ee461eb29206bf0d286721b4a452d3e81ece2 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:27:39 +0200 Subject: [PATCH 3/4] Wrap exceptionally long lines --- .../springframework/dao/support/DataAccessUtils.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java b/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java index 9327c8cc9ff1..58309754b55b 100644 --- a/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java +++ b/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java @@ -116,7 +116,9 @@ public abstract class DataAccessUtils { * element has been found in the given Collection * @since 6.1 */ - public static Optional<@NonNull T> optionalResult(@Nullable Collection results) throws IncorrectResultSizeDataAccessException { + public static Optional<@NonNull T> optionalResult(@Nullable Collection results) + throws IncorrectResultSizeDataAccessException { + return Optional.ofNullable(singleResult(results)); } @@ -159,7 +161,9 @@ public static Optional optionalResult(@Nullable Iterator results) thro * @throws EmptyResultDataAccessException if no element at all * has been found in the given Collection */ - public static @NonNull T requiredSingleResult(@Nullable Collection results) throws IncorrectResultSizeDataAccessException { + public static @NonNull T requiredSingleResult(@Nullable Collection results) + throws IncorrectResultSizeDataAccessException { + if (CollectionUtils.isEmpty(results)) { throw new EmptyResultDataAccessException(1); } @@ -185,7 +189,9 @@ public static Optional optionalResult(@Nullable Iterator results) thro * has been found in the given Collection * @since 5.0.2 */ - public static T nullableSingleResult(@Nullable Collection results) throws IncorrectResultSizeDataAccessException { + public static T nullableSingleResult(@Nullable Collection results) + throws IncorrectResultSizeDataAccessException { + // This is identical to the requiredSingleResult implementation but differs in the // semantics of the incoming Collection (which we currently can't formally express) if (CollectionUtils.isEmpty(results)) { From f4bd0e5d12c2f25c0c83056e2e653cc0cdf9af67 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:30:47 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Remove=20redundant=20declarations=20of=20JS?= =?UTF-8?q?pecify's=20@=E2=81=A0NonNull=20annotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes gh-35341 --- .../beans/ExtendedBeanInfoFactory.java | 4 +--- .../core/annotation/TypeMappedAnnotations.java | 3 +-- .../core/annotation/AnnotationsScannerTests.java | 13 ++++++------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfoFactory.java b/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfoFactory.java index 8532d26e40ee..2a80f47584a2 100644 --- a/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfoFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfoFactory.java @@ -20,8 +20,6 @@ import java.beans.IntrospectionException; import java.lang.reflect.Method; -import org.jspecify.annotations.NonNull; - import org.springframework.core.Ordered; /** @@ -44,7 +42,7 @@ public class ExtendedBeanInfoFactory extends StandardBeanInfoFactory { @Override - public @NonNull BeanInfo getBeanInfo(Class beanClass) throws IntrospectionException { + public BeanInfo getBeanInfo(Class beanClass) throws IntrospectionException { BeanInfo beanInfo = super.getBeanInfo(beanClass); return (supports(beanClass) ? new ExtendedBeanInfo(beanInfo) : beanInfo); } diff --git a/spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotations.java b/spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotations.java index 310348a5cf8a..2c20d6766b0d 100644 --- a/spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotations.java +++ b/spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotations.java @@ -29,7 +29,6 @@ import java.util.stream.Stream; import java.util.stream.StreamSupport; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; /** @@ -483,7 +482,7 @@ private void addAggregateAnnotations(List aggregateAnnotations, @Nul } @Override - public @NonNull List finish(@Nullable List processResult) { + public List finish(@Nullable List processResult) { return this.aggregates; } } diff --git a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationsScannerTests.java b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationsScannerTests.java index 6cb5c2b2248a..c1f06239b783 100644 --- a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationsScannerTests.java +++ b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationsScannerTests.java @@ -29,7 +29,6 @@ import java.util.function.Predicate; import java.util.stream.Stream; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -470,13 +469,13 @@ void scanWhenProcessorReturnsFromDoWithAggregateExitsEarly() { new AnnotationsProcessor() { @Override - public @NonNull String doWithAggregate(Object context, int aggregateIndex) { + public String doWithAggregate(Object context, int aggregateIndex) { return ""; } @Override - public @NonNull String doWithAnnotations(Object context, int aggregateIndex, - @Nullable Object source, @Nullable Annotation @Nullable [] annotations) { + public String doWithAnnotations(Object context, int aggregateIndex, + @Nullable Object source, @Nullable Annotation[] annotations) { throw new IllegalStateException("Should not call"); } @@ -502,13 +501,13 @@ void scanWhenProcessorHasFinishMethodUsesFinishResult() { new AnnotationsProcessor() { @Override - public @NonNull String doWithAnnotations(Object context, int aggregateIndex, - @Nullable Object source, @Nullable Annotation @Nullable [] annotations) { + public String doWithAnnotations(Object context, int aggregateIndex, + @Nullable Object source, @Nullable Annotation[] annotations) { return "K"; } @Override - public @NonNull String finish(@Nullable String result) { + public String finish(@Nullable String result) { return "O" + result; }