diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/ApplicationConversionService.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/ApplicationConversionService.java index a5ff4ce680b..92e4479b00a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/ApplicationConversionService.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/ApplicationConversionService.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -17,12 +17,24 @@ package org.springframework.boot.convert; import java.lang.annotation.Annotation; -import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Supplier; import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.ResolvableType; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalConverter; +import org.springframework.core.convert.converter.ConditionalGenericConverter; import org.springframework.core.convert.converter.Converter; import org.springframework.core.convert.converter.ConverterFactory; import org.springframework.core.convert.converter.ConverterRegistry; @@ -37,6 +49,8 @@ import org.springframework.format.Parser; import org.springframework.format.Printer; import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.format.support.FormattingConversionService; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import org.springframework.util.StringValueResolver; /** @@ -49,10 +63,13 @@ import org.springframework.util.StringValueResolver; * against registry instance. * * @author Phillip Webb + * @author Shixiong Guo * @since 2.0.0 */ public class ApplicationConversionService extends FormattingConversionService { + private static final ResolvableType STRING = ResolvableType.forClass(String.class); + private static volatile ApplicationConversionService sharedInstance; private final boolean unmodifiable; @@ -265,35 +282,284 @@ public class ApplicationConversionService extends FormattingConversionService { } /** - * Add {@link GenericConverter}, {@link Converter}, {@link Printer}, {@link Parser} - * and {@link Formatter} beans from the specified context. + * Add {@link Printer}, {@link Parser}, {@link Formatter}, {@link Converter}, + * {@link ConverterFactory}, {@link GenericConverter}, and beans from the specified + * bean factory. * @param registry the service to register beans with * @param beanFactory the bean factory to get the beans from * @since 2.2.0 */ public static void addBeans(FormatterRegistry registry, ListableBeanFactory beanFactory) { - Set beans = new LinkedHashSet<>(); - beans.addAll(beanFactory.getBeansOfType(GenericConverter.class).values()); - beans.addAll(beanFactory.getBeansOfType(Converter.class).values()); - beans.addAll(beanFactory.getBeansOfType(Printer.class).values()); - beans.addAll(beanFactory.getBeansOfType(Parser.class).values()); - for (Object bean : beans) { - if (bean instanceof GenericConverter genericConverter) { - registry.addConverter(genericConverter); - } - else if (bean instanceof Converter converter) { - registry.addConverter(converter); - } - else if (bean instanceof Formatter formatter) { - registry.addFormatter(formatter); + ConfigurableListableBeanFactory configurableBeanFactory = getConfigurableListableBeanFactory(beanFactory); + getBeans(beanFactory).forEach((beanName, bean) -> { + BeanDefinition beanDefinition = (configurableBeanFactory != null) + ? configurableBeanFactory.getMergedBeanDefinition(beanName) : null; + ResolvableType type = (beanDefinition != null) ? beanDefinition.getResolvableType() : null; + addBean(registry, bean, type); + }); + } + + private static ConfigurableListableBeanFactory getConfigurableListableBeanFactory(ListableBeanFactory beanFactory) { + if (beanFactory instanceof ConfigurableApplicationContext applicationContext) { + return applicationContext.getBeanFactory(); + } + if (beanFactory instanceof ConfigurableListableBeanFactory configurableListableBeanFactory) { + return configurableListableBeanFactory; + } + return null; + } + + private static Map getBeans(ListableBeanFactory beanFactory) { + Map beans = new LinkedHashMap<>(); + beans.putAll(getBeans(beanFactory, Printer.class)); + beans.putAll(getBeans(beanFactory, Parser.class)); + beans.putAll(getBeans(beanFactory, Formatter.class)); + beans.putAll(getBeans(beanFactory, Converter.class)); + beans.putAll(getBeans(beanFactory, ConverterFactory.class)); + beans.putAll(getBeans(beanFactory, GenericConverter.class)); + return beans; + } + + private static Map getBeans(ListableBeanFactory beanFactory, Class type) { + return beanFactory.getBeansOfType(type); + } + + static void addBean(FormatterRegistry registry, Object bean, ResolvableType beanType) { + if (bean instanceof GenericConverter converterBean) { + addBean(registry, converterBean, beanType, GenericConverter.class, registry::addConverter, (Runnable) null); + } + else if (bean instanceof Converter converterBean) { + addBean(registry, converterBean, beanType, Converter.class, registry::addConverter, + ConverterBeanAdapter::new); + } + else if (bean instanceof ConverterFactory converterBean) { + addBean(registry, converterBean, beanType, ConverterFactory.class, registry::addConverterFactory, + ConverterFactoryBeanAdapter::new); + } + else if (bean instanceof Formatter formatterBean) { + addBean(registry, formatterBean, beanType, Formatter.class, registry::addFormatter, () -> { + registry.addConverter(new PrinterBeanAdapter(formatterBean, beanType)); + registry.addConverter(new ParserBeanAdapter(formatterBean, beanType)); + }); + } + else if (bean instanceof Printer printerBean) { + addBean(registry, printerBean, beanType, Printer.class, registry::addPrinter, PrinterBeanAdapter::new); + } + else if (bean instanceof Parser parserBean) { + addBean(registry, parserBean, beanType, Parser.class, registry::addParser, ParserBeanAdapter::new); + } + } + + private static void addBean(FormatterRegistry registry, B bean, ResolvableType beanType, Class type, + Consumer standardRegistrar, BiFunction> beanAdapterFactory) { + addBean(registry, bean, beanType, type, standardRegistrar, + () -> registry.addConverter(beanAdapterFactory.apply(bean, beanType))); + } + + private static void addBean(FormatterRegistry registry, B bean, ResolvableType beanType, Class type, + Consumer standardRegistrar, Runnable beanAdapterRegistrar) { + if (beanType != null && beanAdapterRegistrar != null + && ResolvableType.forInstance(bean).as(type).hasUnresolvableGenerics()) { + beanAdapterRegistrar.run(); + return; + } + standardRegistrar.accept(bean); + } + + /** + * Base class for adapters that adapt a bean to a {@link GenericConverter}. + * + * @param the base type of the bean + */ + abstract static class BeanAdapter implements ConditionalGenericConverter { + + private final B bean; + + private final ResolvableTypePair types; + + BeanAdapter(B bean, ResolvableType beanType) { + Assert.isInstanceOf(beanType.toClass(), bean); + ResolvableType type = ResolvableType.forClass(getClass()).as(BeanAdapter.class).getGeneric(); + ResolvableType[] generics = beanType.as(type.toClass()).getGenerics(); + this.bean = bean; + this.types = getResolvableTypePair(generics); + } + + protected ResolvableTypePair getResolvableTypePair(ResolvableType[] generics) { + return new ResolvableTypePair(generics[0], generics[1]); + } + + protected B bean() { + return this.bean; + } + + @Override + public Set getConvertibleTypes() { + return Set.of(new ConvertiblePair(this.types.source().toClass(), this.types.target().toClass())); + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + return (this.types.target().toClass() == targetType.getObjectType() + && matchesTargetType(targetType.getResolvableType())); + } + + private boolean matchesTargetType(ResolvableType targetType) { + ResolvableType ours = this.types.target(); + return targetType.getType() instanceof Class || targetType.isAssignableFrom(ours) + || this.types.target().hasUnresolvableGenerics(); + } + + protected final boolean conditionalConverterCandidateMatches(Object conditionalConverterCandidate, + TypeDescriptor sourceType, TypeDescriptor targetType) { + return (conditionalConverterCandidate instanceof ConditionalConverter conditionalConverter) + ? conditionalConverter.matches(sourceType, targetType) : true; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + protected final Object convert(Object source, TypeDescriptor targetType, Converter converter) { + return (source != null) ? ((Converter) converter).convert(source) : convertNull(targetType); + } + + private Object convertNull(TypeDescriptor targetType) { + return (targetType.getObjectType() != Optional.class) ? null : Optional.empty(); + } + + @Override + public String toString() { + return this.types + " : " + this.bean; + } + + } + + /** + * Adapts a {@link Printer} bean to a {@link GenericConverter}. + */ + static class PrinterBeanAdapter extends BeanAdapter> { + + PrinterBeanAdapter(Printer bean, ResolvableType beanType) { + super(bean, beanType); + } + + @Override + protected ResolvableTypePair getResolvableTypePair(ResolvableType[] generics) { + return new ResolvableTypePair(generics[0], STRING); + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + return (source != null) ? print(source) : ""; + } + + @SuppressWarnings("unchecked") + private String print(Object object) { + return ((Printer) bean()).print(object, LocaleContextHolder.getLocale()); + } + + } + + /** + * Adapts a {@link Parser} bean to a {@link GenericConverter}. + */ + static class ParserBeanAdapter extends BeanAdapter> { + + ParserBeanAdapter(Parser bean, ResolvableType beanType) { + super(bean, beanType); + } + + @Override + protected ResolvableTypePair getResolvableTypePair(ResolvableType[] generics) { + return new ResolvableTypePair(STRING, generics[0]); + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + String text = (String) source; + return (!StringUtils.hasText(text)) ? null : parse(text); + } + + private Object parse(String text) { + try { + return bean().parse(text, LocaleContextHolder.getLocale()); } - else if (bean instanceof Printer printer) { - registry.addPrinter(printer); + catch (IllegalArgumentException ex) { + throw ex; } - else if (bean instanceof Parser parser) { - registry.addParser(parser); + catch (Throwable ex) { + throw new IllegalArgumentException("Parse attempt failed for value [" + text + "]", ex); } } + + } + + /** + * Adapts a {@link Converter} bean to a {@link GenericConverter}. + */ + static final class ConverterBeanAdapter extends BeanAdapter> { + + ConverterBeanAdapter(Converter bean, ResolvableType beanType) { + super(bean, beanType); + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + return super.matches(sourceType, targetType) + && conditionalConverterCandidateMatches(bean(), sourceType, targetType); + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + return convert(source, targetType, bean()); + } + + } + + /** + * Adapts a {@link ConverterFactory} bean to a {@link GenericConverter}. + */ + private static final class ConverterFactoryBeanAdapter extends BeanAdapter> { + + ConverterFactoryBeanAdapter(ConverterFactory bean, ResolvableType beanType) { + super(bean, beanType); + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + return super.matches(sourceType, targetType) + && conditionalConverterCandidateMatches(bean(), sourceType, targetType) + && conditionalConverterCandidateMatches(getConverter(targetType::getType), sourceType, targetType); + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + return convert(source, targetType, getConverter(targetType::getObjectType)); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private Converter getConverter(Supplier> typeSupplier) { + return ((ConverterFactory) bean()).getConverter(typeSupplier.get()); + } + + } + + /** + * Convertible type information as extracted from bean generics. + * + * @param source the source type + * @param target the target type + */ + record ResolvableTypePair(ResolvableType source, ResolvableType target) { + + ResolvableTypePair { + Assert.notNull(source.resolve(), "'source' cannot be resolved"); + Assert.notNull(target.resolve(), "'target' cannot be resolved"); + } + + @Override + public final String toString() { + return source() + " -> " + target(); + } + } } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/ApplicationConversionServiceTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/ApplicationConversionServiceTests.java index a0b5ae1fdda..4120879ef2c 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/ApplicationConversionServiceTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/ApplicationConversionServiceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 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. @@ -19,34 +19,53 @@ package org.springframework.boot.convert; import java.text.ParseException; import java.util.List; import java.util.Locale; +import java.util.Optional; import java.util.Set; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.junit.jupiter.api.Test; +import org.springframework.boot.convert.ApplicationConversionService.ConverterBeanAdapter; +import org.springframework.boot.convert.ApplicationConversionService.ParserBeanAdapter; +import org.springframework.boot.convert.ApplicationConversionService.PrinterBeanAdapter; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.ResolvableType; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.ConverterNotFoundException; import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalConverter; import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterFactory; import org.springframework.core.convert.converter.GenericConverter; import org.springframework.format.Formatter; import org.springframework.format.FormatterRegistry; import org.springframework.format.Parser; import org.springframework.format.Printer; +import org.springframework.format.support.FormattingConversionService; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.withSettings; /** * Tests for {@link ApplicationConversionService}. * * @author Phillip Webb + * @author Shixiong Guo */ class ApplicationConversionServiceTests { - private final FormatterRegistry registry = mock(FormatterRegistry.class); + private final FormatterRegistry registry = mock(FormatterRegistry.class, + withSettings().extraInterfaces(ConversionService.class)); @Test void addBeansWhenHasGenericConverterBeanAddConverter() { @@ -94,6 +113,47 @@ class ApplicationConversionServiceTests { } } + @Test + @SuppressWarnings("unchecked") + void addBeansWhenHasConverterBeanMethodAddConverter() { + try (ConfigurableApplicationContext context = new AnnotationConfigApplicationContext( + ConverterBeanMethodConfiguration.class)) { + Converter converter = (Converter) context.getBean("converter"); + willThrow(IllegalArgumentException.class).given(this.registry).addConverter(converter); + ApplicationConversionService.addBeans(this.registry, context); + then(this.registry).should().addConverter(any(ConverterBeanAdapter.class)); + then(this.registry).shouldHaveNoMoreInteractions(); + } + } + + @Test + @SuppressWarnings("unchecked") + void addBeansWhenHasPrinterBeanMethodAddPrinter() { + try (ConfigurableApplicationContext context = new AnnotationConfigApplicationContext( + PrinterBeanMethodConfiguration.class)) { + Printer printer = (Printer) context.getBean("printer"); + willThrow(IllegalArgumentException.class).given(this.registry).addPrinter(printer); + ApplicationConversionService.addBeans(this.registry, context); + then(this.registry).should(never()).addPrinter(printer); + then(this.registry).should().addConverter(any(PrinterBeanAdapter.class)); + then(this.registry).shouldHaveNoMoreInteractions(); + } + } + + @Test + @SuppressWarnings("unchecked") + void addBeansWhenHasParserBeanMethodAddParser() { + try (ConfigurableApplicationContext context = new AnnotationConfigApplicationContext( + ParserBeanMethodConfiguration.class)) { + Parser parser = (Parser) context.getBean("parser"); + willThrow(IllegalArgumentException.class).given(this.registry).addParser(parser); + ApplicationConversionService.addBeans(this.registry, context); + then(this.registry).should(never()).addParser(parser); + then(this.registry).should().addConverter(any(ParserBeanAdapter.class)); + then(this.registry).shouldHaveNoMoreInteractions(); + } + } + @Test void isConvertViaObjectSourceTypeWhenObjectSourceReturnsTrue() { // Uses ObjectToCollectionConverter @@ -131,6 +191,130 @@ class ApplicationConversionServiceTests { assertUnmodifiableExceptionThrown(() -> instance.removeConvertible(null, null)); } + @Test + void addPrinterBeanWithTypeConvertsUsingTypeInformation() { + FormattingConversionService conversionService = new FormattingConversionService(); + Printer printer = (object, locale) -> object.toString().toUpperCase(locale); + ApplicationConversionService.addBean(conversionService, printer, + ResolvableType.forClassWithGenerics(Printer.class, ExampleRecord.class)); + assertThat(conversionService.convert(new ExampleRecord("test"), String.class)).isEqualTo("TEST"); + assertThatExceptionOfType(ConverterNotFoundException.class) + .isThrownBy(() -> conversionService.convert(new OtherRecord("test"), String.class)); + assertThatIllegalArgumentException().isThrownBy(() -> conversionService.addPrinter(printer)) + .withMessageContaining("Unable to extract"); + } + + @Test + void addParserBeanWithTypeConvertsUsingTypeInformation() { + FormattingConversionService conversionService = new FormattingConversionService(); + Parser parser = (text, locale) -> new ExampleRecord(text.toString()); + ApplicationConversionService.addBean(conversionService, parser, + ResolvableType.forClassWithGenerics(Parser.class, ExampleRecord.class)); + assertThat(conversionService.convert("test", ExampleRecord.class)).isEqualTo(new ExampleRecord("test")); + assertThatExceptionOfType(ConverterNotFoundException.class) + .isThrownBy(() -> conversionService.convert("test", OtherRecord.class)); + assertThatIllegalArgumentException().isThrownBy(() -> conversionService.addParser(parser)) + .withMessageContaining("Unable to extract"); + } + + @Test + @SuppressWarnings("rawtypes") + void addFormatterBeanWithTypeConvertsUsingTypeInformation() { + FormattingConversionService conversionService = new FormattingConversionService(); + Formatter formatter = new Formatter() { + + @Override + public String print(Object object, Locale locale) { + return object.toString().toUpperCase(locale); + } + + @Override + public Object parse(String text, Locale locale) throws ParseException { + return new ExampleRecord(text.toString()); + } + + }; + ApplicationConversionService.addBean(conversionService, formatter, + ResolvableType.forClassWithGenerics(Formatter.class, ExampleRecord.class)); + assertThat(conversionService.convert(new ExampleRecord("test"), String.class)).isEqualTo("TEST"); + assertThat(conversionService.convert("test", ExampleRecord.class)).isEqualTo(new ExampleRecord("test")); + assertThatExceptionOfType(ConverterNotFoundException.class) + .isThrownBy(() -> conversionService.convert(new OtherRecord("test"), String.class)); + assertThatExceptionOfType(ConverterNotFoundException.class) + .isThrownBy(() -> conversionService.convert("test", OtherRecord.class)); + assertThatIllegalArgumentException().isThrownBy(() -> conversionService.addFormatter(formatter)) + .withMessageContaining("Unable to extract"); + } + + @Test + void addConverterBeanWithTypeConvertsUsingTypeInformation() { + FormattingConversionService conversionService = new FormattingConversionService(); + Converter converter = (source) -> new ExampleRecord(source.toString()); + ApplicationConversionService.addBean(conversionService, converter, + ResolvableType.forClassWithGenerics(Converter.class, CharSequence.class, ExampleRecord.class)); + assertThat(conversionService.convert("test", ExampleRecord.class)).isEqualTo(new ExampleRecord("test")); + assertThat(conversionService.convert(new StringBuilder("test"), ExampleRecord.class)) + .isEqualTo(new ExampleRecord("test")); + assertThatExceptionOfType(ConverterNotFoundException.class) + .isThrownBy(() -> conversionService.convert("test", OtherRecord.class)); + assertThatIllegalArgumentException().isThrownBy(() -> conversionService.addConverter(converter)) + .withMessageContaining("Unable to determine"); + } + + @Test + @SuppressWarnings("rawtypes") + void addConverterBeanWithTypeWhenConditionalChecksCondition() { + FormattingConversionService conversionService = new FormattingConversionService(); + ConditionalConverterConverter converter = new ConditionalConverterConverter() { + + @Override + public Object convert(Object source) { + return new ExampleRecord(source.toString()); + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + return sourceType.getType() != StringBuilder.class; + } + + }; + ApplicationConversionService.addBean(conversionService, converter, + ResolvableType.forClassWithGenerics(Converter.class, CharSequence.class, ExampleRecord.class)); + assertThat(conversionService.convert("test", ExampleRecord.class)).isEqualTo(new ExampleRecord("test")); + assertThatExceptionOfType(ConverterNotFoundException.class) + .isThrownBy(() -> conversionService.convert(new StringBuilder("test"), ExampleRecord.class)); + assertThatIllegalArgumentException().isThrownBy(() -> conversionService.addConverter(converter)) + .withMessageContaining("Unable to determine"); + } + + @Test + @SuppressWarnings("unchecked") + void addConverterBeanWithTypeWhenNullSourceCanConvertToOptionEmpty() { + FormattingConversionService conversionService = new FormattingConversionService(); + Converter converter = (source) -> new ExampleRecord(source.toString()); + ApplicationConversionService.addBean(conversionService, converter, + ResolvableType.forClassWithGenerics(Converter.class, CharSequence.class, ExampleRecord.class)); + assertThat(conversionService.convert(null, ExampleRecord.class)).isNull(); + assertThat(conversionService.convert(null, Optional.class)).isEmpty(); + } + + @Test + @SuppressWarnings("rawtypes") + void addConverterFactoryBeanWithTypeConvertsUsingTypeInformation() { + FormattingConversionService conversionService = new FormattingConversionService(); + Converter converter = (source) -> new ExampleRecord(source.toString()); + ConverterFactory converterFactory = (targetType) -> converter; + ApplicationConversionService.addBean(conversionService, converterFactory, + ResolvableType.forClassWithGenerics(ConverterFactory.class, CharSequence.class, ExampleRecord.class)); + assertThat(conversionService.convert("test", ExampleRecord.class)).isEqualTo(new ExampleRecord("test")); + assertThat(conversionService.convert(new StringBuilder("test"), ExampleRecord.class)) + .isEqualTo(new ExampleRecord("test")); + assertThatExceptionOfType(ConverterNotFoundException.class) + .isThrownBy(() -> conversionService.convert("test", OtherRecord.class)); + assertThatIllegalArgumentException().isThrownBy(() -> conversionService.addConverterFactory(converterFactory)) + .withMessageContaining("Unable to determine"); + } + private void assertUnmodifiableExceptionThrown(ThrowingCallable throwingCallable) { assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(throwingCallable) .withMessage("This ApplicationConversionService cannot be modified"); @@ -191,4 +375,51 @@ class ApplicationConversionServiceTests { } + @Configuration + static class ConverterBeanMethodConfiguration { + + @Bean + Converter converter() { + return Integer::valueOf; + } + + } + + @Configuration + static class PrinterBeanMethodConfiguration { + + @Bean + Printer printer() { + return (object, locale) -> object.toString(); + } + + } + + @Configuration + static class ParserBeanMethodConfiguration { + + @Bean + Parser parser() { + return (text, locale) -> Integer.valueOf(text); + } + + } + + record ExampleRecord(String value) { + + @Override + public final String toString() { + return value(); + } + + } + + record OtherRecord(String value) { + + } + + interface ConditionalConverterConverter extends Converter, ConditionalConverter { + + } + }