From e9084ddd862ba17f440c7f4184fcbc6e04c49c7a Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 9 Oct 2025 21:31:38 +0100 Subject: [PATCH 01/10] Provide auto-configuration for Jackson 2 See gh-47688 --- module/spring-boot-jackson2/build.gradle | 51 ++ .../boot/jackson2/JsonComponent.java | 116 +++ .../boot/jackson2/JsonComponentModule.java | 210 +++++ .../boot/jackson2/JsonMixin.java | 61 ++ .../boot/jackson2/JsonMixinModule.java | 48 ++ .../boot/jackson2/JsonMixinModuleEntries.java | 176 ++++ ...leEntriesBeanRegistrationAotProcessor.java | 118 +++ .../boot/jackson2/JsonObjectDeserializer.java | 158 ++++ .../boot/jackson2/JsonObjectSerializer.java | 66 ++ .../Jackson2AutoConfiguration.java | 393 +++++++++ .../Jackson2BackgroundPreinitializer.java | 37 + ...Jackson2ObjectMapperBuilderCustomizer.java | 42 + .../autoconfigure/Jackson2Properties.java | 272 +++++++ .../jackson2/autoconfigure/package-info.java | 21 + .../boot/jackson2/package-info.java | 21 + ...itional-spring-configuration-metadata.json | 12 + .../main/resources/META-INF/spring.factories | 3 + .../resources/META-INF/spring/aot.factories | 8 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../jackson2/JsonComponentModuleTests.java | 254 ++++++ ...riesBeanRegistrationAotProcessorTests.java | 149 ++++ .../boot/jackson2/JsonMixinModuleTests.java | 128 +++ .../jackson2/JsonObjectDeserializerTests.java | 221 +++++ .../jackson2/JsonObjectSerializerTests.java | 46 ++ .../jackson2/NameAndAgeJsonComponent.java | 67 ++ .../jackson2/NameAndAgeJsonKeyComponent.java | 59 ++ .../jackson2/NameAndCareerJsonComponent.java | 63 ++ .../Jackson2AutoConfigurationTests.java | 770 ++++++++++++++++++ .../jackson2/scan/a/RenameMixInClass.java | 33 + .../scan/b/RenameMixInAbstractClass.java | 32 + .../jackson2/scan/c/RenameMixInInterface.java | 32 + .../boot/jackson2/scan/d/EmptyMixInClass.java | 28 + .../jackson2/scan/e/PrivateMixInClass.java | 35 + .../boot/jackson2/scan/f/EmptyMixIn.java | 31 + .../boot/jackson2/types/Name.java | 38 + .../boot/jackson2/types/NameAndAge.java | 73 ++ .../boot/jackson2/types/NameAndCareer.java | 40 + .../spring-boot-dependencies/build.gradle | 1 + settings.gradle | 1 + 39 files changed, 3915 insertions(+) create mode 100644 module/spring-boot-jackson2/build.gradle create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonComponent.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonComponentModule.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixin.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModule.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModuleEntries.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonObjectDeserializer.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonObjectSerializer.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2AutoConfiguration.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2BackgroundPreinitializer.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2ObjectMapperBuilderCustomizer.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2Properties.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/package-info.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/package-info.java create mode 100644 module/spring-boot-jackson2/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 module/spring-boot-jackson2/src/main/resources/META-INF/spring.factories create mode 100644 module/spring-boot-jackson2/src/main/resources/META-INF/spring/aot.factories create mode 100644 module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonComponentModuleTests.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonMixinModuleEntriesBeanRegistrationAotProcessorTests.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonMixinModuleTests.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonObjectDeserializerTests.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonObjectSerializerTests.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndAgeJsonComponent.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndAgeJsonKeyComponent.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndCareerJsonComponent.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/autoconfigure/Jackson2AutoConfigurationTests.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/a/RenameMixInClass.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/b/RenameMixInAbstractClass.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/c/RenameMixInInterface.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/d/EmptyMixInClass.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/e/PrivateMixInClass.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/f/EmptyMixIn.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/Name.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/NameAndAge.java create mode 100644 module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/NameAndCareer.java diff --git a/module/spring-boot-jackson2/build.gradle b/module/spring-boot-jackson2/build.gradle new file mode 100644 index 00000000000..8a7664b2191 --- /dev/null +++ b/module/spring-boot-jackson2/build.gradle @@ -0,0 +1,51 @@ +/* + * Copyright 2012-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. + */ + +plugins { + id "java-library" + id "org.springframework.boot.auto-configuration" + id "org.springframework.boot.configuration-properties" + id "org.springframework.boot.deployed" + id "org.springframework.boot.optional-dependencies" +} + +description = "Spring Boot Jackson 2 (deprecated in favor of spring-boot-jackson)" + +dependencies { + api(project(":core:spring-boot")) + api("org.springframework:spring-web") + api("com.fasterxml.jackson.core:jackson-databind") + api("com.fasterxml.jackson.datatype:jackson-datatype-jdk8") + api("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + api("com.fasterxml.jackson.module:jackson-module-parameter-names") + + optional(project(":core:spring-boot-autoconfigure")) + optional(project(":core:spring-boot-test-autoconfigure")) + optional(project(":module:spring-boot-jackson")) { + exclude(group: "tools.jackson.core", module: "jackson-databind") + } + optional("org.springframework:spring-web") + optional("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor") + optional("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") + + testImplementation(project(":core:spring-boot-test")) + testImplementation(project(":test-support:spring-boot-test-support")) + testRuntimeOnly("ch.qos.logback:logback-classic") +} + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonComponent.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonComponent.java new file mode 100644 index 00000000000..8753d729b07 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonComponent.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.KeyDeserializer; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + +/** + * {@link Component @Component} that provides {@link JsonSerializer}, + * {@link JsonDeserializer} or {@link KeyDeserializer} implementations to be registered + * with Jackson when {@link JsonComponentModule} is in use. Can be used to annotate + * implementations directly or a class that contains them as inner-classes. For example: + *
+ * @JsonComponent
+ * public class CustomerJsonComponent {
+ *
+ *     public static class Serializer extends JsonSerializer<Customer> {
+ *
+ *         // ...
+ *
+ *     }
+ *
+ *     public static class Deserializer extends JsonDeserializer<Customer> {
+ *
+ *         // ...
+ *
+ *     }
+ *
+ * }
+ *
+ * 
+ * + * @see JsonComponentModule + * @since 4.0.0 + * @author Phillip Webb + * @author Paul Aly + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 and + * {@link org.springframework.boot.jackson.JacksonComponent}. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +@Deprecated(since = "4.0.0", forRemoval = true) +public @interface JsonComponent { + + /** + * The value may indicate a suggestion for a logical component name, to be turned into + * a Spring bean in case of an autodetected component. + * @return the component name + */ + @AliasFor(annotation = Component.class) + String value() default ""; + + /** + * The types that are handled by the provided serializer/deserializer. This attribute + * is mandatory for a {@link KeyDeserializer}, as the type cannot be inferred. For a + * {@link JsonSerializer} or {@link JsonDeserializer} it can be used to limit handling + * to a subclasses of type inferred from the generic. + * @return the types that should be handled by the component + */ + Class[] type() default {}; + + /** + * The scope under which the serializer/deserializer should be registered with the + * module. + * @return the component's handle type + */ + Scope scope() default Scope.VALUES; + + /** + * The various scopes under which a serializer/deserializer can be registered. + */ + enum Scope { + + /** + * A serializer/deserializer for regular value content. + * @see JsonSerializer + * @see JsonDeserializer + */ + VALUES, + + /** + * A serializer/deserializer for keys. + * @see JsonSerializer + * @see KeyDeserializer + */ + KEYS + + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonComponentModule.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonComponentModule.java new file mode 100644 index 00000000000..8421e08aaa6 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonComponentModule.java @@ -0,0 +1,210 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.KeyDeserializer; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.generate.GenerationContext; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.ReflectionHints; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.HierarchicalBeanFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor; +import org.springframework.beans.factory.aot.BeanFactoryInitializationCode; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Spring Bean and Jackson {@link Module} to register {@link JsonComponent @JsonComponent} + * annotated beans. + * + * @author Phillip Webb + * @author Paul Aly + * @since 4.0.0 + * @see JsonComponent + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 and + * {@link org.springframework.boot.jackson.JacksonComponentModule}. + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public class JsonComponentModule extends SimpleModule implements BeanFactoryAware, InitializingBean { + + @SuppressWarnings("NullAway.Init") + private BeanFactory beanFactory; + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + @Override + public void afterPropertiesSet() { + registerJsonComponents(); + } + + public void registerJsonComponents() { + BeanFactory beanFactory = this.beanFactory; + while (beanFactory != null) { + if (beanFactory instanceof ListableBeanFactory listableBeanFactory) { + addJsonBeans(listableBeanFactory); + } + beanFactory = (beanFactory instanceof HierarchicalBeanFactory hierarchicalBeanFactory) + ? hierarchicalBeanFactory.getParentBeanFactory() : null; + } + } + + private void addJsonBeans(ListableBeanFactory beanFactory) { + Map beans = beanFactory.getBeansWithAnnotation(JsonComponent.class); + for (Object bean : beans.values()) { + addJsonBean(bean); + } + } + + private void addJsonBean(Object bean) { + MergedAnnotation annotation = MergedAnnotations + .from(bean.getClass(), SearchStrategy.TYPE_HIERARCHY) + .get(JsonComponent.class); + Class[] types = annotation.getClassArray("type"); + JsonComponent.Scope scope = annotation.getEnum("scope", JsonComponent.Scope.class); + addJsonBean(bean, types, scope); + } + + private void addJsonBean(Object bean, Class[] types, JsonComponent.Scope scope) { + if (bean instanceof JsonSerializer jsonSerializer) { + addJsonSerializerBean(jsonSerializer, scope, types); + } + else if (bean instanceof JsonDeserializer jsonDeserializer) { + addJsonDeserializerBean(jsonDeserializer, types); + } + else if (bean instanceof KeyDeserializer keyDeserializer) { + addKeyDeserializerBean(keyDeserializer, types); + } + for (Class innerClass : bean.getClass().getDeclaredClasses()) { + if (isSuitableInnerClass(innerClass)) { + Object innerInstance = BeanUtils.instantiateClass(innerClass); + addJsonBean(innerInstance, types, scope); + } + } + } + + private static boolean isSuitableInnerClass(Class innerClass) { + return !Modifier.isAbstract(innerClass.getModifiers()) && (JsonSerializer.class.isAssignableFrom(innerClass) + || JsonDeserializer.class.isAssignableFrom(innerClass) + || KeyDeserializer.class.isAssignableFrom(innerClass)); + } + + @SuppressWarnings("unchecked") + private void addJsonSerializerBean(JsonSerializer serializer, JsonComponent.Scope scope, Class[] types) { + Class baseType = (Class) ResolvableType.forClass(JsonSerializer.class, serializer.getClass()) + .resolveGeneric(); + Assert.state(baseType != null, "'baseType' must not be null"); + addBeanToModule(serializer, baseType, types, + (scope == JsonComponent.Scope.VALUES) ? this::addSerializer : this::addKeySerializer); + + } + + @SuppressWarnings("unchecked") + private void addJsonDeserializerBean(JsonDeserializer deserializer, Class[] types) { + Class baseType = (Class) ResolvableType.forClass(JsonDeserializer.class, deserializer.getClass()) + .resolveGeneric(); + Assert.state(baseType != null, "'baseType' must not be null"); + addBeanToModule(deserializer, baseType, types, this::addDeserializer); + } + + private void addKeyDeserializerBean(KeyDeserializer deserializer, Class[] types) { + Assert.notEmpty(types, "'types' must not be empty"); + addBeanToModule(deserializer, Object.class, types, this::addKeyDeserializer); + } + + @SuppressWarnings("unchecked") + private void addBeanToModule(E element, Class baseType, Class[] types, + BiConsumer, E> consumer) { + if (ObjectUtils.isEmpty(types)) { + consumer.accept(baseType, element); + return; + } + for (Class type : types) { + Assert.isAssignable(baseType, type); + consumer.accept((Class) type, element); + } + } + + static class JsonComponentBeanFactoryInitializationAotProcessor implements BeanFactoryInitializationAotProcessor { + + @Override + public @Nullable BeanFactoryInitializationAotContribution processAheadOfTime( + ConfigurableListableBeanFactory beanFactory) { + String[] jsonComponents = beanFactory.getBeanNamesForAnnotation(JsonComponent.class); + Map, List>> innerComponents = new HashMap<>(); + for (String jsonComponent : jsonComponents) { + Class type = beanFactory.getType(jsonComponent, true); + Assert.state(type != null, "'type' must not be null"); + for (Class declaredClass : type.getDeclaredClasses()) { + if (isSuitableInnerClass(declaredClass)) { + innerComponents.computeIfAbsent(type, (t) -> new ArrayList<>()).add(declaredClass); + } + } + } + return innerComponents.isEmpty() ? null : new JsonComponentAotContribution(innerComponents); + } + + } + + private static final class JsonComponentAotContribution implements BeanFactoryInitializationAotContribution { + + private final Map, List>> innerComponents; + + private JsonComponentAotContribution(Map, List>> innerComponents) { + this.innerComponents = innerComponents; + } + + @Override + public void applyTo(GenerationContext generationContext, + BeanFactoryInitializationCode beanFactoryInitializationCode) { + ReflectionHints reflection = generationContext.getRuntimeHints().reflection(); + this.innerComponents.forEach((outer, inners) -> { + reflection.registerType(outer); + inners.forEach((inner) -> reflection.registerType(inner, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)); + }); + } + + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixin.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixin.java new file mode 100644 index 00000000000..aca7164a924 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixin.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Provides a mixin class implementation that registers with Jackson when using + * {@link JsonMixinModule}. + * + * @author Guirong Hu + * @see JsonMixinModule + * @since 4.0.0 + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 and + * {@link org.springframework.boot.jackson.JacksonMixin}. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public @interface JsonMixin { + + /** + * Alias for the {@link #type()} attribute. Allows for more concise annotation + * declarations e.g.: {@code @JsonMixin(MyType.class)} instead of + * {@code @JsonMixin(type=MyType.class)}. + * @return the mixed-in classes + */ + @AliasFor("type") + Class[] value() default {}; + + /** + * The types that are handled by the provided mix-in class. {@link #value()} is an + * alias for (and mutually exclusive with) this attribute. + * @return the mixed-in classes + */ + @AliasFor("value") + Class[] type() default {}; + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModule.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModule.java new file mode 100644 index 00000000000..3d638f67626 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModule.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.jspecify.annotations.Nullable; + +/** + * Spring Bean and Jackson {@link Module} to find and + * {@link SimpleModule#setMixInAnnotation(Class, Class) register} + * {@link JsonMixin @JsonMixin}-annotated classes. + * + * @author Guirong Hu + * @author Stephane Nicoll + * @since 4.0.0 + * @see JsonMixin + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 and + * {@link org.springframework.boot.jackson.JacksonMixinModule} + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public class JsonMixinModule extends SimpleModule { + + /** + * Register the specified {@link JsonMixinModuleEntries entries}. + * @param entries the entries to register to this instance + * @param classLoader the classloader to use + */ + public void registerEntries(JsonMixinModuleEntries entries, @Nullable ClassLoader classLoader) { + entries.doWithEntry(classLoader, this::setMixInAnnotation); + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModuleEntries.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModuleEntries.java new file mode 100644 index 00000000000..201c836f1f7 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModuleEntries.java @@ -0,0 +1,176 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Provide the mapping of json mixin class to consider. + * + * @author Stephane Nicoll + * @since 4.0.0 + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 and + * {@link JsonMixinModuleEntries} + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public final class JsonMixinModuleEntries { + + private final Map entries; + + private JsonMixinModuleEntries(Builder builder) { + this.entries = new LinkedHashMap<>(builder.entries); + } + + /** + * Create an instance using the specified {@link Builder}. + * @param mixins a consumer of the builder + * @return an instance with the state of the customized builder. + */ + public static JsonMixinModuleEntries create(Consumer mixins) { + Builder builder = new Builder(); + mixins.accept(builder); + return builder.build(); + } + + /** + * Scan the classpath for {@link JsonMixin @JsonMixin} in the specified + * {@code basePackages}. + * @param context the application context to use + * @param basePackages the base packages to consider + * @return an instance with the result of the scanning + */ + public static JsonMixinModuleEntries scan(ApplicationContext context, Collection basePackages) { + return JsonMixinModuleEntries.create((builder) -> { + if (ObjectUtils.isEmpty(basePackages)) { + return; + } + JsonMixinComponentScanner scanner = new JsonMixinComponentScanner(); + scanner.setEnvironment(context.getEnvironment()); + scanner.setResourceLoader(context); + for (String basePackage : basePackages) { + if (StringUtils.hasText(basePackage)) { + for (BeanDefinition candidate : scanner.findCandidateComponents(basePackage)) { + String beanClassName = candidate.getBeanClassName(); + Assert.state(beanClassName != null, "'beanClassName' must not be null"); + Class mixinClass = ClassUtils.resolveClassName(beanClassName, context.getClassLoader()); + registerMixinClass(builder, mixinClass); + } + } + } + }); + } + + private static void registerMixinClass(Builder builder, Class mixinClass) { + MergedAnnotation annotation = MergedAnnotations + .from(mixinClass, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY) + .get(JsonMixin.class); + Class[] types = annotation.getClassArray("type"); + Assert.state(!ObjectUtils.isEmpty(types), + () -> "@JsonMixin annotation on class '" + mixinClass.getName() + "' does not specify any types"); + for (Class type : types) { + builder.and(type, mixinClass); + } + } + + /** + * Perform an action on each entry defined by this instance. If a class needs to be + * resolved from its class name, the specified {@link ClassLoader} is used. + * @param classLoader the classloader to use to resolve class name if necessary + * @param action the action to invoke on each type to mixin class entry + */ + public void doWithEntry(@Nullable ClassLoader classLoader, BiConsumer, Class> action) { + this.entries.forEach((type, mixin) -> action.accept(resolveClassNameIfNecessary(type, classLoader), + resolveClassNameIfNecessary(mixin, classLoader))); + } + + private Class resolveClassNameIfNecessary(Object nameOrType, @Nullable ClassLoader classLoader) { + return (nameOrType instanceof Class type) ? type + : ClassUtils.resolveClassName((String) nameOrType, classLoader); + } + + /** + * Builder for {@link JsonMixinModuleEntries}. + */ + public static class Builder { + + private final Map entries; + + Builder() { + this.entries = new LinkedHashMap<>(); + } + + /** + * Add a mapping for the specified class names. + * @param typeClassName the type class name + * @param mixinClassName the mixin class name + * @return {@code this}, to facilitate method chaining + */ + public Builder and(String typeClassName, String mixinClassName) { + this.entries.put(typeClassName, mixinClassName); + return this; + } + + /** + * Add a mapping for the specified classes. + * @param type the type class + * @param mixinClass the mixin class + * @return {@code this}, to facilitate method chaining + */ + public Builder and(Class type, Class mixinClass) { + this.entries.put(type, mixinClass); + return this; + } + + JsonMixinModuleEntries build() { + return new JsonMixinModuleEntries(this); + } + + } + + static class JsonMixinComponentScanner extends ClassPathScanningCandidateComponentProvider { + + JsonMixinComponentScanner() { + addIncludeFilter(new AnnotationTypeFilter(JsonMixin.class)); + } + + @Override + protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { + return true; + } + + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java new file mode 100644 index 00000000000..2fd8ccc530e --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java @@ -0,0 +1,118 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.util.LinkedHashSet; +import java.util.Set; + +import javax.lang.model.element.Modifier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.generate.AccessControl; +import org.springframework.aot.generate.GeneratedMethod; +import org.springframework.aot.generate.GenerationContext; +import org.springframework.aot.hint.BindingReflectionHintsRegistrar; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.beans.factory.aot.BeanRegistrationAotContribution; +import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; +import org.springframework.beans.factory.aot.BeanRegistrationCode; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator; +import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.CodeBlock; + +/** + * {@link BeanRegistrationAotProcessor} that replaces any {@link JsonMixinModuleEntries} + * by an hard-coded equivalent. This has the effect of disabling scanning at runtime. + * + * @author Stephane Nicoll + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +class JsonMixinModuleEntriesBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor { + + @Override + public @Nullable BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) { + if (registeredBean.getBeanClass().equals(JsonMixinModuleEntries.class)) { + return BeanRegistrationAotContribution + .withCustomCodeFragments((codeFragments) -> new AotContribution(codeFragments, registeredBean)); + } + return null; + } + + static class AotContribution extends BeanRegistrationCodeFragmentsDecorator { + + private static final Class BEAN_TYPE = JsonMixinModuleEntries.class; + + private final RegisteredBean registeredBean; + + private final @Nullable ClassLoader classLoader; + + AotContribution(BeanRegistrationCodeFragments delegate, RegisteredBean registeredBean) { + super(delegate); + this.registeredBean = registeredBean; + this.classLoader = registeredBean.getBeanFactory().getBeanClassLoader(); + } + + @Override + public ClassName getTarget(RegisteredBean registeredBean) { + return ClassName.get(BEAN_TYPE); + } + + @Override + public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext, + BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) { + JsonMixinModuleEntries entries = this.registeredBean.getBeanFactory() + .getBean(this.registeredBean.getBeanName(), JsonMixinModuleEntries.class); + contributeHints(generationContext.getRuntimeHints(), entries); + GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> { + method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName()); + method.addModifiers(Modifier.PRIVATE, Modifier.STATIC); + method.returns(BEAN_TYPE); + CodeBlock.Builder code = CodeBlock.builder(); + code.add("return $T.create(", JsonMixinModuleEntries.class).beginControlFlow("(mixins) ->"); + entries.doWithEntry(this.classLoader, (type, mixin) -> addEntryCode(code, type, mixin)); + code.endControlFlow(")"); + method.addCode(code.build()); + }); + return generatedMethod.toMethodReference().toCodeBlock(); + } + + private void addEntryCode(CodeBlock.Builder code, Class type, Class mixin) { + AccessControl accessForTypes = AccessControl.lowest(AccessControl.forClass(type), + AccessControl.forClass(mixin)); + if (accessForTypes.isPublic()) { + code.addStatement("$L.and($T.class, $T.class)", "mixins", type, mixin); + } + else { + code.addStatement("$L.and($S, $S)", "mixins", type.getName(), mixin.getName()); + } + } + + private void contributeHints(RuntimeHints runtimeHints, JsonMixinModuleEntries entries) { + Set> mixins = new LinkedHashSet<>(); + entries.doWithEntry(this.classLoader, (type, mixin) -> mixins.add(mixin)); + new BindingReflectionHintsRegistrar().registerReflectionHints(runtimeHints.reflection(), + mixins.toArray(Class[]::new)); + } + + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonObjectDeserializer.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonObjectDeserializer.java new file mode 100644 index 00000000000..db3c2629da8 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonObjectDeserializer.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.function.Function; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.core.TreeNode; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.NullNode; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.jackson.ObjectValueDeserializer; +import org.springframework.util.Assert; + +/** + * Helper base class for {@link JsonDeserializer} implementations that deserialize + * objects. + * + * @param the supported object type + * @author Phillip Webb + * @since 4.0.0 + * @see JsonObjectSerializer + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 and + * {@link ObjectValueDeserializer} + */ +@Deprecated(since = "4.0.0", forRemoval = true) +public abstract class JsonObjectDeserializer extends com.fasterxml.jackson.databind.JsonDeserializer { + + @Override + public final T deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { + try { + ObjectCodec codec = jp.getCodec(); + JsonNode tree = codec.readTree(jp); + return deserializeObject(jp, ctxt, codec, tree); + } + catch (Exception ex) { + if (ex instanceof IOException ioException) { + throw ioException; + } + throw new JsonMappingException(jp, "Object deserialize error", ex); + } + } + + /** + * Deserialize JSON content into the value type this serializer handles. + * @param jsonParser the source parser used for reading JSON content + * @param context context that can be used to access information about this + * deserialization activity + * @param codec the {@link ObjectCodec} associated with the parser + * @param tree deserialized JSON content as tree expressed using set of + * {@link TreeNode} instances + * @return the deserialized object + * @throws IOException on error + * @see #deserialize(JsonParser, DeserializationContext) + */ + protected abstract T deserializeObject(JsonParser jsonParser, DeserializationContext context, ObjectCodec codec, + JsonNode tree) throws IOException; + + /** + * Helper method to extract a value from the given {@code jsonNode} or return + * {@code null} when the node itself is {@code null}. + * @param jsonNode the source node (may be {@code null}) + * @param type the data type. May be {@link String}, {@link Boolean}, {@link Long}, + * {@link Integer}, {@link Short}, {@link Double}, {@link Float}, {@link BigDecimal} + * or {@link BigInteger}. + * @param the data type requested + * @param the result type + * @param mapper a mapper to convert the value when it is not {@code null} + * @return the node value or {@code null} + * @since 3.4.0 + */ + protected final @Nullable R nullSafeValue(JsonNode jsonNode, Class type, Function mapper) { + D value = nullSafeValue(jsonNode, type); + return (value != null) ? mapper.apply(value) : null; + } + + /** + * Helper method to extract a value from the given {@code jsonNode} or return + * {@code null} when the node itself is {@code null}. + * @param jsonNode the source node (may be {@code null}) + * @param type the data type. May be {@link String}, {@link Boolean}, {@link Long}, + * {@link Integer}, {@link Short}, {@link Double}, {@link Float}, {@link BigDecimal} + * or {@link BigInteger}. + * @param the data type requested + * @return the node value or {@code null} + */ + @SuppressWarnings({ "unchecked" }) + protected final @Nullable D nullSafeValue(JsonNode jsonNode, Class type) { + Assert.notNull(type, "'type' must not be null"); + if (jsonNode == null) { + return null; + } + if (type == String.class) { + return (D) jsonNode.textValue(); + } + if (type == Boolean.class) { + return (D) Boolean.valueOf(jsonNode.booleanValue()); + } + if (type == Long.class) { + return (D) Long.valueOf(jsonNode.longValue()); + } + if (type == Integer.class) { + return (D) Integer.valueOf(jsonNode.intValue()); + } + if (type == Short.class) { + return (D) Short.valueOf(jsonNode.shortValue()); + } + if (type == Double.class) { + return (D) Double.valueOf(jsonNode.doubleValue()); + } + if (type == Float.class) { + return (D) Float.valueOf(jsonNode.floatValue()); + } + if (type == BigDecimal.class) { + return (D) jsonNode.decimalValue(); + } + if (type == BigInteger.class) { + return (D) jsonNode.bigIntegerValue(); + } + throw new IllegalArgumentException("Unsupported value type " + type.getName()); + } + + /** + * Helper method to return a {@link JsonNode} from the tree. + * @param tree the source tree + * @param fieldName the field name to extract + * @return the {@link JsonNode} + */ + protected final JsonNode getRequiredNode(JsonNode tree, String fieldName) { + Assert.notNull(tree, "'tree' must not be null"); + JsonNode node = tree.get(fieldName); + Assert.state(node != null && !(node instanceof NullNode), () -> "Missing JSON field '" + fieldName + "'"); + return node; + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonObjectSerializer.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonObjectSerializer.java new file mode 100644 index 00000000000..d4b1e490068 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/JsonObjectSerializer.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import org.springframework.boot.jackson.ObjectValueSerializer; + +/** + * Helper base class for {@link JsonSerializer} implementations that serialize objects. + * + * @param the supported object type + * @author Phillip Webb + * @since 1.4.0 + * @see JsonObjectDeserializer + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 and + * {@link ObjectValueSerializer} + */ +@Deprecated(since = "4.0.0", forRemoval = true) +public abstract class JsonObjectSerializer extends JsonSerializer { + + @Override + public final void serialize(T value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + try { + jgen.writeStartObject(); + serializeObject(value, jgen, provider); + jgen.writeEndObject(); + } + catch (Exception ex) { + if (ex instanceof IOException ioException) { + throw ioException; + } + throw new JsonMappingException(jgen, "Object serialize error", ex); + } + } + + /** + * Serialize JSON content into the value type this serializer handles. + * @param value the source value + * @param jgen the JSON generator + * @param provider the serializer provider + * @throws IOException on error + */ + protected abstract void serializeObject(T value, JsonGenerator jgen, SerializerProvider provider) + throws IOException; + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2AutoConfiguration.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2AutoConfiguration.java new file mode 100644 index 00000000000..0f5726e3eb1 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2AutoConfiguration.java @@ -0,0 +1,393 @@ +/* + * Copyright 2012-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.boot.jackson2.autoconfigure; + +import java.lang.reflect.Field; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; +import java.util.stream.Stream; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.cfg.ConstructorDetector; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.hint.ReflectionHints; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jackson2.JsonComponentModule; +import org.springframework.boot.jackson2.JsonMixinModule; +import org.springframework.boot.jackson2.JsonMixinModuleEntries; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Scope; +import org.springframework.core.Ordered; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; + +/** + * Auto configuration for Jackson. The following auto-configuration will get applied: + *
    + *
  • an {@link ObjectMapper} in case none is already configured.
  • + *
  • a {@link org.springframework.http.converter.json.Jackson2ObjectMapperBuilder} in + * case none is already configured.
  • + *
  • auto-registration for all {@link Module} beans with all {@link ObjectMapper} beans + * (including the defaulted ones).
  • + *
+ * + * @author Oliver Gierke + * @author Andy Wilkinson + * @author Marcel Overdijk + * @author Sebastien Deleuze + * @author Johannes Edmeier + * @author Phillip Webb + * @author EddĂș MelĂ©ndez + * @author Ralf Ueberfuhr + * @since 4.0.0 + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ +@AutoConfiguration +@ConditionalOnClass(ObjectMapper.class) +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public final class Jackson2AutoConfiguration { + + private static final Map FEATURE_DEFAULTS; + + static { + Map featureDefaults = new HashMap<>(); + featureDefaults.put(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + featureDefaults.put(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, false); + FEATURE_DEFAULTS = Collections.unmodifiableMap(featureDefaults); + } + + @Bean + JsonComponentModule jackson2JsonComponentModule() { + return new JsonComponentModule(); + } + + @Configuration(proxyBeanMethods = false) + static class JacksonMixinConfiguration { + + @Bean + static JsonMixinModuleEntries jackson2JsonMixinModuleEntries(ApplicationContext context) { + List packages = AutoConfigurationPackages.has(context) ? AutoConfigurationPackages.get(context) + : Collections.emptyList(); + return JsonMixinModuleEntries.scan(context, packages); + } + + @Bean + JsonMixinModule jackson2JsonMixinModule(ApplicationContext context, JsonMixinModuleEntries entries) { + JsonMixinModule jsonMixinModule = new JsonMixinModule(); + jsonMixinModule.registerEntries(entries, context.getClassLoader()); + return jsonMixinModule; + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.class) + static class JacksonObjectMapperConfiguration { + + @Bean + @Primary + @ConditionalOnMissingBean + ObjectMapper jackson2ObjectMapper(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) { + return builder.createXmlMapper(false).build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ParameterNamesModule.class) + static class ParameterNamesModuleConfiguration { + + @Bean + @ConditionalOnMissingBean + ParameterNamesModule jackson2ParameterNamesModule() { + return new ParameterNamesModule(JsonCreator.Mode.DEFAULT); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.class) + static class JacksonObjectMapperBuilderConfiguration { + + @Bean + @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) + @ConditionalOnMissingBean + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder( + ApplicationContext applicationContext, List customizers) { + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder = new org.springframework.http.converter.json.Jackson2ObjectMapperBuilder(); + builder.applicationContext(applicationContext); + customize(builder, customizers); + return builder; + } + + private void customize(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder, + List customizers) { + for (Jackson2ObjectMapperBuilderCustomizer customizer : customizers) { + customizer.customize(builder); + } + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.class) + @EnableConfigurationProperties(Jackson2Properties.class) + static class Jackson2ObjectMapperBuilderCustomizerConfiguration { + + @Bean + StandardJackson2ObjectMapperBuilderCustomizer standardJackson2ObjectMapperBuilderCustomizer( + Jackson2Properties jacksonProperties, ObjectProvider modules) { + return new StandardJackson2ObjectMapperBuilderCustomizer(jacksonProperties, modules.stream().toList()); + } + + static final class StandardJackson2ObjectMapperBuilderCustomizer + implements Jackson2ObjectMapperBuilderCustomizer, Ordered { + + private final Jackson2Properties jacksonProperties; + + private final Collection modules; + + StandardJackson2ObjectMapperBuilderCustomizer(Jackson2Properties jacksonProperties, + Collection modules) { + this.jacksonProperties = jacksonProperties; + this.modules = modules; + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public void customize(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) { + if (this.jacksonProperties.getDefaultPropertyInclusion() != null) { + builder.serializationInclusion(this.jacksonProperties.getDefaultPropertyInclusion()); + } + if (this.jacksonProperties.getTimeZone() != null) { + builder.timeZone(this.jacksonProperties.getTimeZone()); + } + configureFeatures(builder, FEATURE_DEFAULTS); + configureVisibility(builder, this.jacksonProperties.getVisibility()); + configureFeatures(builder, this.jacksonProperties.getDeserialization()); + configureFeatures(builder, this.jacksonProperties.getSerialization()); + configureFeatures(builder, this.jacksonProperties.getMapper()); + configureFeatures(builder, this.jacksonProperties.getParser()); + configureFeatures(builder, this.jacksonProperties.getGenerator()); + configureFeatures(builder, this.jacksonProperties.getDatatype().getEnum()); + configureFeatures(builder, this.jacksonProperties.getDatatype().getJsonNode()); + configureDateFormat(builder); + configurePropertyNamingStrategy(builder); + configureModules(builder); + configureLocale(builder); + configureDefaultLeniency(builder); + configureConstructorDetector(builder); + } + + private void configureFeatures(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder, + Map features) { + features.forEach((feature, value) -> { + if (value != null) { + if (value) { + builder.featuresToEnable(feature); + } + else { + builder.featuresToDisable(feature); + } + } + }); + } + + private void configureVisibility( + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder, + Map visibilities) { + visibilities.forEach(builder::visibility); + } + + private void configureDateFormat( + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) { + // We support a fully qualified class name extending DateFormat or a date + // pattern string value + String dateFormat = this.jacksonProperties.getDateFormat(); + if (dateFormat != null) { + try { + Class dateFormatClass = ClassUtils.forName(dateFormat, null); + builder.dateFormat((DateFormat) BeanUtils.instantiateClass(dateFormatClass)); + } + catch (ClassNotFoundException ex) { + SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat); + // Since Jackson 2.6.3 we always need to set a TimeZone (see + // gh-4170). If none in our properties fallback to the Jackson's + // default + TimeZone timeZone = this.jacksonProperties.getTimeZone(); + if (timeZone == null) { + timeZone = new ObjectMapper().getSerializationConfig().getTimeZone(); + } + simpleDateFormat.setTimeZone(timeZone); + builder.dateFormat(simpleDateFormat); + } + } + } + + private void configurePropertyNamingStrategy( + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) { + // We support a fully qualified class name extending Jackson's + // PropertyNamingStrategy or a string value corresponding to the constant + // names in PropertyNamingStrategy which hold default provided + // implementations + String strategy = this.jacksonProperties.getPropertyNamingStrategy(); + if (strategy != null) { + try { + configurePropertyNamingStrategyClass(builder, ClassUtils.forName(strategy, null)); + } + catch (ClassNotFoundException ex) { + configurePropertyNamingStrategyField(builder, strategy); + } + } + } + + private void configurePropertyNamingStrategyClass( + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder, + Class propertyNamingStrategyClass) { + builder.propertyNamingStrategy( + (PropertyNamingStrategy) BeanUtils.instantiateClass(propertyNamingStrategyClass)); + } + + private void configurePropertyNamingStrategyField( + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder, String fieldName) { + // Find the field (this way we automatically support new constants + // that may be added by Jackson in the future) + Field field = findPropertyNamingStrategyField(fieldName); + Assert.state(field != null, () -> "Constant named '" + fieldName + "' not found"); + try { + builder.propertyNamingStrategy((PropertyNamingStrategy) field.get(null)); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private Field findPropertyNamingStrategyField(String fieldName) { + Field field = ReflectionUtils.findField(com.fasterxml.jackson.databind.PropertyNamingStrategies.class, + fieldName, PropertyNamingStrategy.class); + Assert.state(field != null, () -> "Constant named '" + fieldName + "' not found"); + return field; + } + + private void configureModules(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) { + builder.modulesToInstall((modules) -> modules.addAll(this.modules)); + } + + private void configureLocale(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) { + Locale locale = this.jacksonProperties.getLocale(); + if (locale != null) { + builder.locale(locale); + } + } + + private void configureDefaultLeniency( + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) { + Boolean defaultLeniency = this.jacksonProperties.getDefaultLeniency(); + if (defaultLeniency != null) { + builder.postConfigurer((objectMapper) -> objectMapper.setDefaultLeniency(defaultLeniency)); + } + } + + private void configureConstructorDetector( + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) { + org.springframework.boot.jackson2.autoconfigure.Jackson2Properties.ConstructorDetectorStrategy strategy = this.jacksonProperties + .getConstructorDetector(); + if (strategy != null) { + builder.postConfigurer((objectMapper) -> { + switch (strategy) { + case USE_PROPERTIES_BASED -> + objectMapper.setConstructorDetector(ConstructorDetector.USE_PROPERTIES_BASED); + case USE_DELEGATING -> + objectMapper.setConstructorDetector(ConstructorDetector.USE_DELEGATING); + case EXPLICIT_ONLY -> + objectMapper.setConstructorDetector(ConstructorDetector.EXPLICIT_ONLY); + default -> objectMapper.setConstructorDetector(ConstructorDetector.DEFAULT); + } + }); + } + } + + } + + } + + static class Jackson2AutoConfigurationRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + if (ClassUtils.isPresent("com.fasterxml.jackson.databind.PropertyNamingStrategy", classLoader)) { + registerPropertyNamingStrategyHints(hints.reflection()); + } + } + + /** + * Register hints for the {@code configurePropertyNamingStrategyField} method to + * use. + * @param hints reflection hints + */ + private void registerPropertyNamingStrategyHints(ReflectionHints hints) { + registerPropertyNamingStrategyHints(hints, PropertyNamingStrategies.class); + } + + private void registerPropertyNamingStrategyHints(ReflectionHints hints, Class type) { + Stream.of(type.getDeclaredFields()) + .filter(this::isPropertyNamingStrategyField) + .forEach(hints::registerField); + } + + private boolean isPropertyNamingStrategyField(Field candidate) { + return ReflectionUtils.isPublicStaticFinal(candidate) + && candidate.getType().isAssignableFrom(PropertyNamingStrategy.class); + } + + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2BackgroundPreinitializer.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2BackgroundPreinitializer.java new file mode 100644 index 00000000000..5b386ff18e4 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2BackgroundPreinitializer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-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.boot.jackson2.autoconfigure; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.boot.autoconfigure.preinitialize.BackgroundPreinitializer; + +/** + * {@link BackgroundPreinitializer} for Jackson 2. + * + * @author Andy Wilkinson + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +final class Jackson2BackgroundPreinitializer implements BackgroundPreinitializer { + + @Override + public void preinitialize() throws Exception { + new ObjectMapper(); + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2ObjectMapperBuilderCustomizer.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2ObjectMapperBuilderCustomizer.java new file mode 100644 index 00000000000..6606a54bd2c --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2ObjectMapperBuilderCustomizer.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-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.boot.jackson2.autoconfigure; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Callback interface that can be implemented by beans wishing to further customize the + * {@link ObjectMapper} through + * {@link org.springframework.http.converter.json.Jackson2ObjectMapperBuilder} to + * fine-tune its auto-configuration. + * + * @author Grzegorz Poznachowski + * @since 4.0.0 + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ +@FunctionalInterface +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public interface Jackson2ObjectMapperBuilderCustomizer { + + /** + * Customize the JacksonObjectMapperBuilder. + * @param jacksonObjectMapperBuilder the JacksonObjectMapperBuilder to customize + */ + void customize(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder); + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2Properties.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2Properties.java new file mode 100644 index 00000000000..64d287d80ac --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2Properties.java @@ -0,0 +1,272 @@ +/* + * Copyright 2012-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.boot.jackson2.autoconfigure; + +import java.util.EnumMap; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.cfg.EnumFeature; +import com.fasterxml.jackson.databind.cfg.JsonNodeFeature; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; + +/** + * Configuration properties to configure Jackson 2. + * + * @author Andy Wilkinson + * @author Marcel Overdijk + * @author Johannes Edmeier + * @author EddĂș MelĂ©ndez + * @since 4.0.0 + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@ConfigurationProperties("spring.jackson2") +public class Jackson2Properties { + + /** + * Date format string or a fully-qualified date format class name. For instance, + * 'yyyy-MM-dd HH:mm:ss'. + */ + private @Nullable String dateFormat; + + /** + * One of the constants on Jackson's PropertyNamingStrategies. Can also be a + * fully-qualified class name of a PropertyNamingStrategy implementation. + */ + private @Nullable String propertyNamingStrategy; + + /** + * Jackson visibility thresholds that can be used to limit which methods (and fields) + * are auto-detected. + */ + private final Map visibility = new EnumMap<>(PropertyAccessor.class); + + /** + * Jackson on/off features that affect the way Java objects are serialized. + */ + private final Map serialization = new EnumMap<>(SerializationFeature.class); + + /** + * Jackson on/off features that affect the way Java objects are deserialized. + */ + private final Map deserialization = new EnumMap<>(DeserializationFeature.class); + + /** + * Jackson general purpose on/off features. + */ + private final Map mapper = new EnumMap<>(MapperFeature.class); + + /** + * Jackson on/off features for parsers. + */ + private final Map parser = new EnumMap<>(JsonParser.Feature.class); + + /** + * Jackson on/off features for generators. + */ + private final Map generator = new EnumMap<>(JsonGenerator.Feature.class); + + /** + * Controls the inclusion of properties during serialization. Configured with one of + * the values in Jackson's JsonInclude.Include enumeration. + */ + private JsonInclude.@Nullable Include defaultPropertyInclusion; + + /** + * Global default setting (if any) for leniency. + */ + private @Nullable Boolean defaultLeniency; + + /** + * Strategy to use to auto-detect constructor, and in particular behavior with + * single-argument constructors. + */ + private @Nullable ConstructorDetectorStrategy constructorDetector; + + /** + * Time zone used when formatting dates. For instance, "America/Los_Angeles" or + * "GMT+10". + */ + private @Nullable TimeZone timeZone = null; + + /** + * Locale used for formatting. + */ + private @Nullable Locale locale; + + private final Datatype datatype = new Datatype(); + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public @Nullable String getDateFormat() { + return this.dateFormat; + } + + public void setDateFormat(@Nullable String dateFormat) { + this.dateFormat = dateFormat; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public @Nullable String getPropertyNamingStrategy() { + return this.propertyNamingStrategy; + } + + public void setPropertyNamingStrategy(@Nullable String propertyNamingStrategy) { + this.propertyNamingStrategy = propertyNamingStrategy; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public Map getVisibility() { + return this.visibility; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public Map getSerialization() { + return this.serialization; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public Map getDeserialization() { + return this.deserialization; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public Map getMapper() { + return this.mapper; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public Map getParser() { + return this.parser; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public Map getGenerator() { + return this.generator; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public JsonInclude.@Nullable Include getDefaultPropertyInclusion() { + return this.defaultPropertyInclusion; + } + + public void setDefaultPropertyInclusion(JsonInclude.@Nullable Include defaultPropertyInclusion) { + this.defaultPropertyInclusion = defaultPropertyInclusion; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public @Nullable Boolean getDefaultLeniency() { + return this.defaultLeniency; + } + + public void setDefaultLeniency(@Nullable Boolean defaultLeniency) { + this.defaultLeniency = defaultLeniency; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public @Nullable ConstructorDetectorStrategy getConstructorDetector() { + return this.constructorDetector; + } + + public void setConstructorDetector(@Nullable ConstructorDetectorStrategy constructorDetector) { + this.constructorDetector = constructorDetector; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public @Nullable TimeZone getTimeZone() { + return this.timeZone; + } + + public void setTimeZone(@Nullable TimeZone timeZone) { + this.timeZone = timeZone; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public @Nullable Locale getLocale() { + return this.locale; + } + + public void setLocale(@Nullable Locale locale) { + this.locale = locale; + } + + public Datatype getDatatype() { + return this.datatype; + } + + public enum ConstructorDetectorStrategy { + + /** + * Use heuristics to see if "properties" mode is to be used. + */ + DEFAULT, + + /** + * Assume "properties" mode if not explicitly annotated otherwise. + */ + USE_PROPERTIES_BASED, + + /** + * Assume "delegating" mode if not explicitly annotated otherwise. + */ + USE_DELEGATING, + + /** + * Refuse to decide implicit mode and instead throw an InvalidDefinitionException + * for ambiguous cases. + */ + EXPLICIT_ONLY + + } + + public static class Datatype { + + /** + * Jackson on/off features for enums. + */ + private final Map enumFeatures = new EnumMap<>(EnumFeature.class); + + /** + * Jackson on/off features for JsonNodes. + */ + private final Map jsonNode = new EnumMap<>(JsonNodeFeature.class); + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public Map getEnum() { + return this.enumFeatures; + } + + @DeprecatedConfigurationProperty(reason = "Deprecated in favor of Jackson 3") + public Map getJsonNode() { + return this.jsonNode; + } + + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/package-info.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/package-info.java new file mode 100644 index 00000000000..fff5beb1bf1 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-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. + */ + +/** + * Auto-configuration for Jackson 2. + */ +@org.jspecify.annotations.NullMarked +package org.springframework.boot.jackson2.autoconfigure; diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/package-info.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/package-info.java new file mode 100644 index 00000000000..ebb09ca0bee --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-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. + */ + +/** + * Custom enhancements and support for Jackson 2. + */ +@org.jspecify.annotations.NullMarked +package org.springframework.boot.jackson2; diff --git a/module/spring-boot-jackson2/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-jackson2/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..cdddc040143 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,12 @@ +{ + "properties": [ + { + "name": "spring.jackson2.constructor-detector", + "defaultValue": "default" + }, + { + "name": "spring.jackson2.datatype.enum", + "description": "Jackson on/off features for enums." + } + ] +} diff --git a/module/spring-boot-jackson2/src/main/resources/META-INF/spring.factories b/module/spring-boot-jackson2/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..234bbf2be64 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Background Preinitializers +org.springframework.boot.autoconfigure.preinitialize.BackgroundPreinitializer=\ +org.springframework.boot.jackson2.autoconfigure.Jackson2BackgroundPreinitializer diff --git a/module/spring-boot-jackson2/src/main/resources/META-INF/spring/aot.factories b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..be1a42ea2b5 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,8 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ +org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration$Jackson2AutoConfigurationRuntimeHints + +org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor=\ +org.springframework.boot.jackson2.JsonComponentModule$JsonComponentBeanFactoryInitializationAotProcessor + +org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\ +org.springframework.boot.jackson2.JsonMixinModuleEntriesBeanRegistrationAotProcessor diff --git a/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..6aeba7e266f --- /dev/null +++ b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonComponentModuleTests.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonComponentModuleTests.java new file mode 100644 index 00000000000..a909500ddc0 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonComponentModuleTests.java @@ -0,0 +1,254 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; +import org.springframework.beans.factory.aot.BeanFactoryInitializationCode; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.jackson2.types.Name; +import org.springframework.boot.jackson2.types.NameAndAge; +import org.springframework.boot.jackson2.types.NameAndCareer; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JsonComponentModule}. + * + * @author Phillip Webb + * @author Vladimir Tsanev + * @author Paul Aly + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +class JsonComponentModuleTests { + + private @Nullable AnnotationConfigApplicationContext context; + + @AfterEach + void closeContext() { + if (this.context != null) { + getContext().close(); + } + } + + @Test + void moduleShouldRegisterSerializers() throws Exception { + load(OnlySerializer.class); + JsonComponentModule module = getContext().getBean(JsonComponentModule.class); + assertSerialize(module); + } + + @Test + void moduleShouldRegisterDeserializers() throws Exception { + load(OnlyDeserializer.class); + JsonComponentModule module = getContext().getBean(JsonComponentModule.class); + assertDeserialize(module); + } + + @Test + void moduleShouldRegisterInnerClasses() throws Exception { + load(NameAndAgeJsonComponent.class); + JsonComponentModule module = getContext().getBean(JsonComponentModule.class); + assertSerialize(module); + assertDeserialize(module); + } + + @Test + void moduleShouldAllowInnerAbstractClasses() throws Exception { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(JsonComponentModule.class, + ComponentWithInnerAbstractClass.class); + JsonComponentModule module = context.getBean(JsonComponentModule.class); + assertSerialize(module); + context.close(); + } + + @Test + void moduleShouldRegisterKeySerializers() throws Exception { + load(OnlyKeySerializer.class); + JsonComponentModule module = getContext().getBean(JsonComponentModule.class); + assertKeySerialize(module); + } + + @Test + void moduleShouldRegisterKeyDeserializers() throws Exception { + load(OnlyKeyDeserializer.class); + JsonComponentModule module = getContext().getBean(JsonComponentModule.class); + assertKeyDeserialize(module); + } + + @Test + void moduleShouldRegisterInnerClassesForKeyHandlers() throws Exception { + load(NameAndAgeJsonKeyComponent.class); + JsonComponentModule module = getContext().getBean(JsonComponentModule.class); + assertKeySerialize(module); + assertKeyDeserialize(module); + } + + @Test + void moduleShouldRegisterOnlyForSpecifiedClasses() throws Exception { + load(NameAndCareerJsonComponent.class); + JsonComponentModule module = getContext().getBean(JsonComponentModule.class); + assertSerialize(module, new NameAndCareer("spring", "developer"), "{\"name\":\"spring\"}"); + assertSerialize(module); + assertDeserializeForSpecifiedClasses(module); + } + + @Test + void aotContributionRegistersReflectionHintsForSuitableInnerClasses() { + load(ComponentWithInnerAbstractClass.class); + ConfigurableListableBeanFactory beanFactory = getContext().getBeanFactory(); + BeanFactoryInitializationAotContribution contribution = new org.springframework.boot.jackson2.JsonComponentModule.JsonComponentBeanFactoryInitializationAotProcessor() + .processAheadOfTime(beanFactory); + TestGenerationContext generationContext = new TestGenerationContext(); + assertThat(contribution).isNotNull(); + contribution.applyTo(generationContext, mock(BeanFactoryInitializationCode.class)); + RuntimeHints runtimeHints = generationContext.getRuntimeHints(); + assertThat(RuntimeHintsPredicates.reflection().onType(ComponentWithInnerAbstractClass.class)) + .accepts(runtimeHints); + assertThat(RuntimeHintsPredicates.reflection() + .onType(JsonComponentModuleTests.ComponentWithInnerAbstractClass.ConcreteSerializer.class) + .withMemberCategory(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(runtimeHints); + assertThat(RuntimeHintsPredicates.reflection() + .onType(JsonComponentModuleTests.ComponentWithInnerAbstractClass.AbstractSerializer.class) + .withMemberCategory(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) + .negate()).accepts(runtimeHints); + assertThat(RuntimeHintsPredicates.reflection() + .onType(JsonComponentModuleTests.ComponentWithInnerAbstractClass.NotSuitable.class) + .withMemberCategory(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS) + .negate()).accepts(runtimeHints); + } + + private void load(Class... configs) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(configs); + context.register(JsonComponentModule.class); + context.refresh(); + this.context = context; + } + + private void assertSerialize(Module module, Name value, String expectedJson) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + String json = mapper.writeValueAsString(value); + assertThat(json).isEqualToIgnoringWhitespace(expectedJson); + } + + private void assertSerialize(Module module) throws Exception { + assertSerialize(module, new NameAndAge("spring", 100), "{\"name\":\"spring\",\"age\":100}"); + } + + private void assertDeserialize(Module module) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + NameAndAge nameAndAge = mapper.readValue("{\"name\":\"spring\",\"age\":100}", NameAndAge.class); + assertThat(nameAndAge.getName()).isEqualTo("spring"); + assertThat(nameAndAge.getAge()).isEqualTo(100); + } + + private void assertDeserializeForSpecifiedClasses(JsonComponentModule module) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + assertThatExceptionOfType(JsonMappingException.class) + .isThrownBy(() -> mapper.readValue("{\"name\":\"spring\",\"age\":100}", NameAndAge.class)); + NameAndCareer nameAndCareer = mapper.readValue("{\"name\":\"spring\",\"career\":\"developer\"}", + NameAndCareer.class); + assertThat(nameAndCareer.getName()).isEqualTo("spring"); + assertThat(nameAndCareer.getCareer()).isEqualTo("developer"); + } + + private void assertKeySerialize(Module module) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + Map map = new HashMap<>(); + map.put(new NameAndAge("spring", 100), true); + String json = mapper.writeValueAsString(map); + assertThat(json).isEqualToIgnoringWhitespace("{\"spring is 100\": true}"); + } + + private void assertKeyDeserialize(Module module) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + TypeReference> typeRef = new TypeReference<>() { + }; + Map map = mapper.readValue("{\"spring is 100\": true}", typeRef); + assertThat(map).containsEntry(new NameAndAge("spring", 100), true); + } + + private AnnotationConfigApplicationContext getContext() { + AnnotationConfigApplicationContext context = this.context; + assertThat(context).isNotNull(); + return context; + } + + @JsonComponent + static class OnlySerializer extends NameAndAgeJsonComponent.Serializer { + + } + + @JsonComponent + static class OnlyDeserializer extends NameAndAgeJsonComponent.Deserializer { + + } + + @JsonComponent + static class ComponentWithInnerAbstractClass { + + abstract static class AbstractSerializer extends NameAndAgeJsonComponent.Serializer { + + } + + static class ConcreteSerializer extends AbstractSerializer { + + } + + static class NotSuitable { + + } + + } + + @JsonComponent(scope = JsonComponent.Scope.KEYS) + static class OnlyKeySerializer extends NameAndAgeJsonKeyComponent.Serializer { + + } + + @JsonComponent(scope = JsonComponent.Scope.KEYS, type = NameAndAge.class) + static class OnlyKeyDeserializer extends NameAndAgeJsonKeyComponent.Deserializer { + + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonMixinModuleEntriesBeanRegistrationAotProcessorTests.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonMixinModuleEntriesBeanRegistrationAotProcessorTests.java new file mode 100644 index 00000000000..3c007d27a31 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonMixinModuleEntriesBeanRegistrationAotProcessorTests.java @@ -0,0 +1,149 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.BiConsumer; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.boot.jackson2.scan.a.RenameMixInClass; +import org.springframework.boot.jackson2.types.Name; +import org.springframework.boot.jackson2.types.NameAndAge; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.aot.ApplicationContextAotGenerator; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.test.tools.CompileWithForkedClassLoader; +import org.springframework.core.test.tools.Compiled; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.javapoet.ClassName; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +/** + * Tests for {@link JsonMixinModuleEntriesBeanRegistrationAotProcessor}. + * + * @author Stephane Nicoll + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@CompileWithForkedClassLoader +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +class JsonMixinModuleEntriesBeanRegistrationAotProcessorTests { + + private final TestGenerationContext generationContext = new TestGenerationContext(); + + private final GenericApplicationContext applicationContext = new AnnotationConfigApplicationContext(); + + @Test + void processAheadOfTimeShouldRegisterBindingHintsForMixins() { + registerEntries(RenameMixInClass.class); + processAheadOfTime(); + RuntimeHints runtimeHints = this.generationContext.getRuntimeHints(); + assertThat(RuntimeHintsPredicates.reflection() + .onType(RenameMixInClass.class) + .withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(runtimeHints); + } + + @Test + void processAheadOfTimeWhenPublicClassShouldRegisterClass() { + registerEntries(RenameMixInClass.class); + compile((freshContext, compiled) -> { + assertThat(freshContext.getBean(TestConfiguration.class).scanningInvoked).isFalse(); + JsonMixinModuleEntries jsonMixinModuleEntries = freshContext.getBean(JsonMixinModuleEntries.class); + assertThat(jsonMixinModuleEntries).extracting("entries", InstanceOfAssertFactories.MAP) + .containsExactly(entry(Name.class, RenameMixInClass.class), + entry(NameAndAge.class, RenameMixInClass.class)); + }); + } + + @Test + void processAheadOfTimeWhenNonAccessibleClassShouldRegisterClassName() { + Class privateMixinClass = ClassUtils + .resolveClassName("org.springframework.boot.jackson2.scan.e.PrivateMixInClass", null); + registerEntries(privateMixinClass); + compile((freshContext, compiled) -> { + assertThat(freshContext.getBean(TestConfiguration.class).scanningInvoked).isFalse(); + JsonMixinModuleEntries jsonMixinModuleEntries = freshContext.getBean(JsonMixinModuleEntries.class); + assertThat(jsonMixinModuleEntries).extracting("entries", InstanceOfAssertFactories.MAP) + .containsExactly(entry(Name.class.getName(), privateMixinClass.getName()), + entry(NameAndAge.class.getName(), privateMixinClass.getName())); + }); + } + + private ClassName processAheadOfTime() { + ClassName className = new ApplicationContextAotGenerator().processAheadOfTime(this.applicationContext, + this.generationContext); + this.generationContext.writeGeneratedContent(); + return className; + } + + @SuppressWarnings("unchecked") + private void compile(BiConsumer result) { + ClassName className = processAheadOfTime(); + TestCompiler.forSystem().with(this.generationContext).compile((compiled) -> { + GenericApplicationContext freshApplicationContext = new GenericApplicationContext(); + ApplicationContextInitializer initializer = compiled + .getInstance(ApplicationContextInitializer.class, className.toString()); + initializer.initialize(freshApplicationContext); + freshApplicationContext.refresh(); + result.accept(freshApplicationContext, compiled); + }); + } + + private void registerEntries(Class... basePackageClasses) { + List packageNames = Arrays.stream(basePackageClasses).map(Class::getPackageName).toList(); + this.applicationContext.registerBeanDefinition("configuration", + BeanDefinitionBuilder.rootBeanDefinition(TestConfiguration.class) + .addConstructorArgValue(packageNames) + .getBeanDefinition()); + } + + @Configuration(proxyBeanMethods = false) + static class TestConfiguration { + + public boolean scanningInvoked; + + private final Collection packageNames; + + TestConfiguration(Collection packageNames) { + this.packageNames = packageNames; + } + + @Bean + JsonMixinModuleEntries jsonMixinModuleEntries(ApplicationContext applicationContext) { + this.scanningInvoked = true; + return JsonMixinModuleEntries.scan(applicationContext, this.packageNames); + } + + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonMixinModuleTests.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonMixinModuleTests.java new file mode 100644 index 00000000000..74040e50470 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonMixinModuleTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.boot.jackson2.scan.a.RenameMixInClass; +import org.springframework.boot.jackson2.scan.b.RenameMixInAbstractClass; +import org.springframework.boot.jackson2.scan.c.RenameMixInInterface; +import org.springframework.boot.jackson2.scan.d.EmptyMixInClass; +import org.springframework.boot.jackson2.scan.f.EmptyMixIn; +import org.springframework.boot.jackson2.types.Name; +import org.springframework.boot.jackson2.types.NameAndAge; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link JsonMixinModule}. + * + * @author Guirong Hu + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +class JsonMixinModuleTests { + + private @Nullable AnnotationConfigApplicationContext context; + + @AfterEach + void closeContext() { + if (this.context != null) { + getContext().close(); + } + } + + @Test + void jsonWithModuleEmptyMixInWithEmptyTypesShouldFail() { + assertThatExceptionOfType(BeanCreationException.class).isThrownBy(() -> load(EmptyMixIn.class)) + .withMessageContaining("Error creating bean with name 'jsonMixinModule'") + .withStackTraceContaining("@JsonMixin annotation on class " + + "'org.springframework.boot.jackson2.scan.f.EmptyMixIn' does not specify any types"); + } + + @Test + void jsonWithModuleWithRenameMixInClassShouldBeMixedIn() throws Exception { + load(RenameMixInClass.class); + JsonMixinModule module = getContext().getBean(JsonMixinModule.class); + assertMixIn(module, new Name("spring"), "{\"username\":\"spring\"}"); + assertMixIn(module, new NameAndAge("spring", 100), "{\"age\":100,\"username\":\"spring\"}"); + } + + @Test + void jsonWithModuleWithEmptyMixInClassShouldNotBeMixedIn() throws Exception { + load(EmptyMixInClass.class); + JsonMixinModule module = getContext().getBean(JsonMixinModule.class); + assertMixIn(module, new Name("spring"), "{\"name\":\"spring\"}"); + assertMixIn(module, new NameAndAge("spring", 100), "{\"name\":\"spring\",\"age\":100}"); + } + + @Test + void jsonWithModuleWithRenameMixInAbstractClassShouldBeMixedIn() throws Exception { + load(RenameMixInAbstractClass.class); + JsonMixinModule module = getContext().getBean(JsonMixinModule.class); + assertMixIn(module, new NameAndAge("spring", 100), "{\"age\":100,\"username\":\"spring\"}"); + } + + @Test + void jsonWithModuleWithRenameMixInInterfaceShouldBeMixedIn() throws Exception { + load(RenameMixInInterface.class); + JsonMixinModule module = getContext().getBean(JsonMixinModule.class); + assertMixIn(module, new NameAndAge("spring", 100), "{\"age\":100,\"username\":\"spring\"}"); + } + + private void load(Class... basePackageClasses) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.registerBean(JsonMixinModule.class, () -> createJsonMixinModule(context, basePackageClasses)); + context.refresh(); + this.context = context; + } + + private JsonMixinModule createJsonMixinModule(AnnotationConfigApplicationContext context, + Class... basePackageClasses) { + List basePackages = Arrays.stream(basePackageClasses).map(ClassUtils::getPackageName).toList(); + JsonMixinModuleEntries entries = JsonMixinModuleEntries.scan(context, basePackages); + JsonMixinModule jsonMixinModule = new JsonMixinModule(); + jsonMixinModule.registerEntries(entries, context.getClassLoader()); + return jsonMixinModule; + } + + private void assertMixIn(Module module, Name value, String expectedJson) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + String json = mapper.writeValueAsString(value); + assertThat(json).isEqualToIgnoringWhitespace(expectedJson); + } + + private AnnotationConfigApplicationContext getContext() { + AnnotationConfigApplicationContext context = this.context; + assertThat(context).isNotNull(); + return context; + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonObjectDeserializerTests.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonObjectDeserializerTests.java new file mode 100644 index 00000000000..606d17c3247 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonObjectDeserializerTests.java @@ -0,0 +1,221 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.LocalDate; +import java.util.function.Function; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.node.NullNode; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JsonObjectDeserializer}. + * + * @author Phillip Webb + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +class JsonObjectDeserializerTests { + + private final TestJsonObjectDeserializer testDeserializer = new TestJsonObjectDeserializer<>(); + + @Test + void deserializeObjectShouldReadJson() throws Exception { + NameAndAgeJsonComponent.Deserializer deserializer = new NameAndAgeJsonComponent.Deserializer(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(org.springframework.boot.jackson2.types.NameAndAge.class, deserializer); + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + org.springframework.boot.jackson2.types.NameAndAge nameAndAge = mapper + .readValue("{\"name\":\"spring\",\"age\":100}", org.springframework.boot.jackson2.types.NameAndAge.class); + assertThat(nameAndAge.getName()).isEqualTo("spring"); + assertThat(nameAndAge.getAge()).isEqualTo(100); + } + + @Test + void nullSafeValueWhenValueIsNullShouldReturnNull() { + String value = this.testDeserializer.testNullSafeValue(null, String.class); + assertThat(value).isNull(); + } + + @Test + void nullSafeValueWhenClassIsNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.testDeserializer.testNullSafeValue(mock(JsonNode.class), null)) + .withMessageContaining("'type' must not be null"); + } + + @Test + void nullSafeValueWhenClassIsStringShouldReturnString() { + JsonNode node = mock(JsonNode.class); + given(node.textValue()).willReturn("abc"); + String value = this.testDeserializer.testNullSafeValue(node, String.class); + assertThat(value).isEqualTo("abc"); + } + + @Test + void nullSafeValueWhenClassIsBooleanShouldReturnBoolean() { + JsonNode node = mock(JsonNode.class); + given(node.booleanValue()).willReturn(true); + Boolean value = this.testDeserializer.testNullSafeValue(node, Boolean.class); + assertThat(value).isTrue(); + } + + @Test + void nullSafeValueWhenClassIsLongShouldReturnLong() { + JsonNode node = mock(JsonNode.class); + given(node.longValue()).willReturn(10L); + Long value = this.testDeserializer.testNullSafeValue(node, Long.class); + assertThat(value).isEqualTo(10L); + } + + @Test + void nullSafeValueWhenClassIsIntegerShouldReturnInteger() { + JsonNode node = mock(JsonNode.class); + given(node.intValue()).willReturn(10); + Integer value = this.testDeserializer.testNullSafeValue(node, Integer.class); + assertThat(value).isEqualTo(10); + } + + @Test + void nullSafeValueWhenClassIsShortShouldReturnShort() { + JsonNode node = mock(JsonNode.class); + given(node.shortValue()).willReturn((short) 10); + Short value = this.testDeserializer.testNullSafeValue(node, Short.class); + assertThat(value).isEqualTo((short) 10); + } + + @Test + void nullSafeValueWhenClassIsDoubleShouldReturnDouble() { + JsonNode node = mock(JsonNode.class); + given(node.doubleValue()).willReturn(1.1D); + Double value = this.testDeserializer.testNullSafeValue(node, Double.class); + assertThat(value).isEqualTo(1.1D); + } + + @Test + void nullSafeValueWhenClassIsFloatShouldReturnFloat() { + JsonNode node = mock(JsonNode.class); + given(node.floatValue()).willReturn(1.1F); + Float value = this.testDeserializer.testNullSafeValue(node, Float.class); + assertThat(value).isEqualTo(1.1F); + } + + @Test + void nullSafeValueWhenClassIsBigDecimalShouldReturnBigDecimal() { + JsonNode node = mock(JsonNode.class); + given(node.decimalValue()).willReturn(BigDecimal.TEN); + BigDecimal value = this.testDeserializer.testNullSafeValue(node, BigDecimal.class); + assertThat(value).isEqualTo(BigDecimal.TEN); + } + + @Test + void nullSafeValueWhenClassIsBigIntegerShouldReturnBigInteger() { + JsonNode node = mock(JsonNode.class); + given(node.bigIntegerValue()).willReturn(BigInteger.TEN); + BigInteger value = this.testDeserializer.testNullSafeValue(node, BigInteger.class); + assertThat(value).isEqualTo(BigInteger.TEN); + } + + @Test + void nullSafeValueWithMapperShouldTransformValue() { + JsonNode node = mock(JsonNode.class); + given(node.textValue()).willReturn("2023-12-01"); + LocalDate result = this.testDeserializer.testNullSafeValue(node, String.class, LocalDate::parse); + assertThat(result).isEqualTo(LocalDate.of(2023, 12, 1)); + } + + @Test + void nullSafeValueWhenClassIsUnknownShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.testDeserializer.testNullSafeValue(mock(JsonNode.class), InputStream.class)) + .withMessageContaining("Unsupported value type java.io.InputStream"); + + } + + @Test + void getRequiredNodeWhenTreeIsNullShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.testDeserializer.testGetRequiredNode(null, "test")) + .withMessageContaining("'tree' must not be null"); + } + + @Test + void getRequiredNodeWhenNodeIsNullShouldThrowException() { + JsonNode tree = mock(JsonNode.class); + given(tree.get("test")).willReturn(null); + assertThatIllegalStateException().isThrownBy(() -> this.testDeserializer.testGetRequiredNode(tree, "test")) + .withMessageContaining("Missing JSON field 'test'"); + } + + @Test + void getRequiredNodeWhenNodeIsNullNodeShouldThrowException() { + JsonNode tree = mock(JsonNode.class); + given(tree.get("test")).willReturn(NullNode.instance); + assertThatIllegalStateException().isThrownBy(() -> this.testDeserializer.testGetRequiredNode(tree, "test")) + .withMessageContaining("Missing JSON field 'test'"); + } + + @Test + void getRequiredNodeWhenNodeIsFoundShouldReturnNode() { + JsonNode node = mock(JsonNode.class); + given(node.get("test")).willReturn(node); + assertThat(this.testDeserializer.testGetRequiredNode(node, "test")).isEqualTo(node); + } + + static class TestJsonObjectDeserializer extends JsonObjectDeserializer { + + @Override + @SuppressWarnings("unchecked") + protected T deserializeObject(JsonParser jsonParser, DeserializationContext context, ObjectCodec codec, + JsonNode tree) { + return (T) new Object(); + } + + @Nullable R testNullSafeValue(JsonNode jsonNode, Class type, Function mapper) { + return nullSafeValue(jsonNode, type, mapper); + } + + @SuppressWarnings("NullAway") // Test null check + D testNullSafeValue(@Nullable JsonNode jsonNode, @Nullable Class type) { + return nullSafeValue(jsonNode, type); + } + + @SuppressWarnings("NullAway") // Test null check + JsonNode testGetRequiredNode(@Nullable JsonNode tree, String fieldName) { + return getRequiredNode(tree, fieldName); + } + + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonObjectSerializerTests.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonObjectSerializerTests.java new file mode 100644 index 00000000000..1a044fcafc2 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/JsonObjectSerializerTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JsonObjectSerializer}. + * + * @author Phillip Webb + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +class JsonObjectSerializerTests { + + @Test + void serializeObjectShouldWriteJson() throws Exception { + org.springframework.boot.jackson2.NameAndAgeJsonComponent.Serializer serializer = new org.springframework.boot.jackson2.NameAndAgeJsonComponent.Serializer(); + SimpleModule module = new SimpleModule(); + module.addSerializer(org.springframework.boot.jackson2.types.NameAndAge.class, serializer); + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + String json = mapper.writeValueAsString(new org.springframework.boot.jackson2.types.NameAndAge("spring", 100)); + assertThat(json).isEqualToIgnoringWhitespace("{\"name\":\"spring\",\"age\":100}"); + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndAgeJsonComponent.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndAgeJsonComponent.java new file mode 100644 index 00000000000..cb1d13f1c56 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndAgeJsonComponent.java @@ -0,0 +1,67 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; + +import org.springframework.boot.jackson2.types.NameAndAge; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Sample {@link JsonComponent @JsonComponent} used for tests. + * + * @author Phillip Webb + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +@JsonComponent +public class NameAndAgeJsonComponent { + + static class Serializer extends JsonObjectSerializer { + + @Override + protected void serializeObject(NameAndAge value, JsonGenerator jgen, SerializerProvider provider) + throws IOException { + jgen.writeStringField("name", value.getName()); + jgen.writeNumberField("age", value.getAge()); + } + + } + + static class Deserializer extends JsonObjectDeserializer { + + @Override + protected NameAndAge deserializeObject(JsonParser jsonParser, DeserializationContext context, ObjectCodec codec, + JsonNode tree) throws IOException { + String name = nullSafeValue(tree.get("name"), String.class); + Integer age = nullSafeValue(tree.get("age"), Integer.class); + assertThat(age).isNotNull(); + return new NameAndAge(name, age); + } + + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndAgeJsonKeyComponent.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndAgeJsonKeyComponent.java new file mode 100644 index 00000000000..fd0108059a1 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndAgeJsonKeyComponent.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.KeyDeserializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import org.springframework.boot.jackson2.types.NameAndAge; + +/** + * Sample {@link JsonComponent @JsonComponent} used for tests. + * + * @author Paul Aly + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +@JsonComponent(type = NameAndAge.class, scope = JsonComponent.Scope.KEYS) +public class NameAndAgeJsonKeyComponent { + + static class Serializer extends JsonSerializer { + + @Override + public void serialize(NameAndAge value, JsonGenerator jgen, SerializerProvider serializers) throws IOException { + jgen.writeFieldName(value.asKey()); + } + + } + + static class Deserializer extends KeyDeserializer { + + @Override + public NameAndAge deserializeKey(String key, DeserializationContext ctxt) throws IOException { + String[] keys = key.split("is"); + return new NameAndAge(keys[0].trim(), Integer.parseInt(keys[1].trim())); + } + + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndCareerJsonComponent.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndCareerJsonComponent.java new file mode 100644 index 00000000000..6fbcd49f164 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/NameAndCareerJsonComponent.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-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.boot.jackson2; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; + +import org.springframework.boot.jackson2.types.Name; +import org.springframework.boot.jackson2.types.NameAndCareer; + +/** + * Sample {@link JsonComponent @JsonComponent} used for tests. + * + * @author Paul Aly + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +@JsonComponent(type = NameAndCareer.class) +public class NameAndCareerJsonComponent { + + static class Serializer extends JsonObjectSerializer { + + @Override + protected void serializeObject(Name value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + jgen.writeStringField("name", value.getName()); + } + + } + + static class Deserializer extends JsonObjectDeserializer { + + @Override + protected Name deserializeObject(JsonParser jsonParser, DeserializationContext context, ObjectCodec codec, + JsonNode tree) throws IOException { + String name = nullSafeValue(tree.get("name"), String.class); + String career = nullSafeValue(tree.get("career"), String.class); + return new NameAndCareer(name, career); + } + + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/autoconfigure/Jackson2AutoConfigurationTests.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/autoconfigure/Jackson2AutoConfigurationTests.java new file mode 100644 index 00000000000..2b895e59616 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/autoconfigure/Jackson2AutoConfigurationTests.java @@ -0,0 +1,770 @@ +/* + * Copyright 2012-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.boot.jackson2.autoconfigure; + +import java.io.IOException; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.time.Duration; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Stream; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonCreator.Mode; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.AnnotationIntrospector; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.cfg.ConstructorDetector; +import com.fasterxml.jackson.databind.cfg.ConstructorDetector.SingleArgConstructor; +import com.fasterxml.jackson.databind.cfg.EnumFeature; +import com.fasterxml.jackson.databind.cfg.JsonNodeFeature; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.DefaultSerializerProvider; +import com.fasterxml.jackson.databind.util.StdDateFormat; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.predicate.ReflectionHintsPredicates; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.beans.factory.BeanCurrentlyInCreationException; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jackson2.JsonComponent; +import org.springframework.boot.jackson2.JsonMixin; +import org.springframework.boot.jackson2.JsonMixinModule; +import org.springframework.boot.jackson2.JsonMixinModuleEntries; +import org.springframework.boot.jackson2.JsonObjectSerializer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.core.annotation.Order; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link Jackson2AutoConfiguration}. + * + * @author Dave Syer + * @author Oliver Gierke + * @author Andy Wilkinson + * @author Marcel Overdijk + * @author Sebastien Deleuze + * @author Johannes Edmeier + * @author Grzegorz Poznachowski + * @author Ralf Ueberfuhr + * @author EddĂș MelĂ©ndez + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +class Jackson2AutoConfigurationTests { + + protected final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(Jackson2AutoConfiguration.class)); + + // TODO reinstate + // @Test + // void doubleModuleRegistration() { + // this.contextRunner.withUserConfiguration(DoubleModulesConfig.class) + // .withConfiguration(AutoConfigurations.of(HttpMessageConvertersAutoConfiguration.class)) + // .run((context) -> { + // ObjectMapper mapper = context.getBean(ObjectMapper.class); + // assertThat(mapper.writeValueAsString(new Foo())).isEqualTo("{\"foo\":\"bar\"}"); + // }); + // } + + @Test + void jsonMixinModuleShouldBeAutoConfiguredWithBasePackages() { + this.contextRunner.withUserConfiguration(MixinConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(JsonMixinModule.class).hasSingleBean(JsonMixinModuleEntries.class); + JsonMixinModuleEntries moduleEntries = context.getBean(JsonMixinModuleEntries.class); + assertThat(moduleEntries).extracting("entries", InstanceOfAssertFactories.MAP) + .contains(entry(Person.class, EmptyMixin.class)); + }); + } + + @Test + void noCustomDateFormat() { + this.contextRunner.run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(mapper.getDateFormat()).isInstanceOf(StdDateFormat.class); + }); + } + + @Test + void customDateFormat() { + this.contextRunner.withPropertyValues("spring.jackson2.date-format:yyyyMMddHHmmss").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + DateFormat dateFormat = mapper.getDateFormat(); + assertThat(dateFormat).isInstanceOf(SimpleDateFormat.class); + assertThat(((SimpleDateFormat) dateFormat).toPattern()).isEqualTo("yyyyMMddHHmmss"); + }); + } + + @Test + void customDateFormatClass() { + this.contextRunner.withPropertyValues( + "spring.jackson2.date-format:org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfigurationTests.MyDateFormat") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(mapper.getDateFormat()).isInstanceOf(MyDateFormat.class); + }); + } + + @Test + void noCustomPropertyNamingStrategy() { + this.contextRunner.run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(mapper.getPropertyNamingStrategy()).isNull(); + }); + } + + @Test + void customPropertyNamingStrategyField() { + this.contextRunner.withPropertyValues("spring.jackson2.property-naming-strategy:SNAKE_CASE").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(mapper.getPropertyNamingStrategy()).isInstanceOf(SnakeCaseStrategy.class); + }); + } + + @Test + void customPropertyNamingStrategyClass() { + this.contextRunner.withPropertyValues( + "spring.jackson2.property-naming-strategy:com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(mapper.getPropertyNamingStrategy()).isInstanceOf(SnakeCaseStrategy.class); + }); + } + + @Test + void enableSerializationFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.serialization.indent_output:true").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(SerializationFeature.INDENT_OUTPUT.enabledByDefault()).isFalse(); + assertThat(mapper.getSerializationConfig() + .hasSerializationFeatures(SerializationFeature.INDENT_OUTPUT.getMask())).isTrue(); + }); + } + + @Test + void disableSerializationFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.serialization.write_dates_as_timestamps:false") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS.enabledByDefault()).isTrue(); + assertThat(mapper.getSerializationConfig() + .hasSerializationFeatures(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS.getMask())).isFalse(); + }); + } + + @Test + void enableDeserializationFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.deserialization.use_big_decimal_for_floats:true") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS.enabledByDefault()).isFalse(); + assertThat(mapper.getDeserializationConfig() + .hasDeserializationFeatures(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS.getMask())).isTrue(); + }); + } + + @Test + void disableDeserializationFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.deserialization.fail-on-unknown-properties:false") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES.enabledByDefault()).isTrue(); + assertThat(mapper.getDeserializationConfig() + .hasDeserializationFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES.getMask())).isFalse(); + }); + } + + @Test + void enableMapperFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.mapper.require_setters_for_getters:true") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(MapperFeature.REQUIRE_SETTERS_FOR_GETTERS.enabledByDefault()).isFalse(); + + assertThat(mapper.getSerializationConfig().isEnabled(MapperFeature.REQUIRE_SETTERS_FOR_GETTERS)) + .isTrue(); + assertThat(mapper.getDeserializationConfig().isEnabled(MapperFeature.REQUIRE_SETTERS_FOR_GETTERS)) + .isTrue(); + }); + } + + @Test + void disableMapperFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.mapper.use_annotations:false").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(MapperFeature.USE_ANNOTATIONS.enabledByDefault()).isTrue(); + assertThat(mapper.getDeserializationConfig().isEnabled(MapperFeature.USE_ANNOTATIONS)).isFalse(); + assertThat(mapper.getSerializationConfig().isEnabled(MapperFeature.USE_ANNOTATIONS)).isFalse(); + }); + } + + @Test + void enableParserFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.parser.allow_single_quotes:true").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(JsonParser.Feature.ALLOW_SINGLE_QUOTES.enabledByDefault()).isFalse(); + assertThat(mapper.getFactory().isEnabled(JsonParser.Feature.ALLOW_SINGLE_QUOTES)).isTrue(); + }); + } + + @Test + void disableParserFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.parser.auto_close_source:false").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(JsonParser.Feature.AUTO_CLOSE_SOURCE.enabledByDefault()).isTrue(); + assertThat(mapper.getFactory().isEnabled(JsonParser.Feature.AUTO_CLOSE_SOURCE)).isFalse(); + }); + } + + @Test + void enableGeneratorFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.generator.strict_duplicate_detection:true") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + JsonGenerator.Feature feature = JsonGenerator.Feature.STRICT_DUPLICATE_DETECTION; + assertThat(feature.enabledByDefault()).isFalse(); + assertThat(mapper.getFactory().isEnabled(feature)).isTrue(); + }); + } + + @Test + void disableGeneratorFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.generator.auto_close_target:false").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(JsonGenerator.Feature.AUTO_CLOSE_TARGET.enabledByDefault()).isTrue(); + assertThat(mapper.getFactory().isEnabled(JsonGenerator.Feature.AUTO_CLOSE_TARGET)).isFalse(); + }); + } + + @Test + void defaultObjectMapperBuilder() { + this.contextRunner.run((context) -> { + Jackson2ObjectMapperBuilder builder = context.getBean(Jackson2ObjectMapperBuilder.class); + ObjectMapper mapper = builder.build(); + assertThat(MapperFeature.DEFAULT_VIEW_INCLUSION.enabledByDefault()).isTrue(); + assertThat(mapper.getDeserializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse(); + assertThat(MapperFeature.DEFAULT_VIEW_INCLUSION.enabledByDefault()).isTrue(); + assertThat(mapper.getDeserializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse(); + assertThat(mapper.getSerializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse(); + assertThat(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES.enabledByDefault()).isTrue(); + assertThat(mapper.getDeserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)) + .isFalse(); + }); + } + + @Test + void enableEnumFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.datatype.enum.write-enums-to-lowercase=true") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(EnumFeature.WRITE_ENUMS_TO_LOWERCASE.enabledByDefault()).isFalse(); + assertThat(mapper.getSerializationConfig().isEnabled(EnumFeature.WRITE_ENUMS_TO_LOWERCASE)).isTrue(); + }); + } + + @Test + void disableJsonNodeFeature() { + this.contextRunner.withPropertyValues("spring.jackson2.datatype.json-node.write-null-properties:false") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(JsonNodeFeature.WRITE_NULL_PROPERTIES.enabledByDefault()).isTrue(); + assertThat(mapper.getDeserializationConfig().isEnabled(JsonNodeFeature.WRITE_NULL_PROPERTIES)) + .isFalse(); + }); + } + + @Test + void moduleBeansAndWellKnownModulesAreRegisteredWithTheObjectMapperBuilder() { + this.contextRunner.withUserConfiguration(ModuleConfig.class).run((context) -> { + ObjectMapper objectMapper = context.getBean(Jackson2ObjectMapperBuilder.class).build(); + assertThat(context.getBean(CustomModule.class).getOwners()).contains(objectMapper); + assertThat(((DefaultSerializerProvider) objectMapper.getSerializerProviderInstance()) + .hasSerializerFor(Baz.class, null)).isTrue(); + }); + } + + @Test + void customModulesRegisteredByBuilderCustomizerShouldBeRetained() { + this.contextRunner.withUserConfiguration(ModuleConfig.class, CustomModuleBuilderCustomizerConfig.class) + .run((context) -> { + ObjectMapper objectMapper = context.getBean(Jackson2ObjectMapperBuilder.class).build(); + assertThat(context.getBean(CustomModule.class).getOwners()).contains(objectMapper); + assertThat(objectMapper.getRegisteredModuleIds()).contains("module-A", "module-B", + CustomModule.class.getName()); + }); + } + + @Test + void defaultSerializationInclusion() { + this.contextRunner.run((context) -> { + ObjectMapper objectMapper = context.getBean(Jackson2ObjectMapperBuilder.class).build(); + assertThat(objectMapper.getSerializationConfig().getDefaultPropertyInclusion().getValueInclusion()) + .isEqualTo(JsonInclude.Include.USE_DEFAULTS); + }); + } + + @Test + void customSerializationInclusion() { + this.contextRunner.withPropertyValues("spring.jackson2.default-property-inclusion:non_null").run((context) -> { + ObjectMapper objectMapper = context.getBean(Jackson2ObjectMapperBuilder.class).build(); + assertThat(objectMapper.getSerializationConfig().getDefaultPropertyInclusion().getValueInclusion()) + .isEqualTo(JsonInclude.Include.NON_NULL); + }); + } + + @Test + void customTimeZoneFormattingADate() { + this.contextRunner.withPropertyValues("spring.jackson2.time-zone:GMT+10", "spring.jackson2.date-format:z") + .run((context) -> { + ObjectMapper objectMapper = context.getBean(Jackson2ObjectMapperBuilder.class).build(); + Date date = new Date(1436966242231L); + assertThat(objectMapper.writeValueAsString(date)).isEqualTo("\"GMT+10:00\""); + }); + } + + @Test + void enableDefaultLeniency() { + this.contextRunner.withPropertyValues("spring.jackson2.default-leniency:true").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + Person person = mapper.readValue("{\"birthDate\": \"2010-12-30\"}", Person.class); + assertThat(person.getBirthDate()).isNotNull(); + }); + } + + @Test + void disableDefaultLeniency() { + this.contextRunner.withPropertyValues("spring.jackson2.default-leniency:false").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThatExceptionOfType(InvalidFormatException.class) + .isThrownBy(() -> mapper.readValue("{\"birthDate\": \"2010-12-30\"}", Person.class)) + .withMessageContaining("expected format") + .withMessageContaining("yyyyMMdd"); + }); + } + + @Test + void constructorDetectorWithNoStrategyUseDefault() { + this.contextRunner.run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + ConstructorDetector cd = mapper.getDeserializationConfig().getConstructorDetector(); + assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.HEURISTIC); + assertThat(cd.requireCtorAnnotation()).isFalse(); + assertThat(cd.allowJDKTypeConstructors()).isFalse(); + }); + } + + @Test + void constructorDetectorWithDefaultStrategy() { + this.contextRunner.withPropertyValues("spring.jackson2.constructor-detector=default").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + ConstructorDetector cd = mapper.getDeserializationConfig().getConstructorDetector(); + assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.HEURISTIC); + assertThat(cd.requireCtorAnnotation()).isFalse(); + assertThat(cd.allowJDKTypeConstructors()).isFalse(); + }); + } + + @Test + void constructorDetectorWithUsePropertiesBasedStrategy() { + this.contextRunner.withPropertyValues("spring.jackson2.constructor-detector=use-properties-based") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + ConstructorDetector cd = mapper.getDeserializationConfig().getConstructorDetector(); + assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.PROPERTIES); + assertThat(cd.requireCtorAnnotation()).isFalse(); + assertThat(cd.allowJDKTypeConstructors()).isFalse(); + }); + } + + @Test + void constructorDetectorWithUseDelegatingStrategy() { + this.contextRunner.withPropertyValues("spring.jackson2.constructor-detector=use-delegating").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + ConstructorDetector cd = mapper.getDeserializationConfig().getConstructorDetector(); + assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.DELEGATING); + assertThat(cd.requireCtorAnnotation()).isFalse(); + assertThat(cd.allowJDKTypeConstructors()).isFalse(); + }); + } + + @Test + void constructorDetectorWithExplicitOnlyStrategy() { + this.contextRunner.withPropertyValues("spring.jackson2.constructor-detector=explicit-only").run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + ConstructorDetector cd = mapper.getDeserializationConfig().getConstructorDetector(); + assertThat(cd.singleArgMode()).isEqualTo(SingleArgConstructor.REQUIRE_MODE); + assertThat(cd.requireCtorAnnotation()).isFalse(); + assertThat(cd.allowJDKTypeConstructors()).isFalse(); + }); + } + + @Test + void additionalJacksonBuilderCustomization() { + this.contextRunner.withUserConfiguration(ObjectMapperBuilderCustomConfig.class).run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(mapper.getDateFormat()).isInstanceOf(MyDateFormat.class); + }); + } + + @Test + void parameterNamesModuleIsAutoConfigured() { + assertParameterNamesModuleCreatorBinding(Mode.DEFAULT, Jackson2AutoConfiguration.class); + } + + @Test + void customParameterNamesModuleCanBeConfigured() { + assertParameterNamesModuleCreatorBinding(Mode.DELEGATING, ParameterNamesModuleConfig.class, + Jackson2AutoConfiguration.class); + } + + @Test + void writeDurationAsTimestampsDefault() { + this.contextRunner.run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + Duration duration = Duration.ofHours(2); + assertThat(mapper.writeValueAsString(duration)).isEqualTo("\"PT2H\""); + }); + } + + @Test + void writeWithVisibility() { + this.contextRunner + .withPropertyValues("spring.jackson2.visibility.getter:none", "spring.jackson2.visibility.field:any") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + String json = mapper.writeValueAsString(new VisibilityBean()); + assertThat(json).contains("property1"); + assertThat(json).contains("property2"); + assertThat(json).doesNotContain("property3"); + }); + } + + @Test + void builderIsNotSharedAcrossMultipleInjectionPoints() { + this.contextRunner.withUserConfiguration(ObjectMapperBuilderConsumerConfig.class).run((context) -> { + ObjectMapperBuilderConsumerConfig consumer = context.getBean(ObjectMapperBuilderConsumerConfig.class); + assertThat(consumer.builderOne).isNotNull(); + assertThat(consumer.builderTwo).isNotNull(); + assertThat(consumer.builderOne).isNotSameAs(consumer.builderTwo); + }); + } + + @Test + void jsonComponentThatInjectsObjectMapperCausesBeanCurrentlyInCreationException() { + this.contextRunner.withUserConfiguration(CircularDependencySerializerConfiguration.class).run((context) -> { + assertThat(context).hasFailed(); + assertThat(context).getFailure().hasRootCauseInstanceOf(BeanCurrentlyInCreationException.class); + }); + } + + @Test + void shouldRegisterPropertyNamingStrategyHints() { + shouldRegisterPropertyNamingStrategyHints(PropertyNamingStrategies.class, "LOWER_CAMEL_CASE", + "UPPER_CAMEL_CASE", "SNAKE_CASE", "UPPER_SNAKE_CASE", "LOWER_CASE", "KEBAB_CASE", "LOWER_DOT_CASE"); + } + + private void shouldRegisterPropertyNamingStrategyHints(Class type, String... fieldNames) { + RuntimeHints hints = new RuntimeHints(); + new org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.Jackson2AutoConfigurationRuntimeHints() + .registerHints(hints, getClass().getClassLoader()); + ReflectionHintsPredicates reflection = RuntimeHintsPredicates.reflection(); + Stream.of(fieldNames) + .map((name) -> reflection.onField(type, name)) + .forEach((predicate) -> assertThat(predicate).accepts(hints)); + } + + private void assertParameterNamesModuleCreatorBinding(Mode expectedMode, Class... configClasses) { + this.contextRunner.withUserConfiguration(configClasses).run((context) -> { + DeserializationConfig deserializationConfig = context.getBean(ObjectMapper.class) + .getDeserializationConfig(); + AnnotationIntrospector annotationIntrospector = deserializationConfig.getAnnotationIntrospector() + .allIntrospectors() + .iterator() + .next(); + assertThat(annotationIntrospector).hasFieldOrPropertyWithValue("creatorBinding", expectedMode); + }); + } + + static class MyDateFormat extends SimpleDateFormat { + + MyDateFormat() { + super("yyyy-MM-dd HH:mm:ss"); + } + + } + + @Configuration(proxyBeanMethods = false) + static class MockObjectMapperConfig { + + @Bean + @Primary + ObjectMapper objectMapper() { + return mock(ObjectMapper.class); + } + + } + + @Configuration(proxyBeanMethods = false) + @Import(BazSerializer.class) + static class ModuleConfig { + + @Bean + CustomModule jacksonModule() { + return new CustomModule(); + } + + } + + @Configuration + static class DoubleModulesConfig { + + @Bean + Module jacksonModule() { + SimpleModule module = new SimpleModule(); + module.addSerializer(Foo.class, new JsonSerializer<>() { + + @Override + public void serialize(Foo value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + jgen.writeStartObject(); + jgen.writeStringField("foo", "bar"); + jgen.writeEndObject(); + } + }); + return module; + } + + @Bean + @Primary + ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(jacksonModule()); + return mapper; + } + + } + + @Configuration(proxyBeanMethods = false) + static class ParameterNamesModuleConfig { + + @Bean + ParameterNamesModule parameterNamesModule() { + return new ParameterNamesModule(JsonCreator.Mode.DELEGATING); + } + + } + + @Configuration(proxyBeanMethods = false) + static class ObjectMapperBuilderCustomConfig { + + @Bean + Jackson2ObjectMapperBuilderCustomizer customDateFormat() { + return (builder) -> builder.dateFormat(new MyDateFormat()); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomModuleBuilderCustomizerConfig { + + @Bean + @Order(-1) + Jackson2ObjectMapperBuilderCustomizer highPrecedenceCustomizer() { + return (builder) -> builder.modulesToInstall((modules) -> modules.add(new SimpleModule("module-A"))); + } + + @Bean + @Order(1) + Jackson2ObjectMapperBuilderCustomizer lowPrecedenceCustomizer() { + return (builder) -> builder.modulesToInstall((modules) -> modules.add(new SimpleModule("module-B"))); + } + + } + + @Configuration(proxyBeanMethods = false) + static class ObjectMapperBuilderConsumerConfig { + + @Nullable Jackson2ObjectMapperBuilder builderOne; + + @Nullable Jackson2ObjectMapperBuilder builderTwo; + + @Bean + String consumerOne(Jackson2ObjectMapperBuilder builder) { + this.builderOne = builder; + return "one"; + } + + @Bean + String consumerTwo(Jackson2ObjectMapperBuilder builder) { + this.builderTwo = builder; + return "two"; + } + + } + + protected static final class Foo { + + private @Nullable String name; + + private Foo() { + } + + static Foo create() { + return new Foo(); + } + + public @Nullable String getName() { + return this.name; + } + + public void setName(@Nullable String name) { + this.name = name; + } + + } + + static class Bar { + + private @Nullable String propertyName; + + @Nullable String getPropertyName() { + return this.propertyName; + } + + void setPropertyName(@Nullable String propertyName) { + this.propertyName = propertyName; + } + + } + + @JsonComponent + static class BazSerializer extends JsonObjectSerializer { + + @Override + protected void serializeObject(Baz value, JsonGenerator jgen, SerializerProvider provider) { + } + + } + + static class Baz { + + } + + static class CustomModule extends SimpleModule { + + private final Set owners = new HashSet<>(); + + @Override + public void setupModule(SetupContext context) { + this.owners.add(context.getOwner()); + } + + Set getOwners() { + return this.owners; + } + + } + + @SuppressWarnings("unused") + static class VisibilityBean { + + private @Nullable String property1; + + public @Nullable String property2; + + @Nullable String getProperty3() { + return null; + } + + } + + static class Person { + + @JsonFormat(pattern = "yyyyMMdd") + private @Nullable Date birthDate; + + @Nullable Date getBirthDate() { + return this.birthDate; + } + + void setBirthDate(@Nullable Date birthDate) { + this.birthDate = birthDate; + } + + } + + @JsonMixin(type = Person.class) + static class EmptyMixin { + + } + + @AutoConfigurationPackage + static class MixinConfiguration { + + } + + @JsonComponent + static class CircularDependencySerializer extends JsonSerializer { + + CircularDependencySerializer(ObjectMapper objectMapper) { + + } + + @Override + public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + + } + + } + + @Import(CircularDependencySerializer.class) + @Configuration(proxyBeanMethods = false) + static class CircularDependencySerializerConfiguration { + + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/a/RenameMixInClass.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/a/RenameMixInClass.java new file mode 100644 index 00000000000..a0fd94732e2 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/a/RenameMixInClass.java @@ -0,0 +1,33 @@ +/* + * Copyright 2012-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.boot.jackson2.scan.a; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.boot.jackson2.JsonMixin; +import org.springframework.boot.jackson2.types.Name; +import org.springframework.boot.jackson2.types.NameAndAge; + +@JsonMixin(type = { Name.class, NameAndAge.class }) +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public abstract class RenameMixInClass { + + @JsonProperty("username") + public abstract String getName(); + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/b/RenameMixInAbstractClass.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/b/RenameMixInAbstractClass.java new file mode 100644 index 00000000000..80417a4c06b --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/b/RenameMixInAbstractClass.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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.boot.jackson2.scan.b; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.boot.jackson2.JsonMixin; +import org.springframework.boot.jackson2.types.NameAndAge; + +@JsonMixin(type = NameAndAge.class) +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public abstract class RenameMixInAbstractClass { + + @JsonProperty("username") + abstract String getName(); + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/c/RenameMixInInterface.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/c/RenameMixInInterface.java new file mode 100644 index 00000000000..57230acb340 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/c/RenameMixInInterface.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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.boot.jackson2.scan.c; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.boot.jackson2.JsonMixin; +import org.springframework.boot.jackson2.types.NameAndAge; + +@JsonMixin(type = NameAndAge.class) +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public interface RenameMixInInterface { + + @JsonProperty("username") + String getName(); + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/d/EmptyMixInClass.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/d/EmptyMixInClass.java new file mode 100644 index 00000000000..9af003b6bcb --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/d/EmptyMixInClass.java @@ -0,0 +1,28 @@ +/* + * Copyright 2012-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.boot.jackson2.scan.d; + +import org.springframework.boot.jackson2.JsonMixin; +import org.springframework.boot.jackson2.types.Name; +import org.springframework.boot.jackson2.types.NameAndAge; + +@JsonMixin(type = { Name.class, NameAndAge.class }) +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public class EmptyMixInClass { + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/e/PrivateMixInClass.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/e/PrivateMixInClass.java new file mode 100644 index 00000000000..aaf2cab202f --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/e/PrivateMixInClass.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-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.boot.jackson2.scan.e; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.boot.jackson2.JsonMixin; +import org.springframework.boot.jackson2.types.Name; +import org.springframework.boot.jackson2.types.NameAndAge; + +@JsonMixin(type = { Name.class, NameAndAge.class }) +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +class PrivateMixInClass { + + @JsonProperty("username") + String getName() { + return null; + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/f/EmptyMixIn.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/f/EmptyMixIn.java new file mode 100644 index 00000000000..95a2a0ab32c --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/scan/f/EmptyMixIn.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-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.boot.jackson2.scan.f; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.boot.jackson2.JsonMixin; + +@JsonMixin +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public interface EmptyMixIn { + + @JsonProperty("username") + String getName(); + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/Name.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/Name.java new file mode 100644 index 00000000000..9532e2c98de --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/Name.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-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.boot.jackson2.types; + +/** + * Sample object used for tests. + * + * @author Paul Aly + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +public class Name { + + protected final String name; + + public Name(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/NameAndAge.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/NameAndAge.java new file mode 100644 index 00000000000..85813ce0467 --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/NameAndAge.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-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.boot.jackson2.types; + +import org.springframework.util.ObjectUtils; + +/** + * Sample object used for tests. + * + * @author Phillip Webb + * @author Paul Aly + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public final class NameAndAge extends Name { + + private final int age; + + public NameAndAge(String name, int age) { + super(name); + this.age = age; + } + + public int getAge() { + return this.age; + } + + public String asKey() { + return this.name + " is " + this.age; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof NameAndAge other) { + boolean rtn = true; + rtn = rtn && ObjectUtils.nullSafeEquals(this.name, other.name); + rtn = rtn && ObjectUtils.nullSafeEquals(this.age, other.age); + return rtn; + } + return super.equals(obj); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ObjectUtils.nullSafeHashCode(this.name); + result = prime * result + ObjectUtils.nullSafeHashCode(this.age); + return result; + } + +} diff --git a/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/NameAndCareer.java b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/NameAndCareer.java new file mode 100644 index 00000000000..4aa9da7abbf --- /dev/null +++ b/module/spring-boot-jackson2/src/test/java/org/springframework/boot/jackson2/types/NameAndCareer.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-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.boot.jackson2.types; + +/** + * Sample object used for tests. + * + * @author Paul Aly + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3 + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public class NameAndCareer extends Name { + + private final String career; + + public NameAndCareer(String name, String career) { + super(name); + this.career = career; + } + + public String getCareer() { + return this.career; + } + +} diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index 3d3d8b9f159..192ffa77e8f 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -2053,6 +2053,7 @@ bom { "spring-boot-http-converter", "spring-boot-integration", "spring-boot-jackson", + "spring-boot-jackson2", "spring-boot-jarmode-tools", "spring-boot-jdbc", "spring-boot-jdbc-test", diff --git a/settings.gradle b/settings.gradle index d946f982216..2fca806bdd4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -132,6 +132,7 @@ include "module:spring-boot-http-codec" include "module:spring-boot-http-converter" include "module:spring-boot-integration" include "module:spring-boot-jackson" +include "module:spring-boot-jackson2" include "module:spring-boot-jdbc" include "module:spring-boot-jdbc-test" include "module:spring-boot-jetty" From 7b60227f4156562bc0ae3347050bab63d6b8f20b Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Fri, 10 Oct 2025 15:32:01 +0100 Subject: [PATCH 02/10] Consider Jackson 2 in HTTP message converter auto-config See gh-47688 --- .../spring-boot-http-converter/build.gradle | 2 + ...sonHttpMessageConvertersConfiguration.java | 6 + ...ttpMessageConvertersAutoConfiguration.java | 7 +- ...on2HttpMessageConvertersConfiguration.java | 93 ++++++++++++++++ ...onbHttpMessageConvertersConfiguration.java | 5 +- ...itional-spring-configuration-metadata.json | 5 +- ...ssageConvertersAutoConfigurationTests.java | 103 +++++++++++++++++- 7 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/Jackson2HttpMessageConvertersConfiguration.java diff --git a/module/spring-boot-http-converter/build.gradle b/module/spring-boot-http-converter/build.gradle index 9bb40cb0869..1c85d7d28a9 100644 --- a/module/spring-boot-http-converter/build.gradle +++ b/module/spring-boot-http-converter/build.gradle @@ -33,8 +33,10 @@ dependencies { optional(project(":core:spring-boot-autoconfigure")) optional(project(":module:spring-boot-gson")) optional(project(":module:spring-boot-jackson")) + optional(project(":module:spring-boot-jackson2")) optional(project(":module:spring-boot-jsonb")) optional(project(":module:spring-boot-kotlin-serialization")) + optional("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") optional("com.google.code.gson:gson") optional("jakarta.json.bind:jakarta.json.bind-api") optional("org.springframework:spring-webmvc") diff --git a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration.java b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration.java index 17528f4c61e..81d6d7cdb44 100644 --- a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration.java +++ b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/GsonHttpMessageConvertersConfiguration.java @@ -85,6 +85,12 @@ class GsonHttpMessageConvertersConfiguration { } + @SuppressWarnings("removal") + @ConditionalOnBean(org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.class) + static class Jackson2Available { + + } + @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, havingValue = "jsonb") static class JsonbPreferred { diff --git a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfiguration.java b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfiguration.java index 324320cd9a7..bace77bb3f6 100644 --- a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfiguration.java +++ b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfiguration.java @@ -51,14 +51,17 @@ import org.springframework.http.converter.StringHttpMessageConverter; * @author Brian Clozel * @since 4.0.0 */ +@SuppressWarnings("removal") @AutoConfiguration(afterName = { "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration", + "org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration", "org.springframework.boot.jsonb.autoconfigure.JsonbAutoConfiguration", "org.springframework.boot.gson.autoconfigure.GsonAutoConfiguration", "org.springframework.boot.kotlin.serialization.autoconfigure.KotlinSerializationAutoConfiguration" }) @ConditionalOnClass(HttpMessageConverter.class) @Conditional(NotReactiveWebApplicationCondition.class) -@Import({ JacksonHttpMessageConvertersConfiguration.class, GsonHttpMessageConvertersConfiguration.class, - JsonbHttpMessageConvertersConfiguration.class, KotlinSerializationHttpMessageConvertersConfiguration.class }) +@Import({ JacksonHttpMessageConvertersConfiguration.class, Jackson2HttpMessageConvertersConfiguration.class, + GsonHttpMessageConvertersConfiguration.class, JsonbHttpMessageConvertersConfiguration.class, + KotlinSerializationHttpMessageConvertersConfiguration.class }) public final class HttpMessageConvertersAutoConfiguration { static final String PREFERRED_MAPPER_PROPERTY = "spring.http.converters.preferred-json-mapper"; diff --git a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/Jackson2HttpMessageConvertersConfiguration.java b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/Jackson2HttpMessageConvertersConfiguration.java new file mode 100644 index 00000000000..7164f80bf26 --- /dev/null +++ b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/Jackson2HttpMessageConvertersConfiguration.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-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.boot.http.converter.autoconfigure; + +import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.dataformat.xml.XmlMapper; + +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; + +/** + * Configuration for HTTP message converters that use Jackson 2. + * + * @author Andy Wilkinson + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ +@Configuration(proxyBeanMethods = false) +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings({ "deprecation", "removal" }) +class Jackson2HttpMessageConvertersConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ObjectMapper.class) + @ConditionalOnBean(ObjectMapper.class) + @Conditional(PreferJackson2OrJacksonUnavailableCondition.class) + static class MappingJackson2HttpMessageConverterConfiguration { + + @Bean + @ConditionalOnMissingBean + org.springframework.http.converter.json.MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter( + ObjectMapper objectMapper) { + return new org.springframework.http.converter.json.MappingJackson2HttpMessageConverter(objectMapper); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(XmlMapper.class) + @ConditionalOnBean(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.class) + protected static class MappingJackson2XmlHttpMessageConverterConfiguration { + + @Bean + @ConditionalOnMissingBean + public MappingJackson2XmlHttpMessageConverter mappingJackson2XmlHttpMessageConverter( + Jackson2ObjectMapperBuilder builder) { + return new MappingJackson2XmlHttpMessageConverter(builder.createXmlMapper(true).build()); + } + + } + + private static class PreferJackson2OrJacksonUnavailableCondition extends AnyNestedCondition { + + PreferJackson2OrJacksonUnavailableCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, + havingValue = "jackson2") + static class Jackson2Preferred { + + } + + @ConditionalOnMissingBean(JacksonJsonHttpMessageConverter.class) + static class JacksonUnavailable { + + } + + } + +} diff --git a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/JsonbHttpMessageConvertersConfiguration.java b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/JsonbHttpMessageConvertersConfiguration.java index 7669010ae60..be2616b0e7e 100644 --- a/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/JsonbHttpMessageConvertersConfiguration.java +++ b/module/spring-boot-http-converter/src/main/java/org/springframework/boot/http/converter/autoconfigure/JsonbHttpMessageConvertersConfiguration.java @@ -66,7 +66,10 @@ class JsonbHttpMessageConvertersConfiguration { } - @ConditionalOnMissingBean({ JacksonJsonHttpMessageConverter.class, GsonHttpMessageConverter.class }) + @SuppressWarnings("removal") + @ConditionalOnMissingBean({ JacksonJsonHttpMessageConverter.class, + org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.class, + GsonHttpMessageConverter.class }) static class JacksonAndGsonMissing { } diff --git a/module/spring-boot-http-converter/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-http-converter/src/main/resources/META-INF/additional-spring-configuration-metadata.json index d6d92477e76..dfdbb97f5bb 100644 --- a/module/spring-boot-http-converter/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/module/spring-boot-http-converter/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -4,7 +4,7 @@ "name": "spring.http.converters.preferred-json-mapper", "type": "java.lang.String", "defaultValue": "jackson", - "description": "Preferred JSON mapper to use for HTTP message conversion. By default, auto-detected according to the environment. Supported values are 'jackson', 'gson', 'jsonb' and 'kotlin-serialization'. When other json mapping libraries are present, use a custom HttpMessageConverters bean to control the preferred mapper." + "description": "Preferred JSON mapper to use for HTTP message conversion. By default, auto-detected according to the environment. Supported values are 'jackson', 'jackson2' (deprecated), 'gson', 'jsonb' and 'kotlin-serialization'. When other json mapping libraries are present, use a custom HttpMessageConverters bean to control the preferred mapper." } ], "hints": [ @@ -17,6 +17,9 @@ { "value": "jackson" }, + { + "value": "jackson2" + }, { "value": "jsonb" } diff --git a/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfigurationTests.java b/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfigurationTests.java index 2635e75a730..e636dc87b8d 100644 --- a/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfigurationTests.java +++ b/module/spring-boot-http-converter/src/test/java/org/springframework/boot/http/converter/autoconfigure/HttpMessageConvertersAutoConfigurationTests.java @@ -19,18 +19,20 @@ package org.springframework.boot.http.converter.autoconfigure; import java.nio.charset.StandardCharsets; import java.util.List; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.gson.Gson; import jakarta.json.bind.Jsonb; import jakarta.json.bind.JsonbBuilder; import kotlinx.serialization.json.Json; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; import tools.jackson.dataformat.xml.XmlMapper; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; import org.springframework.boot.http.converter.autoconfigure.JacksonHttpMessageConvertersConfiguration.JacksonJsonHttpMessageConverterConfiguration; +import org.springframework.boot.logging.LogLevel; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -77,7 +79,7 @@ class HttpMessageConvertersAutoConfigurationTests { @Test void jacksonNotAvailable() { this.contextRunner.run((context) -> { - assertThat(context).doesNotHaveBean(ObjectMapper.class); + assertThat(context).doesNotHaveBean(JsonMapper.class); assertThat(context).doesNotHaveBean(JacksonJsonHttpMessageConverter.class); assertThat(context).doesNotHaveBean(JacksonXmlHttpMessageConverter.class); }); @@ -107,6 +109,34 @@ class HttpMessageConvertersAutoConfigurationTests { .run(assertConverter(JacksonJsonHttpMessageConverter.class, "customJacksonMessageConverter")); } + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + void jackson2DefaultConverter() { + this.contextRunner.withUserConfiguration(Jackson2ObjectMapperConfig.class) + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .run(assertConverter(org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.class, + "mappingJackson2HttpMessageConverter")); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + void jackson2ConverterWithBuilder() { + this.contextRunner.withUserConfiguration(Jackson2ObjectMapperBuilderConfig.class) + .run(assertConverter(org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.class, + "mappingJackson2HttpMessageConverter")); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + void jackson2CustomConverter() { + this.contextRunner.withUserConfiguration(Jackson2ObjectMapperConfig.class, Jackson2ConverterConfig.class) + .run(assertConverter(org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.class, + "customJacksonMessageConverter")); + } + @Test void gsonNotAvailable() { this.contextRunner.run((context) -> { @@ -244,8 +274,26 @@ class HttpMessageConvertersAutoConfigurationTests { } @Test - void gsonIsPreferredIfJacksonIsNotAvailable() { - allOptionsRunner().withClassLoader(new FilteredClassLoader(ObjectMapper.class.getPackage().getName())) + @SuppressWarnings("removal") + void jackson2IsPreferredIfJacksonIsNotAvailable() { + allOptionsRunner().withClassLoader(new FilteredClassLoader(JsonMapper.class.getPackage().getName())) + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .run((context) -> { + assertConverterBeanExists(context, + org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.class, + "mappingJackson2HttpMessageConverter"); + assertConverterBeanRegisteredWithHttpMessageConverters(context, + org.springframework.http.converter.json.MappingJackson2HttpMessageConverter.class); + assertThat(context).doesNotHaveBean(GsonHttpMessageConverter.class); + assertThat(context).doesNotHaveBean(JsonbHttpMessageConverter.class); + }); + } + + @Test + void gsonIsPreferredIfJacksonAndJackson2AreNotAvailable() { + allOptionsRunner() + .withClassLoader(new FilteredClassLoader(JsonMapper.class.getPackage().getName(), + ObjectMapper.class.getPackage().getName())) .run((context) -> { assertConverterBeanExists(context, GsonHttpMessageConverter.class, "gsonHttpMessageConverter"); assertConverterBeanRegisteredWithHttpMessageConverters(context, GsonHttpMessageConverter.class); @@ -256,8 +304,8 @@ class HttpMessageConvertersAutoConfigurationTests { @Test void jsonbIsPreferredIfJacksonAndGsonAreNotAvailable() { allOptionsRunner() - .withClassLoader(new FilteredClassLoader(ObjectMapper.class.getPackage().getName(), - Gson.class.getPackage().getName())) + .withClassLoader(new FilteredClassLoader(JsonMapper.class.getPackage().getName(), + ObjectMapper.class.getPackage().getName(), Gson.class.getPackage().getName())) .run(assertConverter(JsonbHttpMessageConverter.class, "jsonbHttpMessageConverter")); } @@ -303,6 +351,7 @@ class HttpMessageConvertersAutoConfigurationTests { private ApplicationContextRunner allOptionsRunner() { return this.contextRunner.withBean(Gson.class) .withBean(JsonMapper.class) + .withBean(ObjectMapper.class, ObjectMapper::new) .withBean(Jsonb.class, JsonbBuilder::create) .withBean(Json.class, () -> Json.Default); } @@ -403,6 +452,48 @@ class HttpMessageConvertersAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class Jackson2ObjectMapperConfig { + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + } + + @Configuration(proxyBeanMethods = false) + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + static class Jackson2ObjectMapperBuilderConfig { + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder() { + return new org.springframework.http.converter.json.Jackson2ObjectMapperBuilder(); + } + + } + + @Deprecated(since = "4.0.0", forRemoval = true) + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("removal") + static class Jackson2ConverterConfig { + + @Bean + org.springframework.http.converter.json.MappingJackson2HttpMessageConverter customJacksonMessageConverter( + ObjectMapper objectMapper) { + org.springframework.http.converter.json.MappingJackson2HttpMessageConverter converter = new org.springframework.http.converter.json.MappingJackson2HttpMessageConverter(); + converter.setObjectMapper(objectMapper); + return converter; + } + + } + @Configuration(proxyBeanMethods = false) static class GsonConverterConfig { From eb5cb2d4e15ae76a42a00900cab12591a842c594 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Fri, 10 Oct 2025 15:54:56 +0100 Subject: [PATCH 03/10] Consider Jackson 2 in HTTP codec auto-config See gh-47688 --- module/spring-boot-http-codec/build.gradle | 1 + .../CodecsAutoConfiguration.java | 53 +++++++++++++++++-- ...itional-spring-configuration-metadata.json | 30 +++++++++-- .../CodecsAutoConfigurationTests.java | 45 +++++++++++++++- 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/module/spring-boot-http-codec/build.gradle b/module/spring-boot-http-codec/build.gradle index 7eb8b70cec7..2fae3abe8ea 100644 --- a/module/spring-boot-http-codec/build.gradle +++ b/module/spring-boot-http-codec/build.gradle @@ -31,6 +31,7 @@ dependencies { optional(project(":core:spring-boot-autoconfigure")) optional(project(":core:spring-boot-test")) optional(project(":module:spring-boot-jackson")) + optional(project(":module:spring-boot-jackson2")) optional("org.springframework:spring-webflux") testImplementation(project(":core:spring-boot-test")) diff --git a/module/spring-boot-http-codec/src/main/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfiguration.java b/module/spring-boot-http-codec/src/main/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfiguration.java index f721ee63e96..550e3e79bdd 100644 --- a/module/spring-boot-http-codec/src/main/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfiguration.java +++ b/module/spring-boot-http-codec/src/main/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfiguration.java @@ -16,18 +16,22 @@ package org.springframework.boot.http.codec.autoconfigure; +import com.fasterxml.jackson.databind.ObjectMapper; import org.jspecify.annotations.Nullable; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.http.codec.CodecCustomizer; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; @@ -43,14 +47,17 @@ import org.springframework.web.reactive.function.client.WebClient; * {@link org.springframework.core.codec.Decoder Decoders}. * * @author Brian Clozel - * @since 2.0.0 + * @since 4.0.0 */ -@AutoConfiguration(afterName = "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration") +@AutoConfiguration(afterName = { "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration", + "org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration" }) @ConditionalOnClass({ CodecConfigurer.class, WebClient.class }) public final class CodecsAutoConfiguration { @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(ObjectMapper.class) + @ConditionalOnClass(JsonMapper.class) + @ConditionalOnProperty(name = "spring.http.codecs.preferred-json-mapper", havingValue = "jackson", + matchIfMissing = true) static class JacksonJsonCodecConfiguration { @Bean @@ -66,6 +73,26 @@ public final class CodecsAutoConfiguration { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ObjectMapper.class) + @Conditional(NoJacksonOrJackson2Preferred.class) + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + static class Jackson2JsonCodecConfiguration { + + @Bean + @Order(0) + @ConditionalOnBean(ObjectMapper.class) + CodecCustomizer jackson2CodecCustomizer(ObjectMapper objectMapper) { + return (configurer) -> { + CodecConfigurer.DefaultCodecs defaults = configurer.defaultCodecs(); + defaults.jacksonJsonDecoder(new org.springframework.http.codec.json.Jackson2JsonDecoder(objectMapper)); + defaults.jacksonJsonEncoder(new org.springframework.http.codec.json.Jackson2JsonEncoder(objectMapper)); + }; + } + + } + @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(HttpCodecsProperties.class) static class DefaultCodecsConfiguration { @@ -104,4 +131,22 @@ public final class CodecsAutoConfiguration { } + static class NoJacksonOrJackson2Preferred extends AnyNestedCondition { + + NoJacksonOrJackson2Preferred() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnMissingClass("tools.jackson.databind.json.JsonMapper") + static class NoJackson { + + } + + @ConditionalOnProperty(name = "spring.http.codecs.preferred-json-mapper", havingValue = "jackson2") + static class Jackson2Preferred { + + } + + } + } diff --git a/module/spring-boot-http-codec/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-http-codec/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 671fd2520c2..61e148b885c 100644 --- a/module/spring-boot-http-codec/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/module/spring-boot-http-codec/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -1,4 +1,28 @@ -{ - "groups": [], - "properties": [] +{ + "properties": [ + { + "name": "spring.http.codecs.preferred-json-mapper", + "type": "java.lang.String", + "defaultValue": "jackson", + "description": "Preferred JSON mapper to use for HTTP encoding and decoding. By default, auto-detected according to the environment. Supported values are 'jackson' and 'jackson2' (deprecated)." + } + ], + "hints": [ + { + "name": "spring.http.codecs.preferred-json-mapper", + "values": [ + { + "value": "jackson" + }, + { + "value": "jackson2" + } + ], + "providers": [ + { + "name": "any" + } + ] + } + ] } diff --git a/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationTests.java b/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationTests.java index 39e7d050b67..978b3ec400a 100644 --- a/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationTests.java +++ b/module/spring-boot-http-codec/src/test/java/org/springframework/boot/http/codec/autoconfigure/CodecsAutoConfigurationTests.java @@ -18,11 +18,13 @@ package org.springframework.boot.http.codec.autoconfigure; import java.util.List; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.http.codec.CodecCustomizer; +import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; @@ -72,7 +74,7 @@ class CodecsAutoConfigurationTests { } @Test - void jacksonCodecCustomizerBacksOffWhenThereIsNoObjectMapper() { + void jacksonCodecCustomizerBacksOffWhenThereIsNoJsonMapper() { this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean("jacksonCodecCustomizer")); } @@ -82,6 +84,37 @@ class CodecsAutoConfigurationTests { .run((context) -> assertThat(context).hasBean("jacksonCodecCustomizer")); } + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + void jacksonCodecCustomizerBacksOffWhenJackson2IsPreferred() { + this.contextRunner.withUserConfiguration(JsonMapperConfiguration.class) + .withPropertyValues("spring.http.codecs.preferred-json-mapper=jackson2") + .run((context) -> assertThat(context).doesNotHaveBean("jacksonCodecCustomizer")); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + void jackson2CodecCustomizerIsAutoConfiguredWhenObjectMapperIsPresentAndJackson2IsPreferred() { + this.contextRunner.withUserConfiguration(ObjectMapperConfiguration.class) + .withPropertyValues("spring.http.codecs.preferred-json-mapper=jackson2") + .run((context) -> assertThat(context).hasBean("jackson2CodecCustomizer")); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + void jackson2CodecCustomizerIsAutoConfiguredWhenObjectMapperIsPresentAndJacksonIsMissing() { + this.contextRunner.withUserConfiguration(ObjectMapperConfiguration.class) + .withClassLoader(new FilteredClassLoader(JsonMapper.class.getPackage().getName())) + .run((context) -> assertThat(context).hasBean("jackson2CodecCustomizer")); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + void jackson2CodecCustomizerBacksOffWhenJackson2IsPreferredButThereIsNoObjectMapper() { + this.contextRunner.withPropertyValues("spring.http.codecs.preferred-json-mapper=jackson2") + .run((context) -> assertThat(context).doesNotHaveBean("jackson2CodecCustomizer")); + } + @Test void userProvidedCustomizerCanOverrideJacksonCodecCustomizer() { this.contextRunner.withUserConfiguration(JsonMapperConfiguration.class, CodecCustomizerConfiguration.class) @@ -116,6 +149,16 @@ class CodecsAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class ObjectMapperConfiguration { + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + } + @Configuration(proxyBeanMethods = false) static class CodecCustomizerConfiguration { From 183d765b4d4a9512b0506c02b43b0fe0fa5d6168 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Fri, 10 Oct 2025 14:08:39 -0700 Subject: [PATCH 04/10] Support actuator with Jackson 2 only See gh-47688 --- .../spring-boot-sni-reactive-app/build.gradle | 1 + .../spring-boot-sni-servlet-app/build.gradle | 2 +- .../build.gradle | 4 +- .../Jackson2EndpointAutoConfiguration.java | 55 ++++ .../JacksonEndpointAutoConfiguration.java | 2 +- .../jmx/JmxEndpointAutoConfiguration.java | 5 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 + module/spring-boot-actuator/build.gradle | 2 + .../context/properties/BeanSerializer.java | 34 +++ ...ConfigurationPropertiesReportEndpoint.java | 276 ++--------------- .../properties/Jackson2BeanSerializer.java | 284 ++++++++++++++++++ .../properties/JacksonBeanSerializer.java | 275 +++++++++++++++++ .../jackson/EndpointJackson2ObjectMapper.java | 44 +++ module/spring-boot-webflux/build.gradle | 1 + ...ndpointManagementContextConfiguration.java | 79 ++++- module/spring-boot-webmvc/build.gradle | 1 + ...ndpointManagementContextConfiguration.java | 65 +++- 17 files changed, 850 insertions(+), 281 deletions(-) create mode 100644 module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/Jackson2EndpointAutoConfiguration.java create mode 100644 module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/BeanSerializer.java create mode 100644 module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/Jackson2BeanSerializer.java create mode 100644 module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/JacksonBeanSerializer.java create mode 100644 module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/EndpointJackson2ObjectMapper.java diff --git a/integration-test/spring-boot-sni-integration-tests/spring-boot-sni-reactive-app/build.gradle b/integration-test/spring-boot-sni-integration-tests/spring-boot-sni-reactive-app/build.gradle index 6eca038631c..f7169f354ef 100644 --- a/integration-test/spring-boot-sni-integration-tests/spring-boot-sni-reactive-app/build.gradle +++ b/integration-test/spring-boot-sni-integration-tests/spring-boot-sni-reactive-app/build.gradle @@ -50,6 +50,7 @@ dependencies { implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)) implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-jackson") app(files(sourceSets.main.output)) app("org.springframework.boot:spring-boot-webflux") diff --git a/integration-test/spring-boot-sni-integration-tests/spring-boot-sni-servlet-app/build.gradle b/integration-test/spring-boot-sni-integration-tests/spring-boot-sni-servlet-app/build.gradle index 788925a1454..47e955b164f 100644 --- a/integration-test/spring-boot-sni-integration-tests/spring-boot-sni-servlet-app/build.gradle +++ b/integration-test/spring-boot-sni-integration-tests/spring-boot-sni-servlet-app/build.gradle @@ -45,7 +45,7 @@ dependencies { compileOnly("jakarta.servlet:jakarta.servlet-api:6.0.0") implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)) - implementation("org.springframework.boot:spring-boot-starter-web") { + implementation("org.springframework.boot:spring-boot-starter-webmvc") { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' } implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/module/spring-boot-actuator-autoconfigure/build.gradle b/module/spring-boot-actuator-autoconfigure/build.gradle index 10dcc584158..d95823aba60 100644 --- a/module/spring-boot-actuator-autoconfigure/build.gradle +++ b/module/spring-boot-actuator-autoconfigure/build.gradle @@ -29,13 +29,13 @@ dependencies { api(project(":core:spring-boot-autoconfigure")) api(project(":module:spring-boot-actuator")) - implementation("tools.jackson.core:jackson-databind") - optional(project(":module:spring-boot-web-server")) + optional("com.fasterxml.jackson.core:jackson-databind") optional("io.micrometer:micrometer-core") optional("io.projectreactor:reactor-core") optional("jakarta.servlet:jakarta.servlet-api") optional("org.springframework.security:spring-security-config") + optional("tools.jackson.core:jackson-databind") testFixturesImplementation(project(":core:spring-boot-test")) testFixturesImplementation(project(":test-support:spring-boot-test-support")) diff --git a/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/Jackson2EndpointAutoConfiguration.java b/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/Jackson2EndpointAutoConfiguration.java new file mode 100644 index 00000000000..a09c39a0ea1 --- /dev/null +++ b/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/Jackson2EndpointAutoConfiguration.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.endpoint.jackson; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Endpoint Jackson 2 support. + * + * @author Phillip Webb + * @since 3.0.0 + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ +@AutoConfiguration +@ConditionalOnClass(ObjectMapper.class) +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +public final class Jackson2EndpointAutoConfiguration { + + @Bean + @ConditionalOnBooleanProperty(name = "management.endpoints.jackson.isolated-object-mapper", matchIfMissing = true) + EndpointJackson2ObjectMapper jackson2EndpointJsonMapper() { + ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json() + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, + SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) + .serializationInclusion(Include.NON_NULL) + .build(); + return () -> objectMapper; + } + +} diff --git a/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfiguration.java b/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfiguration.java index fe0052278b3..4703cb89dda 100644 --- a/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfiguration.java +++ b/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfiguration.java @@ -34,11 +34,11 @@ import org.springframework.context.annotation.Bean; * @since 3.0.0 */ @AutoConfiguration +@ConditionalOnClass(ObjectMapper.class) public final class JacksonEndpointAutoConfiguration { @Bean @ConditionalOnBooleanProperty(name = "management.endpoints.jackson.isolated-object-mapper", matchIfMissing = true) - @ConditionalOnClass(ObjectMapper.class) EndpointJsonMapper endpointJsonMapper() { JsonMapper jsonMapper = JsonMapper.builder() .changeDefaultPropertyInclusion( diff --git a/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jmx/JmxEndpointAutoConfiguration.java b/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jmx/JmxEndpointAutoConfiguration.java index 40a04438de1..cfaf5a6fc55 100644 --- a/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jmx/JmxEndpointAutoConfiguration.java +++ b/module/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jmx/JmxEndpointAutoConfiguration.java @@ -42,6 +42,7 @@ import org.springframework.boot.actuate.endpoint.jmx.annotation.JmxEndpointDisco import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; import org.springframework.boot.autoconfigure.condition.SearchStrategy; @@ -99,15 +100,17 @@ public final class JmxEndpointAutoConfiguration { @Bean @ConditionalOnSingleCandidate(MBeanServer.class) + @ConditionalOnClass(ObjectMapper.class) JmxEndpointExporter jmxMBeanExporter(MBeanServer mBeanServer, EndpointObjectNameFactory endpointObjectNameFactory, ObjectProvider objectMapper, JmxEndpointsSupplier jmxEndpointsSupplier) { JmxOperationResponseMapper responseMapper = new JacksonJmxOperationResponseMapper( objectMapper.getIfAvailable()); return new JmxEndpointExporter(mBeanServer, endpointObjectNameFactory, responseMapper, jmxEndpointsSupplier.getEndpoints()); - } + // FIXME + @Bean IncludeExcludeEndpointFilter jmxIncludeExcludePropertyEndpointFilter() { JmxEndpointProperties.Exposure exposure = this.properties.getExposure(); diff --git a/module/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d24d586326c..c6272e69d52 100644 --- a/module/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/module/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -5,6 +5,7 @@ org.springframework.boot.actuate.autoconfigure.condition.ConditionsReportEndpoin org.springframework.boot.actuate.autoconfigure.context.ShutdownEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.context.properties.ConfigurationPropertiesReportEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration +org.springframework.boot.actuate.autoconfigure.endpoint.jackson.Jackson2EndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.endpoint.jackson.JacksonEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.endpoint.jmx.JmxEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration diff --git a/module/spring-boot-actuator/build.gradle b/module/spring-boot-actuator/build.gradle index 5215faae544..00544d29a2c 100644 --- a/module/spring-boot-actuator/build.gradle +++ b/module/spring-boot-actuator/build.gradle @@ -27,6 +27,8 @@ description = "Spring Boot Actuator" dependencies { api(project(":core:spring-boot")) + optional("com.fasterxml.jackson.core:jackson-databind") + optional("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") optional("com.github.ben-manes.caffeine:caffeine") optional("com.google.code.findbugs:jsr305") optional("com.zaxxer:HikariCP") diff --git a/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/BeanSerializer.java b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/BeanSerializer.java new file mode 100644 index 00000000000..b1272e02c9a --- /dev/null +++ b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/BeanSerializer.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-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.boot.actuate.context.properties; + +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +/** + * Strategy used by {@link ConfigurationPropertiesReportEndpoint} to serialize beans into + * a {@link Map}. + * + * @author Phillip Webb + */ +@FunctionalInterface +interface BeanSerializer { + + Map serialize(@Nullable Object bean); + +} diff --git a/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java index 051df426522..7c6382407de 100644 --- a/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java +++ b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java @@ -16,10 +16,7 @@ package org.springframework.boot.actuate.context.properties; -import java.lang.reflect.Constructor; -import java.lang.reflect.Parameter; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -28,31 +25,7 @@ import java.util.Map; import java.util.function.Predicate; import java.util.stream.Collectors; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; -import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.BeanDescription; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.SerializationConfig; -import tools.jackson.databind.SerializationContext; -import tools.jackson.databind.SerializationFeature; -import tools.jackson.databind.cfg.MapperConfig; -import tools.jackson.databind.introspect.Annotated; -import tools.jackson.databind.introspect.AnnotatedMethod; -import tools.jackson.databind.introspect.DefaultAccessorNamingStrategy; -import tools.jackson.databind.introspect.JacksonAnnotationIntrospector; -import tools.jackson.databind.json.JsonMapper; -import tools.jackson.databind.module.SimpleModule; -import tools.jackson.databind.ser.BeanPropertyWriter; -import tools.jackson.databind.ser.BeanSerializerFactory; -import tools.jackson.databind.ser.PropertyWriter; -import tools.jackson.databind.ser.SerializerFactory; -import tools.jackson.databind.ser.ValueSerializerModifier; -import tools.jackson.databind.ser.std.SimpleBeanPropertyFilter; -import tools.jackson.databind.ser.std.SimpleFilterProvider; -import tools.jackson.databind.ser.std.ToStringSerializer; import org.springframework.beans.BeansException; import org.springframework.boot.actuate.endpoint.OperationResponseBody; @@ -66,24 +39,15 @@ import org.springframework.boot.actuate.endpoint.annotation.Selector; import org.springframework.boot.context.properties.BoundConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationPropertiesBean; -import org.springframework.boot.context.properties.bind.BindConstructorProvider; -import org.springframework.boot.context.properties.bind.Bindable; -import org.springframework.boot.context.properties.bind.Name; import org.springframework.boot.context.properties.source.ConfigurationProperty; import org.springframework.boot.context.properties.source.ConfigurationPropertyName; import org.springframework.boot.context.properties.source.ConfigurationPropertySource; import org.springframework.boot.origin.Origin; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; -import org.springframework.core.DefaultParameterNameDiscoverer; -import org.springframework.core.ParameterNameDiscoverer; -import org.springframework.core.annotation.MergedAnnotation; -import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.env.PropertySource; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; -import org.springframework.util.StringUtils; -import org.springframework.util.unit.DataSize; /** * {@link Endpoint @Endpoint} to expose application properties from @@ -107,20 +71,19 @@ import org.springframework.util.unit.DataSize; @Endpoint(id = "configprops") public class ConfigurationPropertiesReportEndpoint implements ApplicationContextAware { - private static final String CONFIGURATION_PROPERTIES_FILTER_ID = "configurationPropertiesFilter"; - private final Sanitizer sanitizer; private final Show showValues; + private final BeanSerializer serializer; + @SuppressWarnings("NullAway.Init") private ApplicationContext context; - private @Nullable ObjectMapper objectMapper; - public ConfigurationPropertiesReportEndpoint(Iterable sanitizingFunctions, Show showValues) { this.sanitizer = new Sanitizer(sanitizingFunctions); this.showValues = showValues; + this.serializer = getBeanSerializer(); } @Override @@ -151,74 +114,46 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext private ConfigurationPropertiesDescriptor getConfigurationProperties(ApplicationContext context, Predicate beanFilterPredicate, boolean showUnsanitized) { - ObjectMapper mapper = getObjectMapper(); Map<@Nullable String, ContextConfigurationPropertiesDescriptor> contexts = new HashMap<>(); ApplicationContext target = context; while (target != null) { - contexts.put(target.getId(), describeBeans(mapper, target, beanFilterPredicate, showUnsanitized)); + contexts.put(target.getId(), describeBeans(target, beanFilterPredicate, showUnsanitized)); target = target.getParent(); } return new ConfigurationPropertiesDescriptor(contexts); } - private ObjectMapper getObjectMapper() { - if (this.objectMapper == null) { - JsonMapper.Builder builder = JsonMapper.builder(); - configureJsonMapper(builder); - this.objectMapper = builder.build(); + @SuppressWarnings("removal") + private static BeanSerializer getBeanSerializer() { + ClassLoader classLoader = ConfigurationPropertiesReportEndpoint.class.getClassLoader(); + if (ClassUtils.isPresent("tools.jackson.databind.json.JsonMapper", classLoader)) { + return new JacksonBeanSerializer(); } - return this.objectMapper; + if (ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader)) { + return new Jackson2BeanSerializer(); + } + return (bean) -> { + throw new IllegalStateException("Jackson is required for the 'configprops' endpoint"); + }; } - /** - * Configure Jackson's {@link JsonMapper} to be used to serialize the - * {@link ConfigurationProperties @ConfigurationProperties} objects into a {@link Map} - * structure. - * @param builder the json mapper builder - * @since 2.6.0 - */ - protected void configureJsonMapper(JsonMapper.Builder builder) { - builder.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); - builder.changeDefaultPropertyInclusion((value) -> value.withValueInclusion(Include.NON_NULL)); - builder.accessorNaming(new DefaultAccessorNamingStrategy.Provider().withFirstCharAcceptance(true, false)); - applyConfigurationPropertiesFilter(builder); - applySerializationModifier(builder); - builder.addModule(new ConfigurationPropertiesModule()); - } - - private void applyConfigurationPropertiesFilter(JsonMapper.Builder builder) { - builder.annotationIntrospector(new ConfigurationPropertiesAnnotationIntrospector()); - builder - .filterProvider(new SimpleFilterProvider().setDefaultFilter(new ConfigurationPropertiesPropertyFilter())); - } - - /** - * Ensure only bindable and non-cyclic bean properties are reported. - * @param builder the JsonMapper builder - */ - private void applySerializationModifier(JsonMapper.Builder builder) { - SerializerFactory factory = BeanSerializerFactory.instance - .withSerializerModifier(new GenericSerializerModifier()); - builder.serializerFactory(factory); - } - - private ContextConfigurationPropertiesDescriptor describeBeans(ObjectMapper mapper, ApplicationContext context, + private ContextConfigurationPropertiesDescriptor describeBeans(ApplicationContext context, Predicate beanFilterPredicate, boolean showUnsanitized) { Map beans = ConfigurationPropertiesBean.getAll(context); Map descriptors = beans.values() .stream() .filter(beanFilterPredicate) .collect(Collectors.toMap(ConfigurationPropertiesBean::getName, - (bean) -> describeBean(mapper, bean, showUnsanitized))); + (bean) -> describeBean(bean, showUnsanitized))); return new ContextConfigurationPropertiesDescriptor(descriptors, (context.getParent() != null) ? context.getParent().getId() : null); } - private ConfigurationPropertiesBeanDescriptor describeBean(ObjectMapper mapper, ConfigurationPropertiesBean bean, + private ConfigurationPropertiesBeanDescriptor describeBean(ConfigurationPropertiesBean bean, boolean showUnsanitized) { String prefix = bean.getAnnotation().prefix(); - Map serialized = safeSerialize(mapper, bean.getInstance(), prefix); + Map serialized = safeSerialize(bean.getInstance(), prefix); Map properties = sanitize(prefix, serialized, showUnsanitized); Map inputs = getInputs(prefix, serialized, showUnsanitized); return new ConfigurationPropertiesBeanDescriptor(prefix, properties, inputs); @@ -227,15 +162,13 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext /** * Cautiously serialize the bean to a map (returning a map with an error message * instead of throwing an exception if there is a problem). - * @param mapper the object mapper * @param bean the source bean * @param prefix the prefix * @return the serialized instance */ - @SuppressWarnings({ "unchecked" }) - private Map safeSerialize(ObjectMapper mapper, @Nullable Object bean, String prefix) { + private Map safeSerialize(@Nullable Object bean, String prefix) { try { - return new HashMap<>(mapper.convertValue(bean, Map.class)); + return new HashMap<>(this.serializer.serialize(bean)); } catch (Exception ex) { return new HashMap<>(Collections.singletonMap("error", "Cannot serialize '" + prefix + "'")); @@ -401,173 +334,6 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext return (prefix.isEmpty() ? prefix : prefix + ".") + key; } - /** - * Extension to {@link JacksonAnnotationIntrospector} to suppress CGLIB generated bean - * properties. - */ - private static final class ConfigurationPropertiesAnnotationIntrospector extends JacksonAnnotationIntrospector { - - @Override - public Object findFilterId(MapperConfig config, Annotated a) { - Object id = super.findFilterId(config, a); - if (id == null) { - id = CONFIGURATION_PROPERTIES_FILTER_ID; - } - return id; - } - - } - - /** - * {@link SimpleBeanPropertyFilter} for serialization of - * {@link ConfigurationProperties @ConfigurationProperties} beans. The filter hides: - * - *
    - *
  • Properties that have a name starting with '$$'. - *
  • Properties that are self-referential. - *
  • Properties that throw an exception when retrieving their value. - *
- */ - private static final class ConfigurationPropertiesPropertyFilter extends SimpleBeanPropertyFilter { - - private static final Log logger = LogFactory.getLog(ConfigurationPropertiesPropertyFilter.class); - - @Override - protected boolean include(BeanPropertyWriter writer) { - return include(writer.getFullName().getSimpleName()); - } - - @Override - protected boolean include(PropertyWriter writer) { - return include(writer.getFullName().getSimpleName()); - } - - private boolean include(String name) { - return !name.startsWith("$$"); - } - - @Override - public void serializeAsProperty(Object pojo, JsonGenerator jgen, SerializationContext context, - PropertyWriter writer) throws Exception { - if (writer instanceof BeanPropertyWriter beanPropertyWriter) { - try { - if (pojo == beanPropertyWriter.get(pojo)) { - if (logger.isDebugEnabled()) { - logger.debug("Skipping '" + writer.getFullName() + "' on '" + pojo.getClass().getName() - + "' as it is self-referential"); - } - return; - } - } - catch (Exception ex) { - if (logger.isDebugEnabled()) { - logger.debug("Skipping '" + writer.getFullName() + "' on '" + pojo.getClass().getName() - + "' as an exception was thrown when retrieving its value", ex); - } - return; - } - } - super.serializeAsProperty(pojo, jgen, context, writer); - } - - } - - /** - * {@link SimpleModule} for configuring the serializer. - */ - private static final class ConfigurationPropertiesModule extends SimpleModule { - - private ConfigurationPropertiesModule() { - addSerializer(DataSize.class, ToStringSerializer.instance); - } - - } - - /** - * {@link ValueSerializerModifier} to return only relevant configuration properties. - */ - protected static class GenericSerializerModifier extends ValueSerializerModifier { - - private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); - - @Override - public List changeProperties(SerializationConfig config, BeanDescription.Supplier beanDesc, - List beanProperties) { - List result = new ArrayList<>(); - Class beanClass = beanDesc.getType().getRawClass(); - Bindable bindable = Bindable.of(ClassUtils.getUserClass(beanClass)); - Constructor bindConstructor = BindConstructorProvider.DEFAULT.getBindConstructor(bindable, false); - for (BeanPropertyWriter writer : beanProperties) { - if (isCandidate(beanDesc, writer, bindConstructor)) { - result.add(writer); - } - } - return result; - } - - private boolean isCandidate(BeanDescription.Supplier beanDesc, BeanPropertyWriter writer, - @Nullable Constructor constructor) { - if (constructor != null) { - Parameter[] parameters = constructor.getParameters(); - @Nullable String @Nullable [] names = PARAMETER_NAME_DISCOVERER.getParameterNames(constructor); - if (names == null) { - names = new String[parameters.length]; - } - for (int i = 0; i < parameters.length; i++) { - String name = MergedAnnotations.from(parameters[i]) - .get(Name.class) - .getValue(MergedAnnotation.VALUE, String.class) - .orElse((names[i] != null) ? names[i] : parameters[i].getName()); - if (name != null && name.equals(writer.getName())) { - return true; - } - } - } - return isReadable(beanDesc, writer); - } - - private boolean isReadable(BeanDescription.Supplier beanDesc, BeanPropertyWriter writer) { - Class parentType = beanDesc.get().getType().getRawClass(); - Class type = writer.getType().getRawClass(); - AnnotatedMethod setter = findSetter(beanDesc.get(), writer); - // If there's a setter, we assume it's OK to report on the value, - // similarly, if there's no setter but the package names match, we assume - // that it is a nested class used solely for binding to config props, so it - // should be kosher. Lists and Maps are also auto-detected by default since - // that's what the metadata generator does. This filter is not used if there - // is JSON metadata for the property, so it's mainly for user-defined beans. - return (setter != null) || ClassUtils.getPackageName(parentType).equals(ClassUtils.getPackageName(type)) - || Map.class.isAssignableFrom(type) || Collection.class.isAssignableFrom(type); - } - - private @Nullable AnnotatedMethod findSetter(BeanDescription beanDesc, BeanPropertyWriter writer) { - String name = "set" + determineAccessorSuffix(writer.getName()); - Class type = writer.getType().getRawClass(); - AnnotatedMethod setter = beanDesc.findMethod(name, new Class[] { type }); - // The enabled property of endpoints returns a boolean primitive but is set - // using a Boolean class - if (setter == null && type.equals(Boolean.TYPE)) { - setter = beanDesc.findMethod(name, new Class[] { Boolean.class }); - } - return setter; - } - - /** - * Determine the accessor suffix of the specified {@code propertyName}, see - * section 8.8 "Capitalization of inferred names" of the JavaBean specs for more - * details. - * @param propertyName the property name to turn into an accessor suffix - * @return the accessor suffix for {@code propertyName} - */ - private String determineAccessorSuffix(String propertyName) { - if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) { - return propertyName; - } - return StringUtils.capitalize(propertyName); - } - - } - /** * Description of an application's * {@link ConfigurationProperties @ConfigurationProperties} beans. diff --git a/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/Jackson2BeanSerializer.java b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/Jackson2BeanSerializer.java new file mode 100644 index 00000000000..27dc8ad80fe --- /dev/null +++ b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/Jackson2BeanSerializer.java @@ -0,0 +1,284 @@ +/* + * Copyright 2012-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.boot.actuate.context.properties; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.introspect.Annotated; +import com.fasterxml.jackson.databind.introspect.AnnotatedMethod; +import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.ser.BeanSerializerFactory; +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; +import com.fasterxml.jackson.databind.ser.PropertyWriter; +import com.fasterxml.jackson.databind.ser.SerializerFactory; +import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.BindConstructorProvider; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Name; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.util.unit.DataSize; + +/** + * {@link BeanSerializer} backed by Jackson 2. + * + * @author Phillip Webb + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ +@Deprecated(since = "4.0.0", forRemoval = true) +class Jackson2BeanSerializer implements BeanSerializer { + + private static final String CONFIGURATION_PROPERTIES_FILTER_ID = "configurationPropertiesFilter"; + + private final ObjectMapper mapper; + + Jackson2BeanSerializer() { + JsonMapper.Builder builder = JsonMapper.builder(); + configureMapper(builder); + this.mapper = builder.build(); + } + + private void configureMapper(JsonMapper.Builder builder) { + builder.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + builder.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + builder.configure(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, false); + builder.configure(MapperFeature.USE_STD_BEAN_NAMING, true); + builder.serializationInclusion(Include.NON_NULL); + applyConfigurationPropertiesFilter(builder); + applySerializationModifier(builder); + if (ClassUtils.isPresent("com.fasterxml.jackson.datatype.jsr310.JavaTimeModule", + builder.getClass().getClassLoader())) { + builder.addModule(new JavaTimeModule()); + } + builder.addModule(new ConfigurationPropertiesModule()); + } + + private void applyConfigurationPropertiesFilter(JsonMapper.Builder builder) { + builder.annotationIntrospector(new ConfigurationPropertiesAnnotationIntrospector()); + ConfigurationPropertiesPropertyFilter filter = new ConfigurationPropertiesPropertyFilter(); + builder.filterProvider(new SimpleFilterProvider().setDefaultFilter(filter)); + } + + /** + * Ensure only bindable and non-cyclic bean properties are reported. + * @param builder the JsonMapper builder + */ + private void applySerializationModifier(JsonMapper.Builder builder) { + SerializerFactory factory = BeanSerializerFactory.instance + .withSerializerModifier(new GenericSerializerModifier()); + builder.serializerFactory(factory); + } + + @Override + @SuppressWarnings("unchecked") + public Map serialize(@Nullable Object bean) { + return this.mapper.convertValue(bean, Map.class); + } + + /** + * Extension to {@link JacksonAnnotationIntrospector} to suppress CGLIB generated bean + * properties. + */ + private static final class ConfigurationPropertiesAnnotationIntrospector extends JacksonAnnotationIntrospector { + + @Override + public Object findFilterId(Annotated a) { + Object id = super.findFilterId(a); + return (id != null) ? id : CONFIGURATION_PROPERTIES_FILTER_ID; + } + + } + + /** + * {@link SimpleBeanPropertyFilter} for serialization of + * {@link ConfigurationProperties @ConfigurationProperties} beans. The filter hides: + * + *
    + *
  • Properties that have a name starting with '$$'. + *
  • Properties that are self-referential. + *
  • Properties that throw an exception when retrieving their value. + *
+ */ + private static final class ConfigurationPropertiesPropertyFilter extends SimpleBeanPropertyFilter { + + private static final Log logger = LogFactory.getLog(ConfigurationPropertiesPropertyFilter.class); + + @Override + protected boolean include(BeanPropertyWriter writer) { + return include(writer.getFullName().getSimpleName()); + } + + @Override + protected boolean include(PropertyWriter writer) { + return include(writer.getFullName().getSimpleName()); + } + + private boolean include(String name) { + return !name.startsWith("$$"); + } + + @Override + public void serializeAsField(Object pojo, JsonGenerator jgen, SerializerProvider provider, + PropertyWriter writer) throws Exception { + if (writer instanceof BeanPropertyWriter beanPropertyWriter) { + try { + if (pojo == beanPropertyWriter.get(pojo)) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping '" + writer.getFullName() + "' on '" + pojo.getClass().getName() + + "' as it is self-referential"); + } + return; + } + } + catch (Exception ex) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping '" + writer.getFullName() + "' on '" + pojo.getClass().getName() + + "' as an exception was thrown when retrieving its value", ex); + } + return; + } + } + super.serializeAsField(pojo, jgen, provider, writer); + } + + } + + /** + * {@link SimpleModule} for configuring the serializer. + */ + private static final class ConfigurationPropertiesModule extends SimpleModule { + + private ConfigurationPropertiesModule() { + addSerializer(DataSize.class, ToStringSerializer.instance); + } + + } + + /** + * {@link BeanSerializerModifier} to return only relevant configuration properties. + */ + protected static class GenericSerializerModifier extends BeanSerializerModifier { + + private static final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + @Override + public List changeProperties(SerializationConfig config, BeanDescription beanDesc, + List beanProperties) { + List result = new ArrayList<>(); + Class beanClass = beanDesc.getType().getRawClass(); + Bindable bindable = Bindable.of(ClassUtils.getUserClass(beanClass)); + Constructor bindConstructor = BindConstructorProvider.DEFAULT.getBindConstructor(bindable, false); + for (BeanPropertyWriter writer : beanProperties) { + if (isCandidate(beanDesc, writer, bindConstructor)) { + result.add(writer); + } + } + return result; + } + + private boolean isCandidate(BeanDescription beanDesc, BeanPropertyWriter writer, + @Nullable Constructor constructor) { + if (constructor != null) { + Parameter[] parameters = constructor.getParameters(); + @Nullable String @Nullable [] names = parameterNameDiscoverer.getParameterNames(constructor); + if (names == null) { + names = new String[parameters.length]; + } + for (int i = 0; i < parameters.length; i++) { + String name = MergedAnnotations.from(parameters[i]) + .get(Name.class) + .getValue(MergedAnnotation.VALUE, String.class) + .orElse((names[i] != null) ? names[i] : parameters[i].getName()); + if (name.equals(writer.getName())) { + return true; + } + } + } + return isReadable(beanDesc, writer); + } + + private boolean isReadable(BeanDescription beanDesc, BeanPropertyWriter writer) { + Class parentType = beanDesc.getType().getRawClass(); + Class type = writer.getType().getRawClass(); + AnnotatedMethod setter = findSetter(beanDesc, writer); + // If there's a setter, we assume it's OK to report on the value, + // similarly, if there's no setter but the package names match, we assume + // that it is a nested class used solely for binding to config props, so it + // should be kosher. Lists and Maps are also auto-detected by default since + // that's what the metadata generator does. This filter is not used if there + // is JSON metadata for the property, so it's mainly for user-defined beans. + return (setter != null) || ClassUtils.getPackageName(parentType).equals(ClassUtils.getPackageName(type)) + || Map.class.isAssignableFrom(type) || Collection.class.isAssignableFrom(type); + } + + private @Nullable AnnotatedMethod findSetter(BeanDescription beanDesc, BeanPropertyWriter writer) { + String name = "set" + determineAccessorSuffix(writer.getName()); + Class type = writer.getType().getRawClass(); + AnnotatedMethod setter = beanDesc.findMethod(name, new Class[] { type }); + // The enabled property of endpoints returns a boolean primitive but is set + // using a Boolean class + if (setter == null && type.equals(Boolean.TYPE)) { + setter = beanDesc.findMethod(name, new Class[] { Boolean.class }); + } + return setter; + } + + /** + * Determine the accessor suffix of the specified {@code propertyName}, see + * section 8.8 "Capitalization of inferred names" of the JavaBean specs for more + * details. + * @param propertyName the property name to turn into an accessor suffix + * @return the accessor suffix for {@code propertyName} + */ + private String determineAccessorSuffix(String propertyName) { + if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) { + return propertyName; + } + return StringUtils.capitalize(propertyName); + } + + } + +} diff --git a/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/JacksonBeanSerializer.java b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/JacksonBeanSerializer.java new file mode 100644 index 00000000000..9c1f6e9032b --- /dev/null +++ b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/JacksonBeanSerializer.java @@ -0,0 +1,275 @@ +/* + * Copyright 2012-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.boot.actuate.context.properties; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.SerializationConfig; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.cfg.MapperConfig; +import tools.jackson.databind.introspect.Annotated; +import tools.jackson.databind.introspect.AnnotatedMethod; +import tools.jackson.databind.introspect.DefaultAccessorNamingStrategy; +import tools.jackson.databind.introspect.JacksonAnnotationIntrospector; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.BeanPropertyWriter; +import tools.jackson.databind.ser.BeanSerializerFactory; +import tools.jackson.databind.ser.PropertyWriter; +import tools.jackson.databind.ser.SerializerFactory; +import tools.jackson.databind.ser.ValueSerializerModifier; +import tools.jackson.databind.ser.std.SimpleBeanPropertyFilter; +import tools.jackson.databind.ser.std.SimpleFilterProvider; +import tools.jackson.databind.ser.std.ToStringSerializer; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.BindConstructorProvider; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Name; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.util.unit.DataSize; + +/** + * {@link BeanSerializer} backed by Jackson. + * + * @author Phillip Webb + */ +class JacksonBeanSerializer implements BeanSerializer { + + private static final String CONFIGURATION_PROPERTIES_FILTER_ID = "configurationPropertiesFilter"; + + private final JsonMapper mapper; + + JacksonBeanSerializer() { + JsonMapper.Builder builder = JsonMapper.builder(); + configureMapper(builder); + this.mapper = builder.build(); + } + + private void configureMapper(JsonMapper.Builder builder) { + builder.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + builder.changeDefaultPropertyInclusion((value) -> value.withValueInclusion(Include.NON_NULL)); + builder.accessorNaming(new DefaultAccessorNamingStrategy.Provider().withFirstCharAcceptance(true, false)); + applyConfigurationPropertiesFilter(builder); + applySerializationModifier(builder); + builder.addModule(new ConfigurationPropertiesModule()); + } + + private void applyConfigurationPropertiesFilter(JsonMapper.Builder builder) { + builder.annotationIntrospector(new ConfigurationPropertiesAnnotationIntrospector()); + ConfigurationPropertiesPropertyFilter filter = new ConfigurationPropertiesPropertyFilter(); + builder.filterProvider(new SimpleFilterProvider().setDefaultFilter(filter)); + } + + /** + * Ensure only bindable and non-cyclic bean properties are reported. + * @param builder the JsonMapper builder + */ + private void applySerializationModifier(JsonMapper.Builder builder) { + SerializerFactory factory = BeanSerializerFactory.instance + .withSerializerModifier(new GenericSerializerModifier()); + builder.serializerFactory(factory); + } + + @Override + @SuppressWarnings("unchecked") + public Map serialize(@Nullable Object bean) { + return this.mapper.convertValue(bean, Map.class); + } + + /** + * Extension to {@link JacksonAnnotationIntrospector} to suppress CGLIB generated bean + * properties. + */ + private static final class ConfigurationPropertiesAnnotationIntrospector extends JacksonAnnotationIntrospector { + + @Override + public Object findFilterId(MapperConfig config, Annotated a) { + Object id = super.findFilterId(config, a); + return (id != null) ? id : CONFIGURATION_PROPERTIES_FILTER_ID; + } + + } + + /** + * {@link SimpleBeanPropertyFilter} for serialization of + * {@link ConfigurationProperties @ConfigurationProperties} beans. The filter hides: + * + *
    + *
  • Properties that have a name starting with '$$'. + *
  • Properties that are self-referential. + *
  • Properties that throw an exception when retrieving their value. + *
+ */ + private static final class ConfigurationPropertiesPropertyFilter extends SimpleBeanPropertyFilter { + + private static final Log logger = LogFactory.getLog(ConfigurationPropertiesPropertyFilter.class); + + @Override + protected boolean include(BeanPropertyWriter writer) { + return include(writer.getFullName().getSimpleName()); + } + + @Override + protected boolean include(PropertyWriter writer) { + return include(writer.getFullName().getSimpleName()); + } + + private boolean include(String name) { + return !name.startsWith("$$"); + } + + @Override + public void serializeAsProperty(Object pojo, JsonGenerator jgen, SerializationContext context, + PropertyWriter writer) throws Exception { + if (writer instanceof BeanPropertyWriter beanPropertyWriter) { + try { + if (pojo == beanPropertyWriter.get(pojo)) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping '" + writer.getFullName() + "' on '" + pojo.getClass().getName() + + "' as it is self-referential"); + } + return; + } + } + catch (Exception ex) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping '" + writer.getFullName() + "' on '" + pojo.getClass().getName() + + "' as an exception was thrown when retrieving its value", ex); + } + return; + } + } + super.serializeAsProperty(pojo, jgen, context, writer); + } + + } + + /** + * {@link SimpleModule} for configuring the serializer. + */ + private static final class ConfigurationPropertiesModule extends SimpleModule { + + private ConfigurationPropertiesModule() { + addSerializer(DataSize.class, ToStringSerializer.instance); + } + + } + + /** + * {@link ValueSerializerModifier} to return only relevant configuration properties. + */ + protected static class GenericSerializerModifier extends ValueSerializerModifier { + + private static final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + @Override + public List changeProperties(SerializationConfig config, BeanDescription.Supplier beanDesc, + List beanProperties) { + List result = new ArrayList<>(); + Class beanClass = beanDesc.getType().getRawClass(); + Bindable bindable = Bindable.of(ClassUtils.getUserClass(beanClass)); + Constructor bindConstructor = BindConstructorProvider.DEFAULT.getBindConstructor(bindable, false); + for (BeanPropertyWriter writer : beanProperties) { + if (isCandidate(beanDesc, writer, bindConstructor)) { + result.add(writer); + } + } + return result; + } + + private boolean isCandidate(BeanDescription.Supplier beanDesc, BeanPropertyWriter writer, + @Nullable Constructor constructor) { + if (constructor != null) { + Parameter[] parameters = constructor.getParameters(); + @Nullable String @Nullable [] names = parameterNameDiscoverer.getParameterNames(constructor); + if (names == null) { + names = new String[parameters.length]; + } + for (int i = 0; i < parameters.length; i++) { + String name = MergedAnnotations.from(parameters[i]) + .get(Name.class) + .getValue(MergedAnnotation.VALUE, String.class) + .orElse((names[i] != null) ? names[i] : parameters[i].getName()); + if (name != null && name.equals(writer.getName())) { + return true; + } + } + } + return isReadable(beanDesc, writer); + } + + private boolean isReadable(BeanDescription.Supplier beanDesc, BeanPropertyWriter writer) { + Class parentType = beanDesc.get().getType().getRawClass(); + Class type = writer.getType().getRawClass(); + AnnotatedMethod setter = findSetter(beanDesc.get(), writer); + // If there's a setter, we assume it's OK to report on the value, + // similarly, if there's no setter but the package names match, we assume + // that it is a nested class used solely for binding to config props, so it + // should be kosher. Lists and Maps are also auto-detected by default since + // that's what the metadata generator does. This filter is not used if there + // is JSON metadata for the property, so it's mainly for user-defined beans. + return (setter != null) || ClassUtils.getPackageName(parentType).equals(ClassUtils.getPackageName(type)) + || Map.class.isAssignableFrom(type) || Collection.class.isAssignableFrom(type); + } + + private @Nullable AnnotatedMethod findSetter(BeanDescription beanDesc, BeanPropertyWriter writer) { + String name = "set" + determineAccessorSuffix(writer.getName()); + Class type = writer.getType().getRawClass(); + AnnotatedMethod setter = beanDesc.findMethod(name, new Class[] { type }); + // The enabled property of endpoints returns a boolean primitive but is set + // using a Boolean class + if (setter == null && type.equals(Boolean.TYPE)) { + setter = beanDesc.findMethod(name, new Class[] { Boolean.class }); + } + return setter; + } + + /** + * Determine the accessor suffix of the specified {@code propertyName}, see + * section 8.8 "Capitalization of inferred names" of the JavaBean specs for more + * details. + * @param propertyName the property name to turn into an accessor suffix + * @return the accessor suffix for {@code propertyName} + */ + private String determineAccessorSuffix(String propertyName) { + if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) { + return propertyName; + } + return StringUtils.capitalize(propertyName); + } + + } + +} diff --git a/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/EndpointJackson2ObjectMapper.java b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/EndpointJackson2ObjectMapper.java new file mode 100644 index 00000000000..4ce79d1e15d --- /dev/null +++ b/module/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/EndpointJackson2ObjectMapper.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-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.boot.actuate.endpoint.jackson; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; + +import org.springframework.boot.actuate.endpoint.OperationResponseBody; + +/** + * Interface used to supply the Jackson 2 {@link ObjectMapper} that should be used when + * serializing endpoint results. + * + * @author Phillip Webb + * @since 4.0.0 + * @see OperationResponseBody + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ +@FunctionalInterface +@Deprecated(since = "4.0.0", forRemoval = true) +public interface EndpointJackson2ObjectMapper { + + /** + * Return the {@link JsonMapper} that should be used to serialize + * {@link OperationResponseBody} endpoint results. + * @return the object mapper + */ + ObjectMapper get(); + +} diff --git a/module/spring-boot-webflux/build.gradle b/module/spring-boot-webflux/build.gradle index fcc66e43844..f0ccc333326 100644 --- a/module/spring-boot-webflux/build.gradle +++ b/module/spring-boot-webflux/build.gradle @@ -39,6 +39,7 @@ dependencies { optional(project(":module:spring-boot-micrometer-observation")) optional(project(":module:spring-boot-jackson")) optional(project(":module:spring-boot-validation")) + optional("com.fasterxml.jackson.core:jackson-databind") optional("org.springframework.security:spring-security-core") testFixturesApi(testFixtures(project(":module:spring-boot-actuator"))) diff --git a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/actuate/web/WebFluxEndpointManagementContextConfiguration.java b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/actuate/web/WebFluxEndpointManagementContextConfiguration.java index 39c4388fe84..522b3f5227b 100644 --- a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/actuate/web/WebFluxEndpointManagementContextConfiguration.java +++ b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/actuate/web/WebFluxEndpointManagementContextConfiguration.java @@ -86,6 +86,9 @@ import org.springframework.web.reactive.DispatcherHandler; @EnableConfigurationProperties(CorsEndpointProperties.class) public class WebFluxEndpointManagementContextConfiguration { + private static final List MEDIA_TYPES = Collections + .unmodifiableList(Arrays.asList(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"))); + @Bean @ConditionalOnMissingBean @SuppressWarnings("removal") @@ -148,20 +151,26 @@ public class WebFluxEndpointManagementContextConfiguration { SingletonSupplier.of(endpointJsonMapper::getObject)); } + @Bean + @SuppressWarnings("removal") + @ConditionalOnBean(org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper.class) + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static ServerCodecConfigurerEndpointJackson2JsonMapperBeanPostProcessor serverCodecConfigurerEndpointJackson2JsonMapperBeanPostProcessor( + ObjectProvider endpointJsonMapper) { + return new ServerCodecConfigurerEndpointJackson2JsonMapperBeanPostProcessor( + SingletonSupplier.of(endpointJsonMapper::getObject)); + } + /** * {@link BeanPostProcessor} to apply {@link EndpointJsonMapper} for - * {@link OperationResponseBody} to - * {@link org.springframework.http.codec.json.Jackson2JsonEncoder} instances. + * {@link OperationResponseBody} to {@link JacksonJsonEncoder} instances. */ static class ServerCodecConfigurerEndpointJsonMapperBeanPostProcessor implements BeanPostProcessor { - private static final List MEDIA_TYPES = Collections - .unmodifiableList(Arrays.asList(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"))); + private final Supplier mapper; - private final Supplier endpointJsonMapper; - - ServerCodecConfigurerEndpointJsonMapperBeanPostProcessor(Supplier endpointJsonMapper) { - this.endpointJsonMapper = endpointJsonMapper; + ServerCodecConfigurerEndpointJsonMapperBeanPostProcessor(Supplier mapper) { + this.mapper = mapper; } @Override @@ -181,10 +190,56 @@ public class WebFluxEndpointManagementContextConfiguration { } private void process(Encoder encoder) { - if (encoder instanceof JacksonJsonEncoder jacksonJsonEncoder) { - jacksonJsonEncoder.registerMappersForType(OperationResponseBody.class, (associations) -> { - JsonMapper jsonMapper = this.endpointJsonMapper.get().get(); - MEDIA_TYPES.forEach((mimeType) -> associations.put(mimeType, jsonMapper)); + if (encoder instanceof JacksonJsonEncoder jacksonEncoder) { + jacksonEncoder.registerMappersForType(OperationResponseBody.class, (associations) -> { + JsonMapper mapper = this.mapper.get().get(); + MEDIA_TYPES.forEach((mimeType) -> associations.put(mimeType, mapper)); + }); + } + } + + } + + /** + * {@link BeanPostProcessor} to apply + * {@link org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper} + * for {@link OperationResponseBody} to + * {@link org.springframework.http.codec.json.Jackson2JsonEncoder} instances. + * + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + static class ServerCodecConfigurerEndpointJackson2JsonMapperBeanPostProcessor implements BeanPostProcessor { + + private final Supplier mapper; + + ServerCodecConfigurerEndpointJackson2JsonMapperBeanPostProcessor( + Supplier mapper) { + this.mapper = mapper; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof ServerCodecConfigurer serverCodecConfigurer) { + process(serverCodecConfigurer); + } + return bean; + } + + private void process(ServerCodecConfigurer configurer) { + for (HttpMessageWriter writer : configurer.getWriters()) { + if (writer instanceof EncoderHttpMessageWriter encoderHttpMessageWriter) { + process((encoderHttpMessageWriter).getEncoder()); + } + } + } + + private void process(Encoder encoder) { + if (encoder instanceof org.springframework.http.codec.json.Jackson2JsonEncoder jacksonEncoder) { + jacksonEncoder.registerObjectMappersForType(OperationResponseBody.class, (associations) -> { + com.fasterxml.jackson.databind.ObjectMapper mapper = this.mapper.get().get(); + MEDIA_TYPES.forEach((mimeType) -> associations.put(mimeType, mapper)); }); } } diff --git a/module/spring-boot-webmvc/build.gradle b/module/spring-boot-webmvc/build.gradle index c2103bc908d..ce06ce2caca 100644 --- a/module/spring-boot-webmvc/build.gradle +++ b/module/spring-boot-webmvc/build.gradle @@ -43,6 +43,7 @@ dependencies { optional(project(":module:spring-boot-tomcat")) optional(project(":module:spring-boot-validation")) optional(project(":module:spring-boot-web-server")) + optional("com.fasterxml.jackson.core:jackson-databind") optional("io.projectreactor:reactor-core") testFixturesApi(testFixtures(project(":module:spring-boot-actuator"))) diff --git a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/actuate/web/WebMvcEndpointManagementContextConfiguration.java b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/actuate/web/WebMvcEndpointManagementContextConfiguration.java index 509ca33bdc5..d216c11d2ec 100644 --- a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/actuate/web/WebMvcEndpointManagementContextConfiguration.java +++ b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/actuate/web/WebMvcEndpointManagementContextConfiguration.java @@ -78,6 +78,9 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @EnableConfigurationProperties(CorsEndpointProperties.class) public class WebMvcEndpointManagementContextConfiguration { + private static final List MEDIA_TYPES = Collections + .unmodifiableList(Arrays.asList(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"))); + @Bean @ConditionalOnMissingBean @SuppressWarnings("removal") @@ -157,33 +160,77 @@ public class WebMvcEndpointManagementContextConfiguration { return new EndpointJsonMapperWebMvcConfigurer(endpointJsonMapper); } + @Bean + @SuppressWarnings("removal") + @ConditionalOnBean(org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper.class) + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static EndpointJackson2ObjectMapperWebMvcConfigurer endpointJackson2ObjectMapperWebMvcConfigurer( + org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper endpointJsonMapper) { + return new EndpointJackson2ObjectMapperWebMvcConfigurer(endpointJsonMapper); + } + /** * {@link WebMvcConfigurer} to apply {@link EndpointJsonMapper} for * {@link OperationResponseBody} to {@link JacksonJsonHttpMessageConverter} instances. */ static class EndpointJsonMapperWebMvcConfigurer implements WebMvcConfigurer { - private static final List MEDIA_TYPES = Collections - .unmodifiableList(Arrays.asList(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"))); + private final EndpointJsonMapper mapper; - private final EndpointJsonMapper endpointJsonMapper; - - EndpointJsonMapperWebMvcConfigurer(EndpointJsonMapper endpointJsonMapper) { - this.endpointJsonMapper = endpointJsonMapper; + EndpointJsonMapperWebMvcConfigurer(EndpointJsonMapper mapper) { + this.mapper = mapper; } @Override public void configureMessageConverters(ServerBuilder builder) { builder.configureMessageConverters((converter) -> { - if (converter instanceof JacksonJsonHttpMessageConverter jacksonJsonHttpMessageConverter) { - configure(jacksonJsonHttpMessageConverter); + if (converter instanceof JacksonJsonHttpMessageConverter jacksonConverter) { + configure(jacksonConverter); } }); } private void configure(JacksonJsonHttpMessageConverter converter) { converter.registerMappersForType(OperationResponseBody.class, (associations) -> { - JsonMapper jsonMapper = this.endpointJsonMapper.get(); + JsonMapper jsonMapper = this.mapper.get(); + MEDIA_TYPES.forEach((mimeType) -> associations.put(mimeType, jsonMapper)); + }); + } + + } + + /** + * {@link WebMvcConfigurer} to apply + * {@link org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper} + * for {@link OperationResponseBody} to + * {@link org.springframework.http.converter.json.MappingJackson2HttpMessageConverter} + * instances. + * + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + static class EndpointJackson2ObjectMapperWebMvcConfigurer implements WebMvcConfigurer { + + private final org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper mapper; + + EndpointJackson2ObjectMapperWebMvcConfigurer( + org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public void configureMessageConverters(ServerBuilder builder) { + builder.configureMessageConverters((converter) -> { + if (converter instanceof org.springframework.http.converter.json.MappingJackson2HttpMessageConverter jacksonConverter) { + configure(jacksonConverter); + } + }); + } + + private void configure(org.springframework.http.converter.json.MappingJackson2HttpMessageConverter converter) { + converter.registerObjectMappersForType(OperationResponseBody.class, (associations) -> { + com.fasterxml.jackson.databind.ObjectMapper jsonMapper = this.mapper.get(); MEDIA_TYPES.forEach((mimeType) -> associations.put(mimeType, jsonMapper)); }); } From 50a73b8dfe2b9b4ccb092d5b836dc7c42bfec303 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Fri, 10 Oct 2025 19:56:07 -0700 Subject: [PATCH 05/10] Add Jackson2Tester support See gh-47688 --- core/spring-boot-test/build.gradle | 3 +- .../boot/test/json/Jackson2Tester.java | 207 ++++++++++++++++++ .../boot/test/json/JacksonTester.java | 8 + .../boot/test/json/Jackson2TesterTests.java | 92 ++++++++ .../Jackson2TesterAutoConfiguration.java | 63 ++++++ ...toconfigure.json.AutoConfigureJson.imports | 1 + ...gure.json.AutoConfigureJsonTesters.imports | 1 + ....test.autoconfigure.json.JsonTest.includes | 2 + 8 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 core/spring-boot-test/src/main/java/org/springframework/boot/test/json/Jackson2Tester.java create mode 100644 core/spring-boot-test/src/test/java/org/springframework/boot/test/json/Jackson2TesterTests.java create mode 100644 module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2TesterAutoConfiguration.java create mode 100644 module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJson.imports create mode 100644 module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters.imports create mode 100644 module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.JsonTest.includes diff --git a/core/spring-boot-test/build.gradle b/core/spring-boot-test/build.gradle index 156829f7063..8720ea9e605 100644 --- a/core/spring-boot-test/build.gradle +++ b/core/spring-boot-test/build.gradle @@ -27,7 +27,7 @@ dependencies { api(project(":core:spring-boot")) api("org.springframework:spring-test") - optional("tools.jackson.core:jackson-databind") + optional("com.fasterxml.jackson.core:jackson-databind") optional("com.google.code.gson:gson") optional("com.jayway.jsonpath:json-path") optional("jakarta.json.bind:jakarta.json.bind-api") @@ -45,6 +45,7 @@ dependencies { optional("org.seleniumhq.selenium:selenium-api") optional("org.skyscreamer:jsonassert") optional("org.springframework:spring-web") + optional("tools.jackson.core:jackson-databind") testImplementation(project(":test-support:spring-boot-test-support")) testImplementation("ch.qos.logback:logback-classic") diff --git a/core/spring-boot-test/src/main/java/org/springframework/boot/test/json/Jackson2Tester.java b/core/spring-boot-test/src/main/java/org/springframework/boot/test/json/Jackson2Tester.java new file mode 100644 index 00000000000..99cf33f8072 --- /dev/null +++ b/core/spring-boot-test/src/main/java/org/springframework/boot/test/json/Jackson2Tester.java @@ -0,0 +1,207 @@ +/* + * Copyright 2012-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.boot.test.json; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.spi.json.JacksonJsonProvider; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.core.ResolvableType; +import org.springframework.util.Assert; + +/** + * AssertJ based JSON tester backed by Jackson 2. Usually instantiated via + * {@link #initFields(Object, ObjectMapper)}, for example:
+ * public class ExampleObjectJsonTests {
+ *
+ *     private Jackson2Tester<ExampleObject> json;
+ *
+ *     @Before
+ *     public void setup() {
+ *         ObjectMapper objectMapper = new ObjectMapper();
+ *         Jackson2Tester.initFields(this, objectMapper);
+ *     }
+ *
+ *     @Test
+ *     public void testWriteJson() throws IOException {
+ *         ExampleObject object = //...
+ *         assertThat(json.write(object)).isEqualToJson("expected.json");
+ *     }
+ *
+ * }
+ * 
+ * + * See {@link AbstractJsonMarshalTester} for more details. + * + * @param the type under test + * @author Phillip Webb + * @author Madhura Bhave + * @author Diego Berrueta + * @since 4.0.0 + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ +@Deprecated(since = "4.0.0", forRemoval = true) +public class Jackson2Tester extends AbstractJsonMarshalTester { + + private final ObjectMapper objectMapper; + + private @Nullable Class view; + + /** + * Create a new {@link Jackson2Tester} instance. + * @param objectMapper the Jackson object mapper + */ + protected Jackson2Tester(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "'objectMapper' must not be null"); + this.objectMapper = objectMapper; + } + + /** + * Create a new {@link Jackson2Tester} instance. + * @param resourceLoadClass the source class used to load resources + * @param type the type under test + * @param objectMapper the Jackson object mapper + */ + public Jackson2Tester(Class resourceLoadClass, ResolvableType type, ObjectMapper objectMapper) { + this(resourceLoadClass, type, objectMapper, null); + } + + /** + * Create a new {@link Jackson2Tester} instance. + * @param resourceLoadClass the source class used to load resources + * @param type the type under test + * @param objectMapper the Jackson object mapper + * @param view the JSON view + */ + public Jackson2Tester(Class resourceLoadClass, ResolvableType type, ObjectMapper objectMapper, + @Nullable Class view) { + super(resourceLoadClass, type); + Assert.notNull(objectMapper, "'objectMapper' must not be null"); + this.objectMapper = objectMapper; + this.view = view; + } + + @Override + protected JsonContent getJsonContent(String json) { + Configuration configuration = Configuration.builder() + .jsonProvider(new JacksonJsonProvider(this.objectMapper)) + .mappingProvider(new JacksonMappingProvider(this.objectMapper)) + .build(); + Class resourceLoadClass = getResourceLoadClass(); + Assert.state(resourceLoadClass != null, "'resourceLoadClass' must not be null"); + return new JsonContent<>(resourceLoadClass, getType(), json, configuration); + } + + @Override + protected T readObject(InputStream inputStream, ResolvableType type) throws IOException { + return getObjectReader(type).readValue(inputStream); + } + + @Override + protected T readObject(Reader reader, ResolvableType type) throws IOException { + return getObjectReader(type).readValue(reader); + } + + private ObjectReader getObjectReader(ResolvableType type) { + ObjectReader objectReader = this.objectMapper.readerFor(getType(type)); + if (this.view != null) { + return objectReader.withView(this.view); + } + return objectReader; + } + + @Override + protected String writeObject(T value, ResolvableType type) throws IOException { + return getObjectWriter(type).writeValueAsString(value); + } + + private ObjectWriter getObjectWriter(ResolvableType type) { + ObjectWriter objectWriter = this.objectMapper.writerFor(getType(type)); + if (this.view != null) { + return objectWriter.withView(this.view); + } + return objectWriter; + } + + private JavaType getType(ResolvableType type) { + return this.objectMapper.constructType(type.getType()); + } + + /** + * Utility method to initialize {@link Jackson2Tester} fields. See + * {@link Jackson2Tester class-level documentation} for example usage. + * @param testInstance the test instance + * @param objectMapper the JSON mapper + * @see #initFields(Object, ObjectMapper) + */ + public static void initFields(Object testInstance, ObjectMapper objectMapper) { + new Jackson2FieldInitializer().initFields(testInstance, objectMapper); + } + + /** + * Utility method to initialize {@link Jackson2Tester} fields. See + * {@link Jackson2Tester class-level documentation} for example usage. + * @param testInstance the test instance + * @param objectMapperFactory a factory to create the object mapper + * @see #initFields(Object, ObjectMapper) + */ + public static void initFields(Object testInstance, ObjectFactory objectMapperFactory) { + new Jackson2FieldInitializer().initFields(testInstance, objectMapperFactory); + } + + /** + * Returns a new instance of {@link Jackson2Tester} with the view that should be used + * for json serialization/deserialization. + * @param view the view class + * @return the new instance + */ + public Jackson2Tester forView(Class view) { + Class resourceLoadClass = getResourceLoadClass(); + ResolvableType type = getType(); + Assert.state(resourceLoadClass != null, "'resourceLoadClass' must not be null"); + Assert.state(type != null, "'type' must not be null"); + return new Jackson2Tester<>(resourceLoadClass, type, this.objectMapper, view); + } + + /** + * {@link FieldInitializer} for Jackson. + */ + private static class Jackson2FieldInitializer extends FieldInitializer { + + protected Jackson2FieldInitializer() { + super(Jackson2Tester.class); + } + + @Override + protected AbstractJsonMarshalTester createTester(Class resourceLoadClass, ResolvableType type, + ObjectMapper marshaller) { + return new Jackson2Tester<>(resourceLoadClass, type, marshaller); + } + + } + +} diff --git a/core/spring-boot-test/src/main/java/org/springframework/boot/test/json/JacksonTester.java b/core/spring-boot-test/src/main/java/org/springframework/boot/test/json/JacksonTester.java index 94f3ff93779..3870834b63b 100644 --- a/core/spring-boot-test/src/main/java/org/springframework/boot/test/json/JacksonTester.java +++ b/core/spring-boot-test/src/main/java/org/springframework/boot/test/json/JacksonTester.java @@ -103,6 +103,14 @@ public class JacksonTester extends AbstractJsonMarshalTester { this(resourceLoadClass, type, jsonMapper, null); } + /** + * Create a new {@link JacksonTester} instance. + * @param resourceLoadClass the source class used to load resources + * @param type the type under test + * @param jsonMapper the Jackson JSON mapper + * @param view the JSON view + * @since 4.0.0 + */ public JacksonTester(Class resourceLoadClass, ResolvableType type, JsonMapper jsonMapper, @Nullable Class view) { super(resourceLoadClass, type); diff --git a/core/spring-boot-test/src/test/java/org/springframework/boot/test/json/Jackson2TesterTests.java b/core/spring-boot-test/src/test/java/org/springframework/boot/test/json/Jackson2TesterTests.java new file mode 100644 index 00000000000..e04b3b926b7 --- /dev/null +++ b/core/spring-boot-test/src/test/java/org/springframework/boot/test/json/Jackson2TesterTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2012-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.boot.test.json; + +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.core.ResolvableType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link Jackson2Tester}. + * + * @author Phillip Webb + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of JacksonTesterTests + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@SuppressWarnings("removal") +class Jackson2TesterTests extends AbstractJsonMarshalTesterTests { + + @Test + @SuppressWarnings("NullAway") // Test null check + void initFieldsWhenTestIsNullShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> Jackson2Tester.initFields(null, new ObjectMapper())) + .withMessageContaining("'testInstance' must not be null"); + } + + @Test + @SuppressWarnings("NullAway") // Test null check + void initFieldsWhenMarshallerIsNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> Jackson2Tester.initFields(new InitFieldsTestClass(), (ObjectMapper) null)) + .withMessageContaining("'marshaller' must not be null"); + } + + @Test + void initFieldsShouldSetNullFields() { + InitFieldsTestClass test = new InitFieldsTestClass(); + assertThat(test.test).isNull(); + assertThat(test.base).isNull(); + Jackson2Tester.initFields(test, new ObjectMapper()); + assertThat(test.test).isNotNull(); + assertThat(test.base).isNotNull(); + ResolvableType type = test.test.getType(); + assertThat(type).isNotNull(); + assertThat(type.resolve()).isEqualTo(List.class); + assertThat(type.resolveGeneric()).isEqualTo(ExampleObject.class); + } + + @Override + protected AbstractJsonMarshalTester createTester(Class resourceLoadClass, ResolvableType type) { + return new org.springframework.boot.test.json.Jackson2Tester<>(resourceLoadClass, type, new ObjectMapper()); + } + + abstract static class InitFieldsBaseClass { + + public org.springframework.boot.test.json.@Nullable Jackson2Tester base; + + public org.springframework.boot.test.json.Jackson2Tester baseSet = new org.springframework.boot.test.json.Jackson2Tester<>( + InitFieldsBaseClass.class, ResolvableType.forClass(ExampleObject.class), new ObjectMapper()); + + } + + static class InitFieldsTestClass extends InitFieldsBaseClass { + + public org.springframework.boot.test.json.@Nullable Jackson2Tester> test; + + public org.springframework.boot.test.json.Jackson2Tester testSet = new org.springframework.boot.test.json.Jackson2Tester<>( + InitFieldsBaseClass.class, ResolvableType.forClass(ExampleObject.class), new ObjectMapper()); + + } + +} diff --git a/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2TesterAutoConfiguration.java b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2TesterAutoConfiguration.java new file mode 100644 index 00000000000..09a5a100739 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/java/org/springframework/boot/jackson2/autoconfigure/Jackson2TesterAutoConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-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.boot.jackson2.autoconfigure; + +import com.fasterxml.jackson.databind.json.JsonMapper; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.test.autoconfigure.TestAutoConfiguration; +import org.springframework.boot.test.autoconfigure.json.ConditionalOnJsonTesters; +import org.springframework.boot.test.autoconfigure.json.JsonMarshalTesterRuntimeHints; +import org.springframework.boot.test.autoconfigure.json.JsonTesterFactoryBean; +import org.springframework.boot.test.json.GsonTester; +import org.springframework.boot.test.json.JacksonTester; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ImportRuntimeHints; +import org.springframework.context.annotation.Scope; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for {@link GsonTester}. + * + * @author Phjllip Webb + * @deprecated since 4.0.0 for removal in 4.2.0 in favor of Jackson 3. + */ +@Deprecated(since = "4.0.0", forRemoval = true) +@TestAutoConfiguration(after = Jackson2AutoConfiguration.class) +@ConditionalOnJsonTesters +@SuppressWarnings("removal") +final class Jackson2TesterAutoConfiguration { + + @Bean + @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) + @ConditionalOnBean(JsonMapper.class) + @ImportRuntimeHints(Jackson2TesterRuntimeHints.class) + FactoryBean> jacksonTesterFactoryBean(JsonMapper mapper) { + return new JsonTesterFactoryBean<>(JacksonTester.class, mapper); + } + + static class Jackson2TesterRuntimeHints extends JsonMarshalTesterRuntimeHints { + + Jackson2TesterRuntimeHints() { + super(JacksonTester.class); + } + + } + +} diff --git a/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJson.imports b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJson.imports new file mode 100644 index 00000000000..83028f13a4c --- /dev/null +++ b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJson.imports @@ -0,0 +1 @@ +org.springframework.boot.jackson.autoconfigure.Jackson2AutoConfiguration diff --git a/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters.imports b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters.imports new file mode 100644 index 00000000000..a470a949079 --- /dev/null +++ b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters.imports @@ -0,0 +1 @@ +org.springframework.boot.jackson.autoconfigure.Jackson2TesterAutoConfiguration diff --git a/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.JsonTest.includes b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.JsonTest.includes new file mode 100644 index 00000000000..707c8d545cd --- /dev/null +++ b/module/spring-boot-jackson2/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.json.JsonTest.includes @@ -0,0 +1,2 @@ +org.springframework.boot.jackson2.JsonComponent +com.fasterxml.jackson.databind.Module \ No newline at end of file From 2264b77c6f75718e3d4e882dce66470d68099fc7 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Mon, 13 Oct 2025 11:34:32 +0100 Subject: [PATCH 06/10] Consider Jackson 2 in GraphQL RSocket auto-configuration See gh-47688 --- module/spring-boot-graphql/build.gradle | 3 +- .../GraphQlRSocketAutoConfiguration.java | 65 ++++++++++++++++++- ...itional-spring-configuration-metadata.json | 22 +++++++ .../GraphQlRSocketAutoConfigurationTests.java | 31 +++++++++ 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/module/spring-boot-graphql/build.gradle b/module/spring-boot-graphql/build.gradle index 2034744222b..c48e36d96d9 100644 --- a/module/spring-boot-graphql/build.gradle +++ b/module/spring-boot-graphql/build.gradle @@ -28,10 +28,11 @@ dependencies { api(project(":core:spring-boot")) api("org.springframework.graphql:spring-graphql") - implementation(project(":module:spring-boot-jackson")) optional(project(":core:spring-boot-autoconfigure")) optional(project(":module:spring-boot-http-converter")) + optional(project(":module:spring-boot-jackson")) + optional(project(":module:spring-boot-jackson2")) optional(project(":module:spring-boot-micrometer-observation")) optional(project(":module:spring-boot-rsocket")) optional(project(":module:spring-boot-security")) diff --git a/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/rsocket/GraphQlRSocketAutoConfiguration.java b/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/rsocket/GraphQlRSocketAutoConfiguration.java index 2e10fee9938..ce242924ab1 100644 --- a/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/rsocket/GraphQlRSocketAutoConfiguration.java +++ b/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/rsocket/GraphQlRSocketAutoConfiguration.java @@ -16,6 +16,7 @@ package org.springframework.boot.graphql.autoconfigure.rsocket; +import com.fasterxml.jackson.databind.ObjectMapper; import graphql.GraphQL; import io.rsocket.core.RSocketServer; import reactor.netty.http.server.HttpServer; @@ -24,12 +25,17 @@ import tools.jackson.databind.json.JsonMapper; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.codec.Encoder; import org.springframework.graphql.ExecutionGraphQlService; import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer; import org.springframework.graphql.execution.GraphQlSource; @@ -46,7 +52,9 @@ import org.springframework.messaging.rsocket.annotation.support.RSocketMessageHa * @since 4.0.0 */ @AutoConfiguration(after = GraphQlAutoConfiguration.class, - afterName = "org.springframework.boot.rsocket.autoconfigure.RSocketMessagingAutoConfiguration") + afterName = { "org.springframework.boot.rsocket.autoconfigure.RSocketMessagingAutoConfiguration", + "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration", + "org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration" }) @ConditionalOnClass({ GraphQL.class, GraphQlSource.class, RSocketServer.class, HttpServer.class }) @ConditionalOnBean({ RSocketMessageHandler.class, AnnotatedControllerConfigurer.class }) @ConditionalOnProperty("spring.graphql.rsocket.mapping") @@ -55,9 +63,9 @@ public final class GraphQlRSocketAutoConfiguration { @Bean @ConditionalOnMissingBean GraphQlRSocketHandler graphQlRSocketHandler(ExecutionGraphQlService graphQlService, - ObjectProvider interceptors, JsonMapper jsonMapper) { + ObjectProvider interceptors, JsonEncoderSupplier jsonEncoderSupplier) { return new GraphQlRSocketHandler(graphQlService, interceptors.orderedStream().toList(), - new JacksonJsonEncoder(jsonMapper)); + jsonEncoderSupplier.jsonEncoder()); } @Bean @@ -66,4 +74,55 @@ public final class GraphQlRSocketAutoConfiguration { return new GraphQlRSocketController(handler); } + interface JsonEncoderSupplier { + + Encoder jsonEncoder(); + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(JsonMapper.class) + @ConditionalOnProperty(name = "spring.graphql.rsocket.preferred-json-mapper", havingValue = "jackson", + matchIfMissing = true) + static class JacksonJsonEncoderSupplierConfiguration { + + @Bean + JsonEncoderSupplier jacksonJsonEncoderSupplier(JsonMapper jsonMapper) { + return () -> new JacksonJsonEncoder(jsonMapper); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(ObjectMapper.class) + @Conditional(NoJacksonOrJackson2Preferred.class) + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + static class Jackson2JsonEncoderSupplierConfiguration { + + @Bean + JsonEncoderSupplier jackson2JsonEncoderSupplier(ObjectMapper objectMapper) { + return () -> new org.springframework.http.codec.json.Jackson2JsonEncoder(objectMapper); + } + + } + + static class NoJacksonOrJackson2Preferred extends AnyNestedCondition { + + NoJacksonOrJackson2Preferred() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnMissingClass("tools.jackson.databind.json.JsonMapper") + static class NoJackson { + + } + + @ConditionalOnProperty(name = "spring.graphql.rsocket.preferred-json-mapper", havingValue = "jackson2") + static class Jackson2Preferred { + + } + + } + } diff --git a/module/spring-boot-graphql/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-graphql/src/main/resources/META-INF/additional-spring-configuration-metadata.json index ac88283fccf..78f7cf91540 100644 --- a/module/spring-boot-graphql/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/module/spring-boot-graphql/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -37,6 +37,12 @@ "since": "3.5.0" } }, + { + "name": "spring.graphql.rsocket.preferred-json-mapper", + "type": "java.lang.String", + "defaultValue": "jackson", + "description": "Preferred JSON mapper to use. By default, auto-detected according to the environment. Supported values are 'jackson' and 'jackson2' (deprecated)." + }, { "name": "spring.graphql.schema.file-extensions", "defaultValue": ".graphqls,.gqls" @@ -95,6 +101,22 @@ "name": "any" } ] + }, + { + "name": "spring.graphql.rsocket.preferred-json-mapper", + "values": [ + { + "value": "jackson" + }, + { + "value": "jackson2" + } + ], + "providers": [ + { + "name": "any" + } + ] } ] } diff --git a/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/rsocket/GraphQlRSocketAutoConfigurationTests.java b/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/rsocket/GraphQlRSocketAutoConfigurationTests.java index 9c1d18740b1..a2fafebc802 100644 --- a/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/rsocket/GraphQlRSocketAutoConfigurationTests.java +++ b/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/rsocket/GraphQlRSocketAutoConfigurationTests.java @@ -22,18 +22,22 @@ import java.util.function.Consumer; import graphql.schema.idl.TypeRuntimeWiring; import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; import org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration; import org.springframework.boot.graphql.autoconfigure.GraphQlTestDataFetchers; import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; +import org.springframework.boot.logging.LogLevel; import org.springframework.boot.reactor.netty.NettyReactiveWebServerFactory; import org.springframework.boot.reactor.netty.NettyRouteProvider; import org.springframework.boot.rsocket.autoconfigure.RSocketMessagingAutoConfiguration; import org.springframework.boot.rsocket.autoconfigure.RSocketServerAutoConfiguration; import org.springframework.boot.rsocket.autoconfigure.RSocketStrategiesAutoConfiguration; import org.springframework.boot.rsocket.context.RSocketPortInfoApplicationContextInitializer; +import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.boot.testsupport.classpath.resources.WithResource; import org.springframework.boot.web.server.context.ServerPortInfoApplicationContextInitializer; @@ -99,6 +103,33 @@ class GraphQlRSocketAutoConfigurationTests { testWithRSocketWebSocket(this::assertThatSimpleQueryWorks); } + @Test + void usesJacksonByDefault() { + this.contextRunner.run((context) -> assertThat(context).hasBean("jacksonJsonEncoderSupplier")); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + void usesJackson2WhenItIsPreferred() { + this.contextRunner.withPropertyValues("spring.graphql.rsocket.preferred-json-mapper=jackson2") + .withConfiguration(AutoConfigurations + .of(org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class)) + .run((context) -> assertThat(context).hasBean("jackson2JsonEncoderSupplier")); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + void usesJackson2WhenJacksonIsAbsent() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(Thread.currentThread().getContextClassLoader(), JsonMapper.class)) + .withConfiguration(AutoConfigurations + .of(org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class)) + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .run((context) -> assertThat(context).hasBean("jackson2JsonEncoderSupplier")); + } + private void assertThatSimpleQueryWorks(RSocketGraphQlClient client) { String document = "{ bookById(id: \"book-1\"){ id name pageCount author } }"; String bookName = client.document(document) From dee32e0914701258b11ea9324d5db1e1f7c1193b Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Mon, 13 Oct 2025 11:57:38 +0100 Subject: [PATCH 07/10] Consider Jackson 2 in RSocket auto-configuration See gh-47688 --- module/spring-boot-rsocket/build.gradle | 2 + .../RSocketStrategiesAutoConfiguration.java | 131 ++++++++++++++---- ...itional-spring-configuration-metadata.json | 26 +++- ...ocketStrategiesAutoConfigurationTests.java | 39 ++++++ 4 files changed, 173 insertions(+), 25 deletions(-) diff --git a/module/spring-boot-rsocket/build.gradle b/module/spring-boot-rsocket/build.gradle index c446b1e8b02..c2bf28560ee 100644 --- a/module/spring-boot-rsocket/build.gradle +++ b/module/spring-boot-rsocket/build.gradle @@ -33,10 +33,12 @@ dependencies { optional(project(":core:spring-boot-autoconfigure")) optional(project(":module:spring-boot-jackson")) + optional(project(":module:spring-boot-jackson2")) optional(project(":module:spring-boot-reactor-netty")) optional("io.rsocket:rsocket-transport-netty") optional("org.springframework:spring-web") optional("tools.jackson.dataformat:jackson-dataformat-cbor") + optional("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor") testImplementation(project(":core:spring-boot-test")) testImplementation(project(":test-support:spring-boot-test-support")) diff --git a/module/spring-boot-rsocket/src/main/java/org/springframework/boot/rsocket/autoconfigure/RSocketStrategiesAutoConfiguration.java b/module/spring-boot-rsocket/src/main/java/org/springframework/boot/rsocket/autoconfigure/RSocketStrategiesAutoConfiguration.java index b672ff90705..a04bf899829 100644 --- a/module/spring-boot-rsocket/src/main/java/org/springframework/boot/rsocket/autoconfigure/RSocketStrategiesAutoConfiguration.java +++ b/module/spring-boot-rsocket/src/main/java/org/springframework/boot/rsocket/autoconfigure/RSocketStrategiesAutoConfiguration.java @@ -16,6 +16,8 @@ package org.springframework.boot.rsocket.autoconfigure; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; import io.netty.buffer.PooledByteBufAllocator; import tools.jackson.databind.json.JsonMapper; import tools.jackson.dataformat.cbor.CBORMapper; @@ -23,11 +25,15 @@ import tools.jackson.dataformat.cbor.CBORMapper; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.rsocket.messaging.RSocketStrategiesCustomizer; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; @@ -45,7 +51,8 @@ import org.springframework.web.util.pattern.PathPatternRouteMatcher; * @author Brian Clozel * @since 4.0.0 */ -@AutoConfiguration(afterName = "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration") +@AutoConfiguration(afterName = { "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration", + "org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration" }) @ConditionalOnClass({ io.rsocket.RSocket.class, RSocketStrategies.class, PooledByteBufAllocator.class }) public final class RSocketStrategiesAutoConfiguration { @@ -63,38 +70,114 @@ public final class RSocketStrategiesAutoConfiguration { } @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(CBORMapper.class) - @ConditionalOnBean(CBORMapper.class) - protected static class JacksonCborStrategyConfiguration { + @ConditionalOnProperty(name = "spring.rsocket.preferred-mapper", havingValue = "jackson", matchIfMissing = true) + static class JacksonStrategyConfiguration { - private static final MediaType[] SUPPORTED_TYPES = { MediaType.APPLICATION_CBOR }; + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(CBORMapper.class) + @ConditionalOnBean(CBORMapper.class) + static class JacksonCborStrategyConfiguration { + + private static final MediaType[] SUPPORTED_TYPES = { MediaType.APPLICATION_CBOR }; + + @Bean + @Order(0) + RSocketStrategiesCustomizer jacksonCborRSocketStrategyCustomizer(CBORMapper cborMapper) { + return (strategy) -> { + strategy.decoder(new JacksonCborDecoder(cborMapper, SUPPORTED_TYPES)); + strategy.encoder(new JacksonCborEncoder(cborMapper, SUPPORTED_TYPES)); + }; + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(JsonMapper.class) + static class JacksonJsonStrategyConfiguration { + + private static final MediaType[] SUPPORTED_TYPES = { MediaType.APPLICATION_JSON, + new MediaType("application", "*+json") }; + + @Bean + @Order(1) + @ConditionalOnBean(JsonMapper.class) + RSocketStrategiesCustomizer jacksonJsonRSocketStrategyCustomizer(JsonMapper jsonMapper) { + return (strategy) -> { + strategy.decoder(new JacksonJsonDecoder(jsonMapper, SUPPORTED_TYPES)); + strategy.encoder(new JacksonJsonEncoder(jsonMapper, SUPPORTED_TYPES)); + }; + } - @Bean - @Order(0) - RSocketStrategiesCustomizer jacksonCborRSocketStrategyCustomizer(CBORMapper cborMapper) { - return (strategy) -> { - strategy.decoder(new JacksonCborDecoder(cborMapper, SUPPORTED_TYPES)); - strategy.encoder(new JacksonCborEncoder(cborMapper, SUPPORTED_TYPES)); - }; } } @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(JsonMapper.class) - @ConditionalOnBean(JsonMapper.class) - protected static class JacksonJsonStrategyConfiguration { + @Conditional(NoJacksonOrJackson2Preferred.class) + @SuppressWarnings("removal") + @Deprecated(since = "4.0.0", forRemoval = true) + static class Jackson2StrategyConfiguration { - private static final MediaType[] SUPPORTED_TYPES = { MediaType.APPLICATION_JSON, - new MediaType("application", "*+json") }; + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ ObjectMapper.class, CBORFactory.class }) + static class Jackson2CborStrategyConfiguration { + + private static final MediaType[] SUPPORTED_TYPES = { MediaType.APPLICATION_CBOR }; + + @Bean + @Order(0) + @ConditionalOnBean(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.class) + RSocketStrategiesCustomizer jackson2CborRSocketStrategyCustomizer( + org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) { + return (strategy) -> { + ObjectMapper objectMapper = builder.createXmlMapper(false) + .factory(new com.fasterxml.jackson.dataformat.cbor.CBORFactory()) + .build(); + strategy.decoder( + new org.springframework.http.codec.cbor.Jackson2CborDecoder(objectMapper, SUPPORTED_TYPES)); + strategy.encoder( + new org.springframework.http.codec.cbor.Jackson2CborEncoder(objectMapper, SUPPORTED_TYPES)); + }; + } + + } + + @ConditionalOnClass(ObjectMapper.class) + static class Jackson2JsonStrategyConfiguration { + + private static final MediaType[] SUPPORTED_TYPES = { MediaType.APPLICATION_JSON, + new MediaType("application", "*+json") }; + + @Bean + @Order(1) + @ConditionalOnBean(ObjectMapper.class) + RSocketStrategiesCustomizer jackson2JsonRSocketStrategyCustomizer(ObjectMapper objectMapper) { + return (strategy) -> { + strategy.decoder( + new org.springframework.http.codec.json.Jackson2JsonDecoder(objectMapper, SUPPORTED_TYPES)); + strategy.encoder( + new org.springframework.http.codec.json.Jackson2JsonEncoder(objectMapper, SUPPORTED_TYPES)); + }; + } + + } + + } + + static class NoJacksonOrJackson2Preferred extends AnyNestedCondition { + + NoJacksonOrJackson2Preferred() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnMissingClass("tools.jackson.databind.json.JsonMapper") + static class NoJackson { + + } + + @ConditionalOnProperty(name = "spring.rsocket.preferred-mapper", havingValue = "jackson2") + static class Jackson2Preferred { - @Bean - @Order(1) - RSocketStrategiesCustomizer jacksonJsonRSocketStrategyCustomizer(JsonMapper jsonMapper) { - return (strategy) -> { - strategy.decoder(new JacksonJsonDecoder(jsonMapper, SUPPORTED_TYPES)); - strategy.encoder(new JacksonJsonEncoder(jsonMapper, SUPPORTED_TYPES)); - }; } } diff --git a/module/spring-boot-rsocket/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-rsocket/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 64913148487..d43649e554c 100644 --- a/module/spring-boot-rsocket/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/module/spring-boot-rsocket/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -1,4 +1,28 @@ { "properties": [ + { + "name": "spring.rsocket.preferred-mapper", + "type": "java.lang.String", + "defaultValue": "jackson", + "description": "Preferred JSON and CBOR mapper to use. By default, auto-detected according to the environment. Supported values are 'jackson' and 'jackson2' (deprecated)." + } + ], + "hints": [ + { + "name": "spring.rsocket.preferred-mapper", + "values": [ + { + "value": "jackson" + }, + { + "value": "jackson2" + } + ], + "providers": [ + { + "name": "any" + } + ] + } ] -} \ No newline at end of file +} diff --git a/module/spring-boot-rsocket/src/test/java/org/springframework/boot/rsocket/autoconfigure/RSocketStrategiesAutoConfigurationTests.java b/module/spring-boot-rsocket/src/test/java/org/springframework/boot/rsocket/autoconfigure/RSocketStrategiesAutoConfigurationTests.java index f45793953e1..4c8b6231846 100644 --- a/module/spring-boot-rsocket/src/test/java/org/springframework/boot/rsocket/autoconfigure/RSocketStrategiesAutoConfigurationTests.java +++ b/module/spring-boot-rsocket/src/test/java/org/springframework/boot/rsocket/autoconfigure/RSocketStrategiesAutoConfigurationTests.java @@ -22,6 +22,7 @@ import tools.jackson.dataformat.cbor.CBORMapper; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.rsocket.messaging.RSocketStrategiesCustomizer; +import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -82,6 +83,44 @@ class RSocketStrategiesAutoConfigurationTests { }); } + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + void shouldUseJackson2WhenPreferred() { + this.contextRunner + .withConfiguration(AutoConfigurations + .of(org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class)) + .withPropertyValues("spring.rsocket.preferred-mapper=jackson2") + .run((context) -> { + RSocketStrategies strategies = context.getBean(RSocketStrategies.class); + assertThat(strategies.decoders()) + .hasAtLeastOneElementOfType(org.springframework.http.codec.cbor.Jackson2CborDecoder.class) + .hasAtLeastOneElementOfType(org.springframework.http.codec.json.Jackson2JsonDecoder.class); + assertThat(strategies.encoders()) + .hasAtLeastOneElementOfType(org.springframework.http.codec.cbor.Jackson2CborEncoder.class) + .hasAtLeastOneElementOfType(org.springframework.http.codec.json.Jackson2JsonEncoder.class); + }); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + void shouldUseJackson2WhenJacksonIsAbsent() { + this.contextRunner + .withConfiguration(AutoConfigurations + .of(org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(JsonMapper.class, CBORMapper.class)) + .run((context) -> { + RSocketStrategies strategies = context.getBean(RSocketStrategies.class); + assertThat(strategies.decoders()) + .hasAtLeastOneElementOfType(org.springframework.http.codec.cbor.Jackson2CborDecoder.class) + .hasAtLeastOneElementOfType(org.springframework.http.codec.json.Jackson2JsonDecoder.class); + assertThat(strategies.encoders()) + .hasAtLeastOneElementOfType(org.springframework.http.codec.cbor.Jackson2CborEncoder.class) + .hasAtLeastOneElementOfType(org.springframework.http.codec.json.Jackson2JsonEncoder.class); + }); + } + @Configuration(proxyBeanMethods = false) static class UserStrategies { From 00006a6924ca48190f6efa7cc493ec19aa983cac Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Mon, 13 Oct 2025 16:00:08 +0100 Subject: [PATCH 08/10] Consider Jackson 2 in WebSocket messaging auto-configuration See gh-47688 --- module/spring-boot-websocket/build.gradle | 5 ++ .../WebSocketMessagingAutoConfiguration.java | 87 +++++++++++++++++-- ...itional-spring-configuration-metadata.json | 28 ++++++ ...SocketMessagingAutoConfigurationTests.java | 52 +++++++++-- 4 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 module/spring-boot-websocket/src/main/resources/META-INF/additional-spring-configuration-metadata.json diff --git a/module/spring-boot-websocket/build.gradle b/module/spring-boot-websocket/build.gradle index 0f9b8900230..6c6673143b4 100644 --- a/module/spring-boot-websocket/build.gradle +++ b/module/spring-boot-websocket/build.gradle @@ -30,6 +30,7 @@ dependencies { optional(project(":core:spring-boot-autoconfigure")) optional(project(":module:spring-boot-jackson")) + optional(project(":module:spring-boot-jackson2")) testImplementation(project(":core:spring-boot-test")) testImplementation(project(":module:spring-boot-tomcat")) @@ -43,3 +44,7 @@ dependencies { tasks.named("compileTestJava") { options.nullability.checking = "tests" } + +tasks.named("test") { + jvmArgs += "--add-opens=java.base/java.net=ALL-UNNAMED" +} diff --git a/module/spring-boot-websocket/src/main/java/org/springframework/boot/websocket/autoconfigure/servlet/WebSocketMessagingAutoConfiguration.java b/module/spring-boot-websocket/src/main/java/org/springframework/boot/websocket/autoconfigure/servlet/WebSocketMessagingAutoConfiguration.java index ee669500304..c273d4dec71 100644 --- a/module/spring-boot-websocket/src/main/java/org/springframework/boot/websocket/autoconfigure/servlet/WebSocketMessagingAutoConfiguration.java +++ b/module/spring-boot-websocket/src/main/java/org/springframework/boot/websocket/autoconfigure/servlet/WebSocketMessagingAutoConfiguration.java @@ -19,18 +19,23 @@ package org.springframework.boot.websocket.autoconfigure.servlet; import java.util.List; import java.util.Map; +import com.fasterxml.jackson.databind.ObjectMapper; import org.jspecify.annotations.Nullable; import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.LazyInitializationExcludeFilter; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.core.task.AsyncTaskExecutor; @@ -53,7 +58,8 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo * @author Moritz Halbritter * @since 4.0.0 */ -@AutoConfiguration(afterName = "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration") +@AutoConfiguration(afterName = { "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration", + "org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration" }) @ConditionalOnWebApplication(type = Type.SERVLET) @ConditionalOnClass(WebSocketMessageBrokerConfigurer.class) public final class WebSocketMessagingAutoConfiguration { @@ -62,13 +68,14 @@ public final class WebSocketMessagingAutoConfiguration { @ConditionalOnBean({ DelegatingWebSocketMessageBrokerConfiguration.class, JsonMapper.class }) @ConditionalOnClass({ JsonMapper.class, AbstractMessageBrokerConfiguration.class }) @Order(0) - static class WebSocketMessageConverterConfiguration implements WebSocketMessageBrokerConfigurer { + static class SpringBootWebSocketMessageBrokerConfiguration implements WebSocketMessageBrokerConfigurer { private final JsonMapper jsonMapper; private final @Nullable AsyncTaskExecutor executor; - WebSocketMessageConverterConfiguration(JsonMapper jsonMapper, Map taskExecutors) { + SpringBootWebSocketMessageBrokerConfiguration(JsonMapper jsonMapper, + Map taskExecutors) { this.jsonMapper = jsonMapper; this.executor = determineAsyncTaskExecutor(taskExecutors); } @@ -83,12 +90,12 @@ public final class WebSocketMessagingAutoConfiguration { @Override public boolean configureMessageConverters(List messageConverters) { + messageConverters.add(new StringMessageConverter()); + messageConverters.add(new ByteArrayMessageConverter()); JacksonJsonMessageConverter converter = new JacksonJsonMessageConverter(this.jsonMapper); DefaultContentTypeResolver resolver = new DefaultContentTypeResolver(); resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON); converter.setContentTypeResolver(resolver); - messageConverters.add(new StringMessageConverter()); - messageConverters.add(new ByteArrayMessageConverter()); messageConverters.add(converter); return false; } @@ -114,4 +121,74 @@ public final class WebSocketMessagingAutoConfiguration { } + @Order(1) + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(name = "spring.websocket.messaging.preferred-json-mapper", havingValue = "jackson", + matchIfMissing = true) + @ConditionalOnClass(JsonMapper.class) + static class JacksonWebSocketMessageConverterConfiguration implements WebSocketMessageBrokerConfigurer { + + private final JsonMapper jsonMapper; + + JacksonWebSocketMessageConverterConfiguration(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + } + + @Override + public boolean configureMessageConverters(List messageConverters) { + JacksonJsonMessageConverter converter = new JacksonJsonMessageConverter(this.jsonMapper); + DefaultContentTypeResolver resolver = new DefaultContentTypeResolver(); + resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON); + converter.setContentTypeResolver(resolver); + messageConverters.add(converter); + return false; + } + + } + + @Order(1) + @Configuration(proxyBeanMethods = false) + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + @Conditional(NoJacksonOrJackson2Preferred.class) + @ConditionalOnClass(ObjectMapper.class) + static class Jackson2WebSocketMessageConverterConfiguration implements WebSocketMessageBrokerConfigurer { + + private final ObjectMapper objectMapper; + + Jackson2WebSocketMessageConverterConfiguration(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public boolean configureMessageConverters(List messageConverters) { + org.springframework.messaging.converter.MappingJackson2MessageConverter converter = new org.springframework.messaging.converter.MappingJackson2MessageConverter( + this.objectMapper); + DefaultContentTypeResolver resolver = new DefaultContentTypeResolver(); + resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON); + converter.setContentTypeResolver(resolver); + messageConverters.add(converter); + return false; + } + + } + + static class NoJacksonOrJackson2Preferred extends AnyNestedCondition { + + NoJacksonOrJackson2Preferred() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnMissingClass("tools.jackson.databind.json.JsonMapper") + static class NoJackson { + + } + + @ConditionalOnProperty(name = "spring.websocket.messaging.preferred-json-mapper", havingValue = "jackson2") + static class Jackson2Preferred { + + } + + } + } diff --git a/module/spring-boot-websocket/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-websocket/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..61f51e0dc9f --- /dev/null +++ b/module/spring-boot-websocket/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,28 @@ +{ + "properties": [ + { + "name": "spring.websocket.messaging.preferred-json-mapper", + "type": "java.lang.String", + "defaultValue": "jackson", + "description": "Preferred JSON mapper to use for WebSocket messaging. By default, auto-detected according to the environment. Supported values are 'jackson' and 'jackson2' (deprecated)." + } + ], + "hints": [ + { + "name": "spring.websocket.messaging.preferred-json-mapper", + "values": [ + { + "value": "jackson" + }, + { + "value": "jackson2" + } + ], + "providers": [ + { + "name": "any" + } + ] + } + ] +} diff --git a/module/spring-boot-websocket/src/test/java/org/springframework/boot/websocket/autoconfigure/servlet/WebSocketMessagingAutoConfigurationTests.java b/module/spring-boot-websocket/src/test/java/org/springframework/boot/websocket/autoconfigure/servlet/WebSocketMessagingAutoConfigurationTests.java index 60672fa05f7..d2b964e9fa5 100644 --- a/module/spring-boot-websocket/src/test/java/org/springframework/boot/websocket/autoconfigure/servlet/WebSocketMessagingAutoConfigurationTests.java +++ b/module/spring-boot-websocket/src/test/java/org/springframework/boot/websocket/autoconfigure/servlet/WebSocketMessagingAutoConfigurationTests.java @@ -27,6 +27,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.tomcat.websocket.WsWebSocketContainer; import org.assertj.core.api.InstanceOfAssertFactories; import org.jspecify.annotations.Nullable; @@ -41,14 +42,18 @@ import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; +import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; import org.springframework.boot.tomcat.autoconfigure.WebSocketTomcatWebServerFactoryCustomizer; import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration; import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.servlet.context.AnnotationConfigServletWebServerApplicationContext; import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration; -import org.springframework.boot.websocket.autoconfigure.servlet.WebSocketMessagingAutoConfiguration.WebSocketMessageConverterConfiguration; +import org.springframework.boot.websocket.autoconfigure.servlet.WebSocketMessagingAutoConfiguration.JacksonWebSocketMessageConverterConfiguration; +import org.springframework.boot.websocket.autoconfigure.servlet.WebSocketMessagingAutoConfiguration.SpringBootWebSocketMessageBrokerConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; @@ -91,6 +96,7 @@ import static org.assertj.core.api.Assertions.fail; * @author Andy Wilkinson * @author Lasse Wulff */ +@DirtiesUrlFactories class WebSocketMessagingAutoConfigurationTests { private final AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext(); @@ -148,7 +154,7 @@ class WebSocketMessagingAutoConfigurationTests { void predefinedThreadExecutorIsSelectedForInboundChannel() { AsyncTaskExecutor expectedExecutor = new SimpleAsyncTaskExecutor(); ChannelRegistration registration = new ChannelRegistration(); - WebSocketMessageConverterConfiguration configuration = new WebSocketMessagingAutoConfiguration.WebSocketMessageConverterConfiguration( + SpringBootWebSocketMessageBrokerConfiguration configuration = new WebSocketMessagingAutoConfiguration.SpringBootWebSocketMessageBrokerConfiguration( new JsonMapper(), Map.of(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, expectedExecutor)); configuration.configureClientInboundChannel(registration); @@ -159,7 +165,7 @@ class WebSocketMessagingAutoConfigurationTests { void predefinedThreadExecutorIsSelectedForOutboundChannel() { AsyncTaskExecutor expectedExecutor = new SimpleAsyncTaskExecutor(); ChannelRegistration registration = new ChannelRegistration(); - WebSocketMessagingAutoConfiguration.WebSocketMessageConverterConfiguration configuration = new WebSocketMessagingAutoConfiguration.WebSocketMessageConverterConfiguration( + WebSocketMessagingAutoConfiguration.SpringBootWebSocketMessageBrokerConfiguration configuration = new WebSocketMessagingAutoConfiguration.SpringBootWebSocketMessageBrokerConfiguration( new JsonMapper(), Map.of(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, expectedExecutor)); configuration.configureClientOutboundChannel(registration); @@ -176,19 +182,51 @@ class WebSocketMessagingAutoConfigurationTests { .getBean(DelegatingWebSocketMessageBrokerConfiguration.class); CustomHighWebSocketMessageBrokerConfigurer high = this.context .getBean(CustomHighWebSocketMessageBrokerConfigurer.class); - WebSocketMessageConverterConfiguration autoConfiguration = this.context - .getBean(WebSocketMessagingAutoConfiguration.WebSocketMessageConverterConfiguration.class); + SpringBootWebSocketMessageBrokerConfiguration commonAutoConfiguration = this.context + .getBean(WebSocketMessagingAutoConfiguration.SpringBootWebSocketMessageBrokerConfiguration.class); + JacksonWebSocketMessageConverterConfiguration jsonMessageConverterAutoConfiguration = this.context + .getBean(WebSocketMessagingAutoConfiguration.JacksonWebSocketMessageConverterConfiguration.class); WebSocketMessagingConfiguration configuration = this.context.getBean(WebSocketMessagingConfiguration.class); CustomLowWebSocketMessageBrokerConfigurer low = this.context .getBean(CustomLowWebSocketMessageBrokerConfigurer.class); assertThat(delegatingConfiguration).extracting("configurers") .asInstanceOf(InstanceOfAssertFactories.LIST) - .containsExactly(high, autoConfiguration, configuration, low); + .containsExactly(high, commonAutoConfiguration, jsonMessageConverterAutoConfiguration, configuration, low); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + void shouldUseJackson2WhenPreferred() { + TestPropertyValues.of("server.port:0", "spring.websocket.messaging.preferred-json-mapper=jackson2") + .applyTo(this.context); + this.context.register(WebSocketMessagingConfiguration.class, CustomLowWebSocketMessageBrokerConfigurer.class, + CustomHighWebSocketMessageBrokerConfigurer.class); + this.context.registerBean(ObjectMapper.class); + this.context.refresh(); + assertThat(AssertableApplicationContext.get(() -> this.context)).hasSingleBean( + org.springframework.boot.websocket.autoconfigure.servlet.WebSocketMessagingAutoConfiguration.Jackson2WebSocketMessageConverterConfiguration.class) + .doesNotHaveBean(JacksonWebSocketMessageConverterConfiguration.class); + } + + @Test + @Deprecated(since = "4.0.0", forRemoval = true) + @SuppressWarnings("removal") + @ClassPathExclusions("jackson-*-3*") + void shouldUseJackson2WhenJacksonIsMissing() { + TestPropertyValues.of("server.port:0").applyTo(this.context); + this.context.register(WebSocketMessagingConfiguration.class, CustomLowWebSocketMessageBrokerConfigurer.class, + CustomHighWebSocketMessageBrokerConfigurer.class); + this.context.registerBean(ObjectMapper.class); + this.context.refresh(); + assertThat(AssertableApplicationContext.get(() -> this.context)).hasSingleBean( + org.springframework.boot.websocket.autoconfigure.servlet.WebSocketMessagingAutoConfiguration.Jackson2WebSocketMessageConverterConfiguration.class) + .doesNotHaveBean(JacksonWebSocketMessageConverterConfiguration.class); } private List getCustomizedConverters() { List customizedConverters = new ArrayList<>(); - WebSocketMessagingAutoConfiguration.WebSocketMessageConverterConfiguration configuration = new WebSocketMessagingAutoConfiguration.WebSocketMessageConverterConfiguration( + WebSocketMessagingAutoConfiguration.SpringBootWebSocketMessageBrokerConfiguration configuration = new WebSocketMessagingAutoConfiguration.SpringBootWebSocketMessageBrokerConfiguration( new JsonMapper(), Collections.emptyMap()); configuration.configureMessageConverters(customizedConverters); return customizedConverters; From ca37f4e972d1e4acebfe9295208df57983c924e9 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 14 Oct 2025 09:47:47 +0100 Subject: [PATCH 09/10] Document Jackson 2 support See gh-47688 --- .../antora/AntoraAsciidocAttributes.java | 1 + .../antora-asciidoc-attributes.properties | 2 ++ .../antora/AntoraAsciidocAttributesTests.java | 2 ++ .../reference/pages/features/json.adoc | 32 ++++++++++++++++--- module/spring-boot-websocket/build.gradle | 1 + 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/buildSrc/src/main/java/org/springframework/boot/build/antora/AntoraAsciidocAttributes.java b/buildSrc/src/main/java/org/springframework/boot/build/antora/AntoraAsciidocAttributes.java index 9ee20abd90c..c0f6a06d644 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/antora/AntoraAsciidocAttributes.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/antora/AntoraAsciidocAttributes.java @@ -165,6 +165,7 @@ public class AntoraAsciidocAttributes { addDependencyVersion(attributes, "jackson-core", "tools.jackson.core:jackson-core"); addDependencyVersion(attributes, "jackson-databind", "tools.jackson.core:jackson-databind"); addDependencyVersion(attributes, "jackson-dataformat-xml", "tools.jackson.dataformat:jackson-dataformat-xml"); + addDependencyVersion(attributes, "jackson2-databind", "com.fasterxml.jackson.core:jackson-databind"); addSpringDataDependencyVersion(attributes, internal, "spring-data-commons"); addSpringDataDependencyVersion(attributes, internal, "spring-data-couchbase"); addSpringDataDependencyVersion(attributes, internal, "spring-data-cassandra"); diff --git a/buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties b/buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties index e025675d02f..cbe5e560a02 100644 --- a/buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties +++ b/buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties @@ -80,10 +80,12 @@ url-jackson-annotations-javadoc=https://javadoc.io/doc/com.fasterxml.jackson.cor url-jackson-core-javadoc=https://javadoc.io/doc/tools.jackson.core/jackson-core/{version-jackson-core} url-jackson-databind-javadoc=https://javadoc.io/doc/tools.jackson.core/jackson-databind/{version-jackson-databind} url-jackson-dataformat-xml-javadoc=https://javadoc.io/doc/tools.jackson.dataformat/jackson-dataformat-xml/{version-jackson-dataformat-xml} +url-jackson2-databind-javadoc=https://javadoc.io/doc/com.fasterxml.jackson.core/jackson-databind/{version-jackson2-databind} # === Javadoc Locations === javadoc-location-com-fasterxml-jackson-annotation={url-jackson-annotations-javadoc} +javadoc-location-com-fasterxml-jackson-databind={url-jackson2-databind-javadoc} javadoc-location-org-apache-pulsar-client-api={url-pulsar-client-api-javadoc} javadoc-location-org-apache-pulsar-reactive-client-api={url-pulsar-client-reactive-api-javadoc} javadoc-location-org-springframework-data-cassandra={url-spring-data-cassandra-javadoc} diff --git a/buildSrc/src/test/java/org/springframework/boot/build/antora/AntoraAsciidocAttributesTests.java b/buildSrc/src/test/java/org/springframework/boot/build/antora/AntoraAsciidocAttributesTests.java index ea22801d174..769bf5d1a2b 100644 --- a/buildSrc/src/test/java/org/springframework/boot/build/antora/AntoraAsciidocAttributesTests.java +++ b/buildSrc/src/test/java/org/springframework/boot/build/antora/AntoraAsciidocAttributesTests.java @@ -282,8 +282,10 @@ class AntoraAsciidocAttributesTests { addMockTestcontainersVersion(versions, "redpanda", version); addMockTestcontainersVersion(versions, "r2dbc", version); addMockJackson2CoreVersion(versions, "jackson-annotations", version); + addMockJackson2CoreVersion(versions, "jackson-databind", version); addMockJacksonCoreVersion(versions, "jackson-core", version); addMockJacksonCoreVersion(versions, "jackson-databind", version); + addMockJacksonCoreVersion(versions, "jackson-databind", version); versions.put("org.apache.pulsar:pulsar-client-api", version); versions.put("org.apache.pulsar:pulsar-client-reactive-api", version); versions.put("tools.jackson.dataformat:jackson-dataformat-xml", version); diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/json.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/json.adoc index 40e14883bb7..e525ff83d09 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/json.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/features/json.adoc @@ -3,19 +3,23 @@ Spring Boot provides integration with the following JSON mapping libraries: -- Gson - Jackson 3 +- Jackson 2 +- Gson - JSON-B - Kotlin Serialization -Jackson is the preferred and default library. +Jackson 3 is the preferred and default library. + +Support for Jackson 2 is deprecated and will be removed in a future Spring Boot 4.x release. +It is provided purely to ease the migration from Jackson 2 to Jackson 3 and should not be relied up in the longer term. [[features.json.jackson]] -== Jackson +== Jackson 3 -Auto-configuration for Jackson is provided and Jackson is part of `spring-boot-starter-json`. +Auto-configuration for Jackson 3 is provided and Jackson is part of `spring-boot-starter-json`. When Jackson is on the classpath a javadoc:tools.jackson.databind.json.JsonMapper[] bean is automatically configured. Several configuration properties are provided for xref:how-to:spring-mvc.adoc#howto.spring-mvc.customize-jackson-jsonmapper[customizing the configuration of the javadoc:tools.jackson.databind.json.JsonMapper[]]. @@ -53,6 +57,26 @@ The registration is performed by Spring Boot's javadoc:org.springframework.boot. +[[features.json.jackson2]] +== Jackson 2 + +Deprecated auto-configuration for Jackson 2 is provided by the `spring-boot-jackson2` module. +When this module is on the classpath a javadoc:com.fasterxml.jackson.databind.ObjectMapper[] bean is automatically configured. +Several `+spring.jackson2.*+` configuration properties are provided for customizing the configuration. +To take more control, define one or more javadoc:org.springframework.boot.jackson2.autoconfigure.Jackson2ObjectMapperBuilderCustomizer[] beans. + +When both Jackson 3 and Jackson 2 are present, various configuration properties can be used to indicate that Jackson 2 is preferred: + +- configprop:spring.graphql.rsocket.preferred-json-mapper[] +- configprop:spring.http.codecs.preferred-json-mapper[] (used by Spring WebFlux and reactive HTTP clients) +- configprop:spring.http.converters.preferred-json-mapper[] (used by Spring MVC and imperative HTTP clients) +- configprop:spring.rsocket.preferred-mapper[] +- configprop:spring.websocket.messaging.preferred-json-mapper[] + +In each case, set the relevant property to `jackson2` to indicate that Jackson 2 is preferred. + + + [[features.json.gson]] == Gson diff --git a/module/spring-boot-websocket/build.gradle b/module/spring-boot-websocket/build.gradle index 6c6673143b4..8a275503c6f 100644 --- a/module/spring-boot-websocket/build.gradle +++ b/module/spring-boot-websocket/build.gradle @@ -17,6 +17,7 @@ plugins { id "java-library" id "org.springframework.boot.auto-configuration" + id "org.springframework.boot.configuration-properties" id "org.springframework.boot.deployed" id "org.springframework.boot.optional-dependencies" } From e7f3ae19f8bb86ad4be3dc0c5f732b0bf62713f6 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Fri, 10 Oct 2025 14:08:16 -0700 Subject: [PATCH 10/10] Add Jackson 2 smoke tests See gh-47688 Co-authored-by: Andy Wilkinson --- settings.gradle | 2 + .../build.gradle | 30 +++++++ .../java/smoketest/jackson2/mixed/Name.java | 21 +++++ .../jackson2/mixed/NamesEndpoint.java | 39 ++++++++++ .../jackson2/mixed/SampleController.java | 32 ++++++++ .../mixed/SampleJackson2MixedApplication.java | 29 +++++++ .../mixed/SampleJacksonComponent.java | 38 +++++++++ .../jackson2/mixed/SampleJsonComponent.java | 40 ++++++++++ .../jackson2/mixed/package-info.java | 20 +++++ .../src/main/resources/application.properties | 2 + .../mixed/MixedJacksonPresenceTests.java | 42 ++++++++++ .../SampleJackson2MixedApplicationTests.java | 77 ++++++++++++++++++ ...ApplicationWithJackson3PreferredTests.java | 78 +++++++++++++++++++ .../build.gradle | 34 ++++++++ .../only/SampleJackson2OnlyApplication.java | 29 +++++++ .../smoketest/jackson2/only/package-info.java | 20 +++++ .../src/main/resources/application.properties | 1 + .../only/OnlyJackson2PresenceTests.java | 42 ++++++++++ .../SampleJackson2OnlyApplicationTests.java | 67 ++++++++++++++++ 19 files changed, 643 insertions(+) create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/Name.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/NamesEndpoint.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleController.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJackson2MixedApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJacksonComponent.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJsonComponent.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/resources/application.properties create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/MixedJacksonPresenceTests.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/SampleJackson2MixedApplicationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/SampleJackson2MixedApplicationWithJackson3PreferredTests.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-only/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-only/src/main/java/smoketest/jackson2/only/SampleJackson2OnlyApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-only/src/main/java/smoketest/jackson2/only/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-only/src/main/resources/application.properties create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-only/src/test/java/smoketest/jackson2/only/OnlyJackson2PresenceTests.java create mode 100644 smoke-test/spring-boot-smoke-test-jackson2-only/src/test/java/smoketest/jackson2/only/SampleJackson2OnlyApplicationTests.java diff --git a/settings.gradle b/settings.gradle index 2fca806bdd4..bbcd67e46da 100644 --- a/settings.gradle +++ b/settings.gradle @@ -414,6 +414,8 @@ include ":smoke-test:spring-boot-smoke-test-graphql" include ":smoke-test:spring-boot-smoke-test-hateoas" include ":smoke-test:spring-boot-smoke-test-hibernate" include ":smoke-test:spring-boot-smoke-test-integration" +include ":smoke-test:spring-boot-smoke-test-jackson2-mixed" +include ":smoke-test:spring-boot-smoke-test-jackson2-only" include ":smoke-test:spring-boot-smoke-test-jetty" include ":smoke-test:spring-boot-smoke-test-jetty-jsp" include ":smoke-test:spring-boot-smoke-test-jetty-ssl" diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/build.gradle b/smoke-test/spring-boot-smoke-test-jackson2-mixed/build.gradle new file mode 100644 index 00000000000..c9fcf508ae3 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/build.gradle @@ -0,0 +1,30 @@ +/* + * Copyright 2012-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. + */ + +plugins { + id "java" +} + +description = "Spring Boot Jackson 2 mixed with Jackson 3 smoke test" + +dependencies { + implementation(project(":module:spring-boot-jackson2")) + implementation(project(":starter:spring-boot-starter-actuator")) + implementation(project(":starter:spring-boot-starter-webmvc")) + + testImplementation(project(":starter:spring-boot-starter-test")) + testImplementation(project(":starter:spring-boot-starter-webmvc-test")) +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/Name.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/Name.java new file mode 100644 index 00000000000..b39cd2940da --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/Name.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-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 smoketest.jackson2.mixed; + +public record Name(String first, String last) { + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/NamesEndpoint.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/NamesEndpoint.java new file mode 100644 index 00000000000..74f83c4654c --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/NamesEndpoint.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-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 smoketest.jackson2.mixed; + +import java.util.List; + +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.stereotype.Component; + +/** + * Names endpoint. + * + * @author Andy Wilkinson + */ +@Component +@Endpoint(id = "names") +public class NamesEndpoint { + + @ReadOperation + List names() { + return List.of(new Name("Spring", "Boot")); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleController.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleController.java new file mode 100644 index 00000000000..45d449de5cc --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleController.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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 smoketest.jackson2.mixed; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SampleController { + + @GetMapping("/names") + List names() { + return List.of(new Name("Spring", "Boot")); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJackson2MixedApplication.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJackson2MixedApplication.java new file mode 100644 index 00000000000..f2a83c9b7ae --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJackson2MixedApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-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 smoketest.jackson2.mixed; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleJackson2MixedApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleJackson2MixedApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJacksonComponent.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJacksonComponent.java new file mode 100644 index 00000000000..e74fc0d6925 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJacksonComponent.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-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 smoketest.jackson2.mixed; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; + +import org.springframework.boot.jackson.JacksonComponent; + +@JacksonComponent +public class SampleJacksonComponent { + + public static class Serializer extends ValueSerializer { + + @Override + public void serialize(Name value, JsonGenerator gen, SerializationContext ctxt) throws JacksonException { + gen.writeString("JACKSON:%s:%s".formatted(value.first(), value.last())); + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJsonComponent.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJsonComponent.java new file mode 100644 index 00000000000..f6a13a5fef5 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/SampleJsonComponent.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-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 smoketest.jackson2.mixed; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import org.springframework.boot.jackson2.JsonComponent; + +@JsonComponent +@SuppressWarnings("removal") +public class SampleJsonComponent { + + public static class Serializer extends JsonSerializer { + + @Override + public void serialize(Name value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString("JACKSON2:%s:%s".formatted(value.first(), value.last())); + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/package-info.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/package-info.java new file mode 100644 index 00000000000..5f429cddee0 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/java/smoketest/jackson2/mixed/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +@NullMarked +package smoketest.jackson2.mixed; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/resources/application.properties b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/resources/application.properties new file mode 100644 index 00000000000..1288f44c6f3 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/main/resources/application.properties @@ -0,0 +1,2 @@ +management.endpoints.web.exposure.include=* +spring.http.converters.preferred-json-mapper=jackson2 diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/MixedJacksonPresenceTests.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/MixedJacksonPresenceTests.java new file mode 100644 index 00000000000..8c797c56e5c --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/MixedJacksonPresenceTests.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-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 smoketest.jackson2.mixed; + +import org.junit.jupiter.api.Test; + +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the presence of mixed versions of Jackson. + * + * @author Andy Wilkinson + */ +class MixedJacksonPresenceTests { + + @Test + void jackson2IsPresent() { + assertThat(ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", null)).isTrue(); + } + + @Test + void jackson3IsPresent() { + assertThat(ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", null)).isTrue(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/SampleJackson2MixedApplicationTests.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/SampleJackson2MixedApplicationTests.java new file mode 100644 index 00000000000..da4d76a8a5c --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/SampleJackson2MixedApplicationTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-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 smoketest.jackson2.mixed; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.web.servlet.client.RestTestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SampleJackson2MixedApplication}. + * + * @author Andy Wilkinson + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureRestTestClient +class SampleJackson2MixedApplicationTests { + + @Autowired + RestTestClient rest; + + @Test + void jackson2IsUsedForHttpMessageConversion() { + this.rest.get().uri("/names").exchange().expectBody().json("[\"JACKSON2:Spring:Boot\"]"); + } + + @Test + void jackson2IsUsedForActuatorEndpoints() { + this.rest.get().uri("/actuator/names").exchange().expectBody().json("[\"JACKSON2:Spring:Boot\"]"); + } + + @Test + @SuppressWarnings("unchecked") + void configPropsShouldReturnOk() { + this.rest.get() + .uri("/actuator/configprops") + .exchange() + .expectStatus() + .isOk() + .expectBody(Map.class) + .value((body) -> assertThat(body).containsOnlyKeys("contexts")); + } + + @Test + @SuppressWarnings("unchecked") + void actuatorLinksShouldReturnOk() { + this.rest.get() + .uri("/actuator") + .exchange() + .expectStatus() + .isOk() + .expectBody(Map.class) + .value((body) -> assertThat(body).containsOnlyKeys("_links")); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/SampleJackson2MixedApplicationWithJackson3PreferredTests.java b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/SampleJackson2MixedApplicationWithJackson3PreferredTests.java new file mode 100644 index 00000000000..f17e1e918d5 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-mixed/src/test/java/smoketest/jackson2/mixed/SampleJackson2MixedApplicationWithJackson3PreferredTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-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 smoketest.jackson2.mixed; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.web.servlet.client.RestTestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SampleJackson2MixedApplication} when Jackson 3 is preferred. + * + * @author Andy Wilkinson + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = "spring.http.converters.preferred-json-mapper=jackson") +@AutoConfigureRestTestClient +class SampleJackson2MixedApplicationWithJackson3PreferredTests { + + @Autowired + RestTestClient rest; + + @Test + void jacksonIsUsedForHttpMessageConversion() { + this.rest.get().uri("/names").exchange().expectBody().json("[\"JACKSON:Spring:Boot\"]"); + } + + @Test + void jacksonIsUsedForActuatorEndpoints() { + this.rest.get().uri("/actuator/names").exchange().expectBody().json("[\"JACKSON:Spring:Boot\"]"); + } + + @Test + @SuppressWarnings("unchecked") + void configPropsShouldReturnOk() { + this.rest.get() + .uri("/actuator/configprops") + .exchange() + .expectStatus() + .isOk() + .expectBody(Map.class) + .value((body) -> assertThat(body).containsOnlyKeys("contexts")); + } + + @Test + @SuppressWarnings("unchecked") + void actuatorLinksShouldReturnOk() { + this.rest.get() + .uri("/actuator") + .exchange() + .expectStatus() + .isOk() + .expectBody(Map.class) + .value((body) -> assertThat(body).containsOnlyKeys("_links")); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-only/build.gradle b/smoke-test/spring-boot-smoke-test-jackson2-only/build.gradle new file mode 100644 index 00000000000..136d32d6701 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-only/build.gradle @@ -0,0 +1,34 @@ +/* + * Copyright 2012-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. + */ + +plugins { + id "java" +} + +description = "Spring Boot Jackson 2 without Jackson 3 smoke test" + +dependencies { + implementation(project(":module:spring-boot-jackson2")) + implementation(project(":starter:spring-boot-starter-actuator")) + implementation(project(":starter:spring-boot-starter-webmvc")) { + exclude module: 'spring-boot-starter-jackson' + } + + testImplementation(project(":starter:spring-boot-starter-test")) + testImplementation(project(":starter:spring-boot-starter-webmvc-test")) { + exclude module: 'spring-boot-starter-jackson' + } +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-only/src/main/java/smoketest/jackson2/only/SampleJackson2OnlyApplication.java b/smoke-test/spring-boot-smoke-test-jackson2-only/src/main/java/smoketest/jackson2/only/SampleJackson2OnlyApplication.java new file mode 100644 index 00000000000..4ed4b1faf53 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-only/src/main/java/smoketest/jackson2/only/SampleJackson2OnlyApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-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 smoketest.jackson2.only; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleJackson2OnlyApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleJackson2OnlyApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-only/src/main/java/smoketest/jackson2/only/package-info.java b/smoke-test/spring-boot-smoke-test-jackson2-only/src/main/java/smoketest/jackson2/only/package-info.java new file mode 100644 index 00000000000..ecf34f0a59e --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-only/src/main/java/smoketest/jackson2/only/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +@NullMarked +package smoketest.jackson2.only; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-jackson2-only/src/main/resources/application.properties b/smoke-test/spring-boot-smoke-test-jackson2-only/src/main/resources/application.properties new file mode 100644 index 00000000000..821da092741 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-only/src/main/resources/application.properties @@ -0,0 +1 @@ +management.endpoints.web.exposure.include=* diff --git a/smoke-test/spring-boot-smoke-test-jackson2-only/src/test/java/smoketest/jackson2/only/OnlyJackson2PresenceTests.java b/smoke-test/spring-boot-smoke-test-jackson2-only/src/test/java/smoketest/jackson2/only/OnlyJackson2PresenceTests.java new file mode 100644 index 00000000000..00ada803a7d --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-only/src/test/java/smoketest/jackson2/only/OnlyJackson2PresenceTests.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-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 smoketest.jackson2.only; + +import org.junit.jupiter.api.Test; + +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the presence of mixed versions of Jackson. + * + * @author Andy Wilkinson + */ +class OnlyJackson2PresenceTests { + + @Test + void jackson2IsPresent() { + assertThat(ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", null)).isTrue(); + } + + @Test + void jackson3IsNotPresent() { + assertThat(ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", null)).isFalse(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jackson2-only/src/test/java/smoketest/jackson2/only/SampleJackson2OnlyApplicationTests.java b/smoke-test/spring-boot-smoke-test-jackson2-only/src/test/java/smoketest/jackson2/only/SampleJackson2OnlyApplicationTests.java new file mode 100644 index 00000000000..fce0b81cb12 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jackson2-only/src/test/java/smoketest/jackson2/only/SampleJackson2OnlyApplicationTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2012-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 smoketest.jackson2.only; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.web.servlet.client.RestTestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for Actuator running in {@link SampleJackson2OnlyApplication}. + * + * @author Andy Wilkinson + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureRestTestClient +class SampleJackson2OnlyApplicationTests { + + @Autowired + RestTestClient rest; + + @Test + @SuppressWarnings("unchecked") + void configPropsShouldReturnOk() { + this.rest.get() + .uri("/actuator/configprops") + .exchange() + .expectStatus() + .isOk() + .expectBody(Map.class) + .value((body) -> assertThat(body).containsOnlyKeys("contexts")); + } + + @Test + @SuppressWarnings("unchecked") + void actuatorLinksShouldReturnOk() { + this.rest.get() + .uri("/actuator") + .exchange() + .expectStatus() + .isOk() + .expectBody(Map.class) + .value((body) -> assertThat(body).containsOnlyKeys("_links")); + } + +}