From f7cbb937e006fda18e57224620b29d3d8f4aa81e Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:18:15 +0700 Subject: [PATCH] Fix Feign JSON converter to use application JsonMapper modules registerDefaults() may install a classpath-default Jackson mapper that does not include JacksonModule beans (e.g. JavaxMoneyModule). Wire the application JsonMapper into FeignHttpMessageConverters after Boot ClientHttpMessageConvertersCustomizers so Feign deserializes the same types as MVC. Fixes gh-1376 Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com> --- .../openfeign/FeignClientsConfiguration.java | 6 +- .../support/FeignHttpMessageConverters.java | 72 ++++++++++++ .../FeignHttpMessageConvertersTests.java | 110 ++++++++++++++++++ 3 files changed, 186 insertions(+), 2 deletions(-) diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClientsConfiguration.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClientsConfiguration.java index f85e59b30..47e84f219 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClientsConfiguration.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClientsConfiguration.java @@ -34,6 +34,7 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.observation.ObservationRegistry; +import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -102,8 +103,9 @@ public class FeignClientsConfiguration { @ConditionalOnMissingBean public FeignHttpMessageConverters feignHttpMessageConverters( ObjectProvider customizers, - ObjectProvider cloudCustomizers) { - return new FeignHttpMessageConverters(customizers, cloudCustomizers); + ObjectProvider cloudCustomizers, BeanFactory beanFactory) { + return new FeignHttpMessageConverters(customizers, cloudCustomizers, + FeignHttpMessageConverters.jsonMapperProvider(beanFactory)); } @Bean diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/FeignHttpMessageConverters.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/FeignHttpMessageConverters.java index e130a568e..de832218e 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/FeignHttpMessageConverters.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/FeignHttpMessageConverters.java @@ -19,29 +19,64 @@ import java.util.ArrayList; import java.util.List; +import tools.jackson.databind.json.JsonMapper; + import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.http.converter.autoconfigure.ClientHttpMessageConvertersCustomizer; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageConverters; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.util.ClassUtils; /** * Class that mimics {@link HttpMessageConverters} and the default implementation there. * Applies the {@link HttpMessageConverterCustomizer}s and gathers all the converters into * a {@link List}. + *

+ * When a {@link JsonMapper} is available (Boot's application mapper, including + * {@code JacksonModule} beans such as {@code JavaxMoneyModule}), it is applied as the + * JSON converter after {@link ClientHttpMessageConvertersCustomizer}s so Feign uses the + * same modules as MVC (gh-1376). + * + * @author seonwoo_jung + * @author Olga Maciaszek-Sharma */ public class FeignHttpMessageConverters { + private static final boolean JACKSON_JSON_MAPPER_PRESENT = ClassUtils + .isPresent("tools.jackson.databind.json.JsonMapper", FeignHttpMessageConverters.class.getClassLoader()); + private final ObjectProvider customizers; private final ObjectProvider cloudCustomizers; + private final ObjectProvider jsonMapper; + private volatile List> converters; + /** + * Create an instance without an explicit application {@link JsonMapper} provider. + * @param customizers Boot client HTTP message converter customizers + * @param cloudCustomizers OpenFeign HTTP message converter customizers + */ public FeignHttpMessageConverters(ObjectProvider customizers, ObjectProvider cloudCustomizers) { + this(customizers, cloudCustomizers, new EmptyObjectProvider<>()); + } + + /** + * Create an instance that wires the application {@link JsonMapper} into the JSON + * converter when present. + * @param customizers Boot client HTTP message converter customizers + * @param cloudCustomizers OpenFeign HTTP message converter customizers + * @param jsonMapper provider for the application {@link JsonMapper} + */ + public FeignHttpMessageConverters(ObjectProvider customizers, + ObjectProvider cloudCustomizers, ObjectProvider jsonMapper) { this.customizers = customizers; this.cloudCustomizers = cloudCustomizers; + this.jsonMapper = jsonMapper; } public List> getConverters() { @@ -60,6 +95,9 @@ private void initConvertersIfRequired() { // TODO: check if already added? Howto order? this.customizers.orderedStream().forEach(customizer -> customizer.customize(builder)); + // Prefer the application JsonMapper (with JacksonModule beans) over + // any classpath-default mapper from registerDefaults() — gh-1376. + applyApplicationJsonMapper(builder); HttpMessageConverters hmc = builder.build(); hmc.forEach(converter -> converters.add(converter)); cloudCustomizers.forEach(customizer -> customizer.accept(converters)); @@ -71,6 +109,40 @@ private void initConvertersIfRequired() { } } + private void applyApplicationJsonMapper(HttpMessageConverters.ClientBuilder builder) { + if (!JACKSON_JSON_MAPPER_PRESENT) { + return; + } + JsonMapper mapper = this.jsonMapper.getIfAvailable(); + if (mapper != null) { + builder.withJsonConverter(new JacksonJsonHttpMessageConverter(mapper)); + } + } + + /** + * Resolve an {@link ObjectProvider} for the application {@link JsonMapper} without + * requiring Jackson on the classpath of the calling configuration class. + * @param beanFactory the bean factory (typically the Feign child context, which + * parents the main application context) + * @return a provider for {@link JsonMapper}, or an empty provider if Jackson is + * absent + */ + @SuppressWarnings("unchecked") + public static ObjectProvider jsonMapperProvider( + org.springframework.beans.factory.BeanFactory beanFactory) { + if (!JACKSON_JSON_MAPPER_PRESENT) { + return new EmptyObjectProvider<>(); + } + try { + Class jsonMapperClass = ClassUtils.forName("tools.jackson.databind.json.JsonMapper", + FeignHttpMessageConverters.class.getClassLoader()); + return (ObjectProvider) beanFactory.getBeanProvider(jsonMapperClass); + } + catch (ClassNotFoundException ex) { + return new EmptyObjectProvider<>(); + } + } + private static boolean supportsMediaType(HttpMessageConverter converter, MediaType mediaType) { for (MediaType supportedMediaType : converter.getSupportedMediaTypes()) { if (supportedMediaType.equalsTypeAndSubtype(mediaType)) { diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/FeignHttpMessageConvertersTests.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/FeignHttpMessageConvertersTests.java index 8201ff663..3fd543f19 100644 --- a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/FeignHttpMessageConvertersTests.java +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/FeignHttpMessageConvertersTests.java @@ -16,6 +16,8 @@ package org.springframework.cloud.openfeign.support; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -25,10 +27,21 @@ import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import tools.jackson.core.Version; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleDeserializers; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.http.converter.autoconfigure.ClientHttpMessageConvertersCustomizer; +import org.springframework.cloud.loadbalancer.support.SimpleObjectProvider; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -97,6 +110,52 @@ void shouldNotExposePartiallyInitializedConvertersToConcurrentCallers() throws E assertThat(readerResult.get()).isSameAs(initializerResult.get()); } + @Test + // Issue: https://github.com/spring-cloud/spring-cloud-openfeign/issues/1376 + void shouldUseApplicationJsonMapperIncludingRegisteredModules() throws Exception { + JsonMapper applicationMapper = JsonMapper.builder().addModule(new AbstractAmountModule()).build(); + + @SuppressWarnings("unchecked") + ObjectProvider customizers = mock(ObjectProvider.class); + when(customizers.orderedStream()).thenReturn(Stream.empty()); + @SuppressWarnings("unchecked") + ObjectProvider cloudCustomizers = mock(ObjectProvider.class); + when(cloudCustomizers.iterator()).thenReturn(Collections.emptyIterator()); + ObjectProvider jsonMapper = new SimpleObjectProvider<>(applicationMapper); + + // registerDefaults alone would install a classpath-default mapper without our + // module; the application JsonMapper must win so module-backed types deserialize. + FeignHttpMessageConverters feignConverters = new FeignHttpMessageConverters(customizers, cloudCustomizers, + jsonMapper); + + JacksonJsonHttpMessageConverter jacksonConverter = feignConverters.getConverters() + .stream() + .filter(JacksonJsonHttpMessageConverter.class::isInstance) + .map(JacksonJsonHttpMessageConverter.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError("expected JacksonJsonHttpMessageConverter")); + + assertThat(jacksonConverter.getMapper()).isSameAs(applicationMapper); + + HttpInputMessage message = new HttpInputMessage() { + @Override + public java.io.InputStream getBody() { + return new ByteArrayInputStream("\"42.50 EUR\"".getBytes(StandardCharsets.UTF_8)); + } + + @Override + public HttpHeaders getHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + }; + + Object value = jacksonConverter.read(AbstractAmount.class, message); + assertThat(value).isInstanceOf(AbstractAmount.class); + assertThat(((AbstractAmount) value).value()).isEqualTo("42.50 EUR"); + } + private static void waitUntilBlockedOrFinished(Thread thread) { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); while (System.nanoTime() < deadline) { @@ -109,4 +168,55 @@ private static void waitUntilBlockedOrFinished(Thread thread) { } } + /** + * Stand-in for abstract money types (e.g. {@code MonetaryAmount}) that need a Jackson + * module to deserialize. + */ + abstract static class AbstractAmount { + + abstract String value(); + + } + + static final class SimpleAmount extends AbstractAmount { + + private final String value; + + SimpleAmount(String value) { + this.value = value; + } + + @Override + String value() { + return this.value; + } + + } + + static final class AbstractAmountModule extends JacksonModule { + + @Override + public String getModuleName() { + return "AbstractAmountModule"; + } + + @Override + public Version version() { + return new Version(1, 0, 0, null, null, null); + } + + @Override + public void setupModule(SetupContext context) { + SimpleDeserializers deserializers = new SimpleDeserializers(); + deserializers.addDeserializer(AbstractAmount.class, new ValueDeserializer() { + @Override + public AbstractAmount deserialize(tools.jackson.core.JsonParser p, DeserializationContext ctxt) { + return new SimpleAmount(p.getString()); + } + }); + context.addDeserializers(deserializers); + } + + } + }