From df417bf317ed1c64309bc3e4e210117c1e7a8bb1 Mon Sep 17 00:00:00 2001 From: Guirong Hu Date: Thu, 10 Mar 2022 11:37:16 +0800 Subject: [PATCH] Simplify registration of Jackson mixin types See gh-30152 --- .../jackson/JacksonAutoConfiguration.java | 12 ++ .../JacksonAutoConfigurationTests.java | 8 +- .../boot/jackson/JsonMixin.java | 59 ++++++ .../boot/jackson/JsonMixinModule.java | 107 ++++++++++ .../boot/jackson/JsonMixinScan.java | 75 +++++++ .../boot/jackson/JsonMixinScanPackages.java | 185 ++++++++++++++++++ .../boot/jackson/JsonMixinModuleTests.java | 107 ++++++++++ .../jackson/JsonMixinScanPackagesTests.java | 182 +++++++++++++++++ .../boot/jackson/scan/a/RenameMixInClass.java | 33 ++++ .../scan/b/RenameMixInAbstractClass.java | 30 +++ .../jackson/scan/c/RenameMixInInterface.java | 30 +++ .../boot/jackson/scan/d/EmptyMixInClass.java | 26 +++ 12 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixin.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinScan.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinScanPackages.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinScanPackagesTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/a/RenameMixInClass.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/b/RenameMixInAbstractClass.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/c/RenameMixInInterface.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/scan/d/EmptyMixInClass.java 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..d8a3b1e93a 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,14 @@ 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.boot.jackson.JsonMixinScanPackages; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -93,6 +96,15 @@ public class JacksonAutoConfiguration { return new JsonComponentModule(); } + @Bean + public JsonMixinModule jsonMixinModule(ApplicationContext context) { + List packages = JsonMixinScanPackages.get(context).getPackageNames(); + if (packages.isEmpty() && AutoConfigurationPackages.has(context)) { + packages = AutoConfigurationPackages.get(context); + } + 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..1c79ba9829 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. @@ -52,6 +52,7 @@ import org.junit.jupiter.api.Test; 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 +91,11 @@ class JacksonAutoConfigurationTests { }); } + @Test + void jsonMixinModuleShouldBeAutoconfigured() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(JsonMixinModule.class)); + } + @Test void noCustomDateFormat() { this.contextRunner.run((context) -> { 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..de70dd907f --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.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.io.IOException; +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.classreading.MetadataReader; +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 register {@link JsonMixin @JsonMixin} + * annotated beans. + * + * @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); + Class[] targetTypes = annotation.getClassArray("type"); + if (ObjectUtils.isEmpty(targetTypes)) { + return; + } + for (Class targetType : targetTypes) { + setMixInAnnotation(targetType, mixinClass); + } + } + + static class JsonMixinComponentScanner extends ClassPathScanningCandidateComponentProvider { + + @Override + protected boolean isCandidateComponent(MetadataReader metadataReader) throws IOException { + return true; + } + + @Override + protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { + return beanDefinition.getMetadata().hasAnnotation(JsonMixin.class.getName()); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinScan.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinScan.java new file mode 100644 index 0000000000..86f8835e98 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinScan.java @@ -0,0 +1,75 @@ +/* + * 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.context.annotation.Import; +import org.springframework.core.annotation.AliasFor; + +/** + * Configures the base packages used by auto-configuration when scanning for mix-in + * classes. One of {@link #basePackageClasses()}, {@link #basePackages()} or its alias + * {@link #value()} may be specified to define specific packages to scan. If specific + * packages are not defined scanning will occur from the package of the class with this + * annotation. + * + * @author Guirong Hu + * @since 2.7.0 + * @see JsonMixinScanPackages + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Import(JsonMixinScanPackages.Registrar.class) +public @interface JsonMixinScan { + + /** + * Alias for the {@link #basePackages()} attribute. Allows for more concise annotation + * declarations e.g.: {@code @JsonMixinScan("org.my.pkg")} instead of + * {@code @JsonMixinScan(basePackages="org.my.pkg")}. + * @return the base packages to scan + */ + @AliasFor("basePackages") + String[] value() default {}; + + /** + * Base packages to scan for mix-in classes. {@link #value()} is an alias for (and + * mutually exclusive with) this attribute. + *

+ * Use {@link #basePackageClasses()} for a type-safe alternative to String-based + * package names. + * @return the base packages to scan + */ + @AliasFor("value") + String[] basePackages() default {}; + + /** + * Type-safe alternative to {@link #basePackages()} for specifying the packages to + * scan for mix-in classes. The package of each class specified will be scanned. + *

+ * Consider creating a special no-op marker class or interface in each package that + * serves no purpose other than being referenced by this attribute. + * @return classes from the base packages to scan + */ + Class[] basePackageClasses() default {}; + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinScanPackages.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinScanPackages.java new file mode 100644 index 0000000000..144c0c0543 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinScanPackages.java @@ -0,0 +1,185 @@ +/* + * 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.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.GenericBeanDefinition; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * Class for storing {@link JsonMixinScan @JsonMixinScan} specified packages for reference + * later. + * + * @author Guirong Hu + * @since 2.7.0 + * @see JsonMixinScan + * @see JsonMixinModule + */ +public class JsonMixinScanPackages { + + private static final String BEAN = JsonMixinScanPackages.class.getName(); + + private static final JsonMixinScanPackages NONE = new JsonMixinScanPackages(); + + private final List packageNames; + + JsonMixinScanPackages(String... packageNames) { + List packages = new ArrayList<>(); + for (String name : packageNames) { + if (StringUtils.hasText(name)) { + packages.add(name); + } + } + this.packageNames = Collections.unmodifiableList(packages); + } + + /** + * Return the package names specified from all {@link JsonMixinScan @JsonMixinScan} + * annotations. + * @return the mix-in classes scan package names + */ + public List getPackageNames() { + return this.packageNames; + } + + /** + * Return the {@link JsonMixinScanPackages} for the given bean factory. + * @param beanFactory the source bean factory + * @return the {@link JsonMixinScanPackages} for the bean factory (never {@code null}) + */ + public static JsonMixinScanPackages get(BeanFactory beanFactory) { + // Currently, we only store a single base package, but we return a list to + // allow this to change in the future if needed + try { + return beanFactory.getBean(BEAN, JsonMixinScanPackages.class); + } + catch (NoSuchBeanDefinitionException ex) { + return NONE; + } + } + + /** + * Register the specified mix-in classes scan packages with the system. + * @param registry the source registry + * @param packageNames the package names to register + */ + public static void register(BeanDefinitionRegistry registry, String... packageNames) { + Assert.notNull(registry, "Registry must not be null"); + Assert.notNull(packageNames, "PackageNames must not be null"); + register(registry, Arrays.asList(packageNames)); + } + + /** + * Register the specified mix-in classes scan packages with the system. + * @param registry the source registry + * @param packageNames the package names to register + */ + public static void register(BeanDefinitionRegistry registry, Collection packageNames) { + Assert.notNull(registry, "Registry must not be null"); + Assert.notNull(packageNames, "PackageNames must not be null"); + if (registry.containsBeanDefinition(BEAN)) { + JsonMixinScanPackagesBeanDefinition beanDefinition = (JsonMixinScanPackagesBeanDefinition) registry + .getBeanDefinition(BEAN); + beanDefinition.addPackageNames(packageNames); + } + else { + registry.registerBeanDefinition(BEAN, new JsonMixinScanPackagesBeanDefinition(packageNames)); + } + } + + /** + * {@link ImportBeanDefinitionRegistrar} to store the base package from the importing + * configuration. + */ + static class Registrar implements ImportBeanDefinitionRegistrar { + + private final Environment environment; + + Registrar(Environment environment) { + this.environment = environment; + } + + @Override + public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) { + register(registry, getPackagesToScan(metadata)); + } + + private Set getPackagesToScan(AnnotationMetadata metadata) { + AnnotationAttributes attributes = AnnotationAttributes + .fromMap(metadata.getAnnotationAttributes(JsonMixinScan.class.getName())); + Set packagesToScan = new LinkedHashSet<>(); + for (String basePackage : attributes.getStringArray("basePackages")) { + String[] tokenized = StringUtils.tokenizeToStringArray( + this.environment.resolvePlaceholders(basePackage), + ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS); + Collections.addAll(packagesToScan, tokenized); + } + for (Class basePackageClass : attributes.getClassArray("basePackageClasses")) { + packagesToScan.add(this.environment.resolvePlaceholders(ClassUtils.getPackageName(basePackageClass))); + } + if (packagesToScan.isEmpty()) { + String packageName = ClassUtils.getPackageName(metadata.getClassName()); + Assert.state(StringUtils.hasLength(packageName), + "@JsonMixinScan cannot be used with the default package"); + return Collections.singleton(packageName); + } + return packagesToScan; + } + + } + + static class JsonMixinScanPackagesBeanDefinition extends GenericBeanDefinition { + + private final Set packageNames = new LinkedHashSet<>(); + + JsonMixinScanPackagesBeanDefinition(Collection packageNames) { + setBeanClass(JsonMixinScanPackages.class); + setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + addPackageNames(packageNames); + } + + @Override + public Supplier getInstanceSupplier() { + return () -> new JsonMixinScanPackages(StringUtils.toStringArray(this.packageNames)); + } + + private void addPackageNames(Collection additionalPackageNames) { + this.packageNames.addAll(additionalPackageNames); + } + + } + +} 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..07addfaa78 --- /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 + */ +public 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/JsonMixinScanPackagesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinScanPackagesTests.java new file mode 100644 index 0000000000..179c4fd128 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinScanPackagesTests.java @@ -0,0 +1,182 @@ +/* + * 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 java.util.Collections; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotationConfigurationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link JsonMixinScanPackages}. + * + * @author Guirong Hu + */ +class JsonMixinScanPackagesTests { + + private AnnotationConfigApplicationContext context; + + @AfterEach + void cleanup() { + if (this.context != null) { + this.context.close(); + } + } + + @Test + void getWhenNoneRegisteredShouldReturnNone() { + this.context = new AnnotationConfigApplicationContext(); + this.context.refresh(); + JsonMixinScanPackages packages = JsonMixinScanPackages.get(this.context); + assertThat(packages).isNotNull(); + assertThat(packages.getPackageNames()).isEmpty(); + } + + @Test + void getShouldReturnRegisterPackages() { + this.context = new AnnotationConfigApplicationContext(); + JsonMixinScanPackages.register(this.context, "a", "b"); + JsonMixinScanPackages.register(this.context, "b", "c"); + this.context.refresh(); + JsonMixinScanPackages packages = JsonMixinScanPackages.get(this.context); + assertThat(packages.getPackageNames()).containsExactly("a", "b", "c"); + } + + @Test + void registerFromArrayWhenRegistryIsNullShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> JsonMixinScanPackages.register(null)) + .withMessageContaining("Registry must not be null"); + + } + + @Test + void registerFromArrayWhenPackageNamesIsNullShouldThrowException() { + this.context = new AnnotationConfigApplicationContext(); + assertThatIllegalArgumentException() + .isThrownBy(() -> JsonMixinScanPackages.register(this.context, (String[]) null)) + .withMessageContaining("PackageNames must not be null"); + } + + @Test + void registerFromCollectionWhenRegistryIsNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> JsonMixinScanPackages.register(null, Collections.emptyList())) + .withMessageContaining("Registry must not be null"); + } + + @Test + void registerFromCollectionWhenPackageNamesIsNullShouldThrowException() { + this.context = new AnnotationConfigApplicationContext(); + assertThatIllegalArgumentException() + .isThrownBy(() -> JsonMixinScanPackages.register(this.context, (Collection) null)) + .withMessageContaining("PackageNames must not be null"); + } + + @Test + void jsonMixinScanAnnotationWhenHasValueAttributeShouldSetupPackages() { + this.context = new AnnotationConfigApplicationContext(JsonMixinScanValueConfig.class); + JsonMixinScanPackages packages = JsonMixinScanPackages.get(this.context); + assertThat(packages.getPackageNames()).containsExactly("a"); + } + + @Test + void jsonMixinScanAnnotationWhenHasValueAttributeShouldSetupPackagesAsm() { + this.context = new AnnotationConfigApplicationContext(); + this.context.registerBeanDefinition("jsonMixinScanValueConfig", + new RootBeanDefinition(JsonMixinScanValueConfig.class.getName())); + this.context.refresh(); + JsonMixinScanPackages packages = JsonMixinScanPackages.get(this.context); + assertThat(packages.getPackageNames()).containsExactly("a"); + } + + @Test + void jsonMixinScanAnnotationWhenHasBasePackagesAttributeShouldSetupPackages() { + this.context = new AnnotationConfigApplicationContext(JsonMixinScanBasePackagesConfig.class); + JsonMixinScanPackages packages = JsonMixinScanPackages.get(this.context); + assertThat(packages.getPackageNames()).containsExactly("b"); + } + + @Test + void jsonMixinScanAnnotationWhenHasValueAndBasePackagesAttributeShouldThrow() { + assertThatExceptionOfType(AnnotationConfigurationException.class) + .isThrownBy(() -> this.context = new AnnotationConfigApplicationContext( + JsonMixinScanValueAndBasePackagesConfig.class)); + } + + @Test + void jsonMixinScanAnnotationWhenHasBasePackageClassesAttributeShouldSetupPackages() { + this.context = new AnnotationConfigApplicationContext(JsonMixinScanBasePackageClassesConfig.class); + JsonMixinScanPackages packages = JsonMixinScanPackages.get(this.context); + assertThat(packages.getPackageNames()).containsExactly(getClass().getPackage().getName()); + } + + @Test + void jsonMixinScanAnnotationWhenNoAttributesShouldSetupPackages() { + this.context = new AnnotationConfigApplicationContext(JsonMixinScanNoAttributesConfig.class); + JsonMixinScanPackages packages = JsonMixinScanPackages.get(this.context); + assertThat(packages.getPackageNames()).containsExactly(getClass().getPackage().getName()); + } + + @Test + void jsonMixinScanAnnotationWhenLoadingFromMultipleConfigsShouldCombinePackages() { + this.context = new AnnotationConfigApplicationContext(JsonMixinScanValueConfig.class, + JsonMixinScanBasePackagesConfig.class); + JsonMixinScanPackages packages = JsonMixinScanPackages.get(this.context); + assertThat(packages.getPackageNames()).containsExactly("a", "b"); + } + + @Configuration(proxyBeanMethods = false) + @JsonMixinScan("a") + static class JsonMixinScanValueConfig { + + } + + @Configuration(proxyBeanMethods = false) + @JsonMixinScan(basePackages = "b") + static class JsonMixinScanBasePackagesConfig { + + } + + @Configuration(proxyBeanMethods = false) + @JsonMixinScan(value = "a", basePackages = "b") + static class JsonMixinScanValueAndBasePackagesConfig { + + } + + @Configuration(proxyBeanMethods = false) + @JsonMixinScan(basePackageClasses = JsonMixinScanPackagesTests.class) + static class JsonMixinScanBasePackageClassesConfig { + + } + + @Configuration(proxyBeanMethods = false) + @JsonMixinScan + static class JsonMixinScanNoAttributesConfig { + + } + +} 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 { + +}