diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java index dd1d938959..90b035efe9 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java @@ -41,11 +41,13 @@ import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.beans.factory.ListableBeanFactory; 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.autoconfigure.jackson.JacksonProperties.ConstructorDetectorStrategy; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.jackson.JsonComponentModule; +import org.springframework.boot.jackson.JsonMixinModule; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -93,6 +95,13 @@ public class JacksonAutoConfiguration { return new JsonComponentModule(); } + @Bean + public JsonMixinModule jsonMixinModule(ApplicationContext context) { + List packages = AutoConfigurationPackages.has(context) ? AutoConfigurationPackages.get(context) + : Collections.emptyList(); + return new JsonMixinModule(context, packages); + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(Jackson2ObjectMapperBuilder.class) static class JacksonObjectMapperConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java index c95797bf38..894583bb14 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 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. @@ -47,11 +47,14 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.util.StdDateFormat; import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; import org.springframework.boot.jackson.JsonComponent; +import org.springframework.boot.jackson.JsonMixinModule; import org.springframework.boot.jackson.JsonObjectSerializer; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; @@ -90,6 +93,16 @@ class JacksonAutoConfigurationTests { }); } + @Test + void jsonMixinModuleShouldBeAutoConfiguredWithBasePackages() { + this.contextRunner.withUserConfiguration(MixinConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(JsonMixinModule.class); + JsonMixinModule module = context.getBean(JsonMixinModule.class); + assertThat(module).extracting("basePackages", InstanceOfAssertFactories.list(String.class)) + .containsExactly(MixinConfiguration.class.getPackage().getName()); + }); + } + @Test void noCustomDateFormat() { this.contextRunner.run((context) -> { @@ -631,4 +644,9 @@ class JacksonAutoConfigurationTests { } + @AutoConfigurationPackage + static class MixinConfiguration { + + } + } diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/json.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/json.adoc index f11989c0a8..62a392628f 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/json.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/json.adoc @@ -40,6 +40,14 @@ include::code:object/MyJsonComponent[] +[[features.json.jackson.mixins]] +==== Mixins +Jackson has support for mixins that can be used to mix additional annotations into those already declared on a target class. +Spring Boot's Jackson auto-configuration will scan your application's packages for classes annotated with `@JsonMixin` and register them with the auto-configured `ObjectMapper`. +The registration is performed by Spring Boot's `JsonMixinModule`. + + + [[features.json.gson]] === Gson Auto-configuration for Gson is provided. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixin.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixin.java new file mode 100644 index 0000000000..c1dcbe892c --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixin.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2022 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.jackson; + +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 2.7.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +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 + * @since 2.7.0 + */ + @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 + * @since 2.7.0 + */ + @AliasFor("value") + Class[] type() default {}; + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java new file mode 100644 index 0000000000..2285683384 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-2022 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.jackson; + +import java.util.Collection; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.module.SimpleModule; + +import org.springframework.beans.factory.InitializingBean; +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; + +/** + * Spring Bean and Jackson {@link Module} to find and + * {@link SimpleModule#setMixInAnnotation(Class, Class) register} + * {@link JsonMixin @JsonMixin}-annotated classes. + * + * @author Guirong Hu + * @since 2.7.0 + * @see JsonMixin + */ +public class JsonMixinModule extends SimpleModule implements InitializingBean { + + private final ApplicationContext context; + + private final Collection basePackages; + + /** + * Create a new {@link JsonMixinModule} instance. + * @param context the source application context + * @param basePackages the packages to check for annotated classes + */ + public JsonMixinModule(ApplicationContext context, Collection basePackages) { + Assert.notNull(context, "Context must not be null"); + this.context = context; + this.basePackages = basePackages; + } + + @Override + public void afterPropertiesSet() throws Exception { + if (ObjectUtils.isEmpty(this.basePackages)) { + return; + } + JsonMixinComponentScanner scanner = new JsonMixinComponentScanner(); + scanner.setEnvironment(this.context.getEnvironment()); + scanner.setResourceLoader(this.context); + for (String basePackage : this.basePackages) { + if (StringUtils.hasText(basePackage)) { + for (BeanDefinition candidate : scanner.findCandidateComponents(basePackage)) { + addJsonMixin(ClassUtils.forName(candidate.getBeanClassName(), this.context.getClassLoader())); + } + } + } + } + + private void addJsonMixin(Class mixinClass) { + MergedAnnotation annotation = MergedAnnotations + .from(mixinClass, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY).get(JsonMixin.class); + for (Class targetType : annotation.getClassArray("type")) { + setMixInAnnotation(targetType, mixinClass); + } + } + + static class JsonMixinComponentScanner extends ClassPathScanningCandidateComponentProvider { + + JsonMixinComponentScanner() { + addIncludeFilter(new AnnotationTypeFilter(JsonMixin.class)); + } + + @Override + protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { + return true; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java new file mode 100644 index 0000000000..b6a1430e3d --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2012-2022 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.jackson; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.jackson.scan.a.RenameMixInClass; +import org.springframework.boot.jackson.scan.b.RenameMixInAbstractClass; +import org.springframework.boot.jackson.scan.c.RenameMixInInterface; +import org.springframework.boot.jackson.scan.d.EmptyMixInClass; +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.assertThatIllegalArgumentException; + +/** + * Tests for {@link JsonMixinModule}. + * + * @author Guirong Hu + */ +class JsonMixinModuleTests { + + private AnnotationConfigApplicationContext context; + + @AfterEach + void closeContext() { + if (this.context != null) { + this.context.close(); + } + } + + @Test + void createWhenContextIsNullShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> new JsonMixinModule(null, Collections.emptyList())) + .withMessageContaining("Context must not be null"); + } + + @Test + void jsonWithModuleWithRenameMixInClassShouldBeMixedIn() throws Exception { + load(RenameMixInClass.class); + JsonMixinModule module = this.context.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 = this.context.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 = this.context.getBean(JsonMixinModule.class); + assertMixIn(module, new NameAndAge("spring", 100), "{\"age\":100,\"username\":\"spring\"}"); + } + + @Test + void jsonWithModuleWithRenameMixInInterfaceShouldBeMixedIn() throws Exception { + load(RenameMixInInterface.class); + JsonMixinModule module = this.context.getBean(JsonMixinModule.class); + assertMixIn(module, new NameAndAge("spring", 100), "{\"age\":100,\"username\":\"spring\"}"); + } + + private void load(Class... basePackageClasses) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + List basePackages = Arrays.stream(basePackageClasses).map(ClassUtils::getPackageName) + .collect(Collectors.toList()); + context.registerBean(JsonMixinModule.class, () -> new JsonMixinModule(context, basePackages)); + context.refresh(); + this.context = context; + } + + 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); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/a/RenameMixInClass.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/a/RenameMixInClass.java new file mode 100644 index 0000000000..15d401f598 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/a/RenameMixInClass.java @@ -0,0 +1,33 @@ +/* + * Copyright 2012-2022 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.jackson.scan.a; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.boot.jackson.JsonMixin; +import org.springframework.boot.jackson.Name; +import org.springframework.boot.jackson.NameAndAge; + +@JsonMixin(type = { Name.class, NameAndAge.class }) +public class RenameMixInClass { + + @JsonProperty("username") + String getName() { + return null; + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/b/RenameMixInAbstractClass.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/b/RenameMixInAbstractClass.java new file mode 100644 index 0000000000..0ee9bf1913 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/b/RenameMixInAbstractClass.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-2022 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.jackson.scan.b; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.boot.jackson.JsonMixin; +import org.springframework.boot.jackson.NameAndAge; + +@JsonMixin(type = NameAndAge.class) +public abstract class RenameMixInAbstractClass { + + @JsonProperty("username") + abstract String getName(); + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/c/RenameMixInInterface.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/c/RenameMixInInterface.java new file mode 100644 index 0000000000..cfff37fe32 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/c/RenameMixInInterface.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-2022 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.jackson.scan.c; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.boot.jackson.JsonMixin; +import org.springframework.boot.jackson.NameAndAge; + +@JsonMixin(type = NameAndAge.class) +public interface RenameMixInInterface { + + @JsonProperty("username") + String getName(); + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/d/EmptyMixInClass.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/d/EmptyMixInClass.java new file mode 100644 index 0000000000..358f9cb378 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/d/EmptyMixInClass.java @@ -0,0 +1,26 @@ +/* + * Copyright 2012-2022 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.jackson.scan.d; + +import org.springframework.boot.jackson.JsonMixin; +import org.springframework.boot.jackson.Name; +import org.springframework.boot.jackson.NameAndAge; + +@JsonMixin(type = { Name.class, NameAndAge.class }) +public class EmptyMixInClass { + +}