diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java index 670b23722..e7d9c050c 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java @@ -18,6 +18,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Method; +import java.lang.reflect.Parameter; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; @@ -35,9 +36,11 @@ import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.ResourceLoaderAware; import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.ResourceLoader; @@ -58,6 +61,7 @@ /** * @author Spencer Gibb * @author Abhijit Sarkar + * @author Halvdan Hoem Grelland */ public class SpringMvcContract extends Contract.BaseContract implements ResourceLoaderAware { @@ -66,13 +70,18 @@ public class SpringMvcContract extends Contract.BaseContract private static final String CONTENT_TYPE = "Content-Type"; + private static final TypeDescriptor STRING_TYPE_DESCRIPTOR = + TypeDescriptor.valueOf(String.class); + private static final TypeDescriptor ITERABLE_TYPE_DESCRIPTOR = + TypeDescriptor.valueOf(Iterable.class); + private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); private final Map, AnnotatedParameterProcessor> annotatedArgumentProcessors; private final Map processedMethods = new HashMap<>(); private final ConversionService conversionService; - private final Param.Expander expander; + private final ConvertingExpanderFactory convertingExpanderFactory; private ResourceLoader resourceLoader = new DefaultResourceLoader(); public SpringMvcContract() { @@ -100,7 +109,7 @@ public SpringMvcContract( } this.annotatedArgumentProcessors = toAnnotatedArgumentProcessorMap(processors); this.conversionService = conversionService; - this.expander = new ConvertingExpander(conversionService); + this.convertingExpanderFactory = new ConvertingExpanderFactory(conversionService); } @Override @@ -239,14 +248,40 @@ protected boolean processAnnotationsOnParameter(MethodMetadata data, processParameterAnnotation, method); } } - if (isHttpAnnotation && data.indexToExpander().get(paramIndex) == null - && this.conversionService.canConvert( - method.getParameterTypes()[paramIndex], String.class)) { - data.indexToExpander().put(paramIndex, this.expander); + + if (isHttpAnnotation && data.indexToExpander().get(paramIndex) == null) { + TypeDescriptor typeDescriptor = createTypeDescriptor(method, paramIndex); + if (conversionService.canConvert(typeDescriptor, STRING_TYPE_DESCRIPTOR)) { + Param.Expander expander = + convertingExpanderFactory.getExpander(typeDescriptor); + if (expander != null) { + data.indexToExpander().put(paramIndex, expander); + } + } } return isHttpAnnotation; } + private static TypeDescriptor createTypeDescriptor(Method method, int paramIndex) { + Parameter parameter = method.getParameters()[paramIndex]; + MethodParameter methodParameter = MethodParameter.forParameter(parameter); + TypeDescriptor typeDescriptor = new TypeDescriptor(methodParameter); + + // Feign applies the Param.Expander to each element of an Iterable, so in those + // cases we need to provide a TypeDescriptor of the element. + if (typeDescriptor.isAssignableTo(ITERABLE_TYPE_DESCRIPTOR)) { + TypeDescriptor elementTypeDescriptor = + typeDescriptor.getElementTypeDescriptor(); + + checkState(elementTypeDescriptor != null, + "Could not resolve element type of Iterable type %s. Not declared?", + typeDescriptor); + + typeDescriptor = elementTypeDescriptor; + } + return typeDescriptor; + } + private void parseProduces(MethodMetadata md, Method method, RequestMapping annotation) { String[] serverProduces = annotation.produces(); @@ -361,6 +396,10 @@ public Collection setTemplateParameter(String name, } } + /** + * @deprecated Not used internally anymore. Will be removed in the future. + */ + @Deprecated public static class ConvertingExpander implements Param.Expander { private final ConversionService conversionService; @@ -375,4 +414,21 @@ public String expand(Object value) { } } + + private static class ConvertingExpanderFactory { + + private final ConversionService conversionService; + + ConvertingExpanderFactory(ConversionService conversionService) { + this.conversionService = conversionService; + } + + Param.Expander getExpander(TypeDescriptor typeDescriptor) { + return value -> { + Object converted = this.conversionService.convert( + value, typeDescriptor, STRING_TYPE_DESCRIPTOR); + return (String) converted; + }; + } + } } diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/SpringMvcContractTests.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/SpringMvcContractTests.java index ab82643e3..ac21d65f6 100644 --- a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/SpringMvcContractTests.java +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/support/SpringMvcContractTests.java @@ -18,12 +18,24 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.Collection; +import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Map; +import feign.Param; import org.junit.Before; import org.junit.Test; + +import org.springframework.core.convert.ConversionService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.NumberFormat; +import org.springframework.format.number.NumberStyleFormatter; +import org.springframework.format.support.FormattingConversionServiceFactoryBean; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.MultiValueMap; @@ -48,6 +60,7 @@ /** * @author chadjaros + * @author Halvdan Hoem Grelland */ public class SpringMvcContractTests { private static final Class EXECUTABLE_TYPE; @@ -67,7 +80,12 @@ public class SpringMvcContractTests { @Before public void setup() { - this.contract = new SpringMvcContract(); + FormattingConversionServiceFactoryBean conversionServiceFactoryBean + = new FormattingConversionServiceFactoryBean(); + conversionServiceFactoryBean.afterPropertiesSet(); + ConversionService conversionService = conversionServiceFactoryBean.getObject(); + + this.contract = new SpringMvcContract(Collections.emptyList(), conversionService); } @Test @@ -255,6 +273,47 @@ public void testProcessAnnotations_Aliased() throws Exception { data.template().queries().get("amount").iterator().next()); } + @Test + public void testProcessAnnotations_DateTimeFormatParam() throws Exception { + Method method = TestTemplate_DateTimeFormatParameter.class.getDeclaredMethod( + "getTest", LocalDateTime.class); + MethodMetadata data = this.contract + .parseAndValidateMetadata(method.getDeclaringClass(), method); + + Param.Expander expander = data.indexToExpander().get(0); + assertNotNull(expander); + + LocalDateTime input = LocalDateTime.of(2001, 10, 12, 23, 56, 3); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern( + TestTemplate_DateTimeFormatParameter.CUSTOM_PATTERN); + + String expected = formatter.format(input); + + assertEquals(expected, expander.expand(input)); + } + + @Test + public void testProcessAnnotations_NumberFormatParam() throws Exception { + Method method = TestTemplate_NumberFormatParameter.class.getDeclaredMethod( + "getTest", BigDecimal.class); + MethodMetadata data = this.contract + .parseAndValidateMetadata(method.getDeclaringClass(), method); + + Param.Expander expander = data.indexToExpander().get(0); + assertNotNull(expander); + + NumberStyleFormatter formatter = new NumberStyleFormatter( + TestTemplate_NumberFormatParameter.CUSTOM_PATTERN); + + BigDecimal input = BigDecimal.valueOf(1220.345); + + String expected = formatter.print(input, Locale.getDefault()); + String actual = expander.expand(input); + + assertEquals(expected, actual); + } + @Test public void testProcessAnnotations_Advanced2() throws Exception { Method method = TestTemplate_Advanced.class.getDeclaredMethod("getTest"); @@ -539,6 +598,24 @@ ResponseEntity getTestFallback(@RequestHeader String Authorization, TestObject getTest(); } + public interface TestTemplate_DateTimeFormatParameter { + + String CUSTOM_PATTERN = "dd-MM-yyyy HH:mm"; + + @RequestMapping(method = RequestMethod.GET) + String getTest(@RequestParam(name = "localDateTime") + @DateTimeFormat(pattern = CUSTOM_PATTERN) LocalDateTime localDateTime); + } + + public interface TestTemplate_NumberFormatParameter { + + String CUSTOM_PATTERN = "$###,###.###"; + + @RequestMapping(method = RequestMethod.GET) + String getTest(@RequestParam("amount") + @NumberFormat(pattern = CUSTOM_PATTERN) BigDecimal amount); + } + @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, setterVisibility = JsonAutoDetect.Visibility.NONE) public class TestObject { diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/valid/FeignClientTests.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/valid/FeignClientTests.java index d4c55b2bf..1e8ce9e77 100644 --- a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/valid/FeignClientTests.java +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/valid/FeignClientTests.java @@ -20,6 +20,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.text.ParseException; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -51,6 +52,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.format.Formatter; import org.springframework.format.FormatterRegistry; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpEntity; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -93,6 +95,7 @@ * @author Spencer Gibb * @author Jakub Narloch * @author Erik Kringen + * @author Halvdan Hoem Grelland */ @RunWith(SpringJUnit4ClassRunner.class) @SpringBootTest(classes = FeignClientTests.Application.class, webEnvironment = WebEnvironment.RANDOM_PORT, value = { @@ -187,6 +190,11 @@ protected interface TestClient { @RequestMapping(method = RequestMethod.GET, path = "/helloparams") List getParams(@RequestParam("params") List params); + @RequestMapping(method = RequestMethod.GET, path = "/formattedparams") + List getFormattedParams( + @RequestParam("params") + @DateTimeFormat(pattern = "dd-MM-yyyy") List params); + @RequestMapping(method = RequestMethod.GET, path = "/hellos") HystrixCommand> getHellosHystrix(); @@ -441,6 +449,13 @@ public List getParams(@RequestParam("params") List params) { return params; } + @RequestMapping(method = RequestMethod.GET, path = "/formattedparams") + public List getFormattedParams( + @RequestParam("params") + @DateTimeFormat(pattern = "dd-MM-yyyy") List params) { + return params; + } + @RequestMapping(method = RequestMethod.GET, path = "/noContent") ResponseEntity noContent() { return ResponseEntity.noContent().build(); @@ -583,6 +598,15 @@ public void testParams() { assertEquals("params size was wrong", list.size(), params.size()); } + @Test + public void testFormattedParams() { + List list = Arrays.asList( + LocalDate.of(2001, 1, 1), LocalDate.of(2018, 6, 10)); + List params = this.testClient.getFormattedParams(list); + assertNotNull("params was null", params); + assertEquals("params not converted correctly", list, params); + } + @Test public void testHystrixCommand() throws NoSuchMethodException { HystrixCommand> command = this.testClient.getHellosHystrix();