Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -102,8 +103,9 @@ public class FeignClientsConfiguration {
@ConditionalOnMissingBean
public FeignHttpMessageConverters feignHttpMessageConverters(
ObjectProvider<ClientHttpMessageConvertersCustomizer> customizers,
ObjectProvider<HttpMessageConverterCustomizer> cloudCustomizers) {
return new FeignHttpMessageConverters(customizers, cloudCustomizers);
ObjectProvider<HttpMessageConverterCustomizer> cloudCustomizers, BeanFactory beanFactory) {
return new FeignHttpMessageConverters(customizers, cloudCustomizers,
FeignHttpMessageConverters.jsonMapperProvider(beanFactory));
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
* <p>
* 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<ClientHttpMessageConvertersCustomizer> customizers;

private final ObjectProvider<HttpMessageConverterCustomizer> cloudCustomizers;

private final ObjectProvider<JsonMapper> jsonMapper;

private volatile List<HttpMessageConverter<?>> 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<ClientHttpMessageConvertersCustomizer> customizers,
ObjectProvider<HttpMessageConverterCustomizer> 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<ClientHttpMessageConvertersCustomizer> customizers,
ObjectProvider<HttpMessageConverterCustomizer> cloudCustomizers, ObjectProvider<JsonMapper> jsonMapper) {
this.customizers = customizers;
this.cloudCustomizers = cloudCustomizers;
this.jsonMapper = jsonMapper;
}

public List<HttpMessageConverter<?>> getConverters() {
Expand All @@ -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));
Expand All @@ -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<JsonMapper> 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<JsonMapper>) 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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<ClientHttpMessageConvertersCustomizer> customizers = mock(ObjectProvider.class);
when(customizers.orderedStream()).thenReturn(Stream.empty());
@SuppressWarnings("unchecked")
ObjectProvider<HttpMessageConverterCustomizer> cloudCustomizers = mock(ObjectProvider.class);
when(cloudCustomizers.iterator()).thenReturn(Collections.emptyIterator());
ObjectProvider<JsonMapper> 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) {
Expand All @@ -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<AbstractAmount>() {
@Override
public AbstractAmount deserialize(tools.jackson.core.JsonParser p, DeserializationContext ctxt) {
return new SimpleAmount(p.getString());
}
});
context.addDeserializers(deserializers);
}

}

}