Support Spring formatting annotations for params (#48)

* Add factory for Param.Expander using ConversionService. Instances use ConversionService and passes annotations (through TypeDescriptor) - ConversionService can now pick up @DateTimeFormat and @NumberFormat and convert the params applying those.
This commit is contained in:
Halvdan Hoem Grelland
2018-08-15 13:19:24 +02:00
committed by Ryan Baxter
parent 7148cce790
commit 52bea35160
3 changed files with 164 additions and 7 deletions

View File

@@ -18,6 +18,7 @@ package org.springframework.cloud.openfeign.support;
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.cloud.openfeign.annotation.RequestParamParameterProce
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 @@ import feign.Param;
/**
* @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<Class<? extends Annotation>, AnnotatedParameterProcessor> annotatedArgumentProcessors;
private final Map<String, Method> 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 class SpringMvcContract extends Contract.BaseContract
}
this.annotatedArgumentProcessors = toAnnotatedArgumentProcessorMap(processors);
this.conversionService = conversionService;
this.expander = new ConvertingExpander(conversionService);
this.convertingExpanderFactory = new ConvertingExpanderFactory(conversionService);
}
@Override
@@ -239,14 +248,40 @@ public class SpringMvcContract extends Contract.BaseContract
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 class SpringMvcContract extends Contract.BaseContract
}
}
/**
* @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 class SpringMvcContract extends Contract.BaseContract
}
}
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;
};
}
}
}

View File

@@ -18,12 +18,24 @@ package org.springframework.cloud.openfeign.support;
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 @@ import feign.MethodMetadata;
/**
* @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 class SpringMvcContractTests {
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 @@ public class SpringMvcContractTests {
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 {

View File

@@ -20,6 +20,7 @@ import java.lang.reflect.InvocationHandler;
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;
@@ -53,6 +54,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
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;
@@ -95,6 +97,7 @@ import rx.Single;
* @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 = {
@@ -189,6 +192,11 @@ public class FeignClientTests {
@RequestMapping(method = RequestMethod.GET, path = "/helloparams")
List<String> getParams(@RequestParam("params") List<String> params);
@RequestMapping(method = RequestMethod.GET, path = "/formattedparams")
List<LocalDate> getFormattedParams(
@RequestParam("params")
@DateTimeFormat(pattern = "dd-MM-yyyy") List<LocalDate> params);
@RequestMapping(method = RequestMethod.GET, path = "/hellos")
HystrixCommand<List<Hello>> getHellosHystrix();
@@ -444,6 +452,13 @@ public class FeignClientTests {
return params;
}
@RequestMapping(method = RequestMethod.GET, path = "/formattedparams")
public List<LocalDate> getFormattedParams(
@RequestParam("params")
@DateTimeFormat(pattern = "dd-MM-yyyy") List<LocalDate> params) {
return params;
}
@RequestMapping(method = RequestMethod.GET, path = "/noContent")
ResponseEntity<Void> noContent() {
return ResponseEntity.noContent().build();
@@ -586,6 +601,15 @@ public class FeignClientTests {
assertEquals("params size was wrong", list.size(), params.size());
}
@Test
public void testFormattedParams() {
List<LocalDate> list = Arrays.asList(
LocalDate.of(2001, 1, 1), LocalDate.of(2018, 6, 10));
List<LocalDate> params = this.testClient.getFormattedParams(list);
assertNotNull("params was null", params);
assertEquals("params not converted correctly", list, params);
}
@Test
public void testHystrixCommand() throws NoSuchMethodException {
HystrixCommand<List<Hello>> command = this.testClient.getHellosHystrix();