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 e01d709fe..af37d4498 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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2019 the original author or authors. + * Copyright 2013-2020 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. @@ -27,10 +27,13 @@ import feign.Retryer; import feign.codec.Decoder; import feign.codec.Encoder; +import feign.form.MultipartFormContentProcessor; +import feign.form.spring.SpringFormEncoder; import feign.hystrix.HystrixFeign; import feign.optionals.OptionalDecoder; import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -38,6 +41,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.data.web.SpringDataWebProperties; import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.support.AbstractFormWriter; import org.springframework.cloud.openfeign.support.PageJacksonModule; import org.springframework.cloud.openfeign.support.PageableSpringEncoder; import org.springframework.cloud.openfeign.support.ResponseEntityDecoder; @@ -51,9 +55,12 @@ import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.format.support.FormattingConversionService; +import static feign.form.ContentType.MULTIPART; + /** * @author Dave Syer * @author Venil Noronha + * @author Darren Foong */ @Configuration(proxyBeanMethods = false) public class FeignClientsConfiguration { @@ -83,16 +90,18 @@ public Decoder feignDecoder() { @Bean @ConditionalOnMissingBean @ConditionalOnMissingClass("org.springframework.data.domain.Pageable") - public Encoder feignEncoder() { - return new SpringEncoder(this.messageConverters); + public Encoder feignEncoder(ObjectProvider formWriterProvider) { + return springEncoder(formWriterProvider); } @Bean @ConditionalOnClass(name = "org.springframework.data.domain.Pageable") @ConditionalOnMissingBean - public Encoder feignEncoderPageable() { + public Encoder feignEncoderPageable( + ObjectProvider formWriterProvider) { PageableSpringEncoder encoder = new PageableSpringEncoder( - new SpringEncoder(this.messageConverters)); + springEncoder(formWriterProvider)); + if (springDataWebProperties != null) { encoder.setPageParameter( springDataWebProperties.getPageable().getPageParameter()); @@ -144,6 +153,18 @@ public Module pageJacksonModule() { return new PageJacksonModule(); } + private Encoder springEncoder(ObjectProvider formWriterProvider) { + AbstractFormWriter formWriter = formWriterProvider.getIfAvailable(); + + if (formWriter != null) { + return new SpringEncoder(new SpringPojoFormEncoder(formWriter), + this.messageConverters); + } + else { + return new SpringEncoder(new SpringFormEncoder(), this.messageConverters); + } + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass({ HystrixCommand.class, HystrixFeign.class }) protected static class HystrixFeignConfiguration { @@ -158,4 +179,16 @@ public Feign.Builder feignHystrixBuilder() { } + private class SpringPojoFormEncoder extends SpringFormEncoder { + + SpringPojoFormEncoder(AbstractFormWriter formWriter) { + super(); + + MultipartFormContentProcessor processor = (MultipartFormContentProcessor) getContentProcessor( + MULTIPART); + processor.addFirstWriter(formWriter); + } + + } + } diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/AbstractFormWriter.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/AbstractFormWriter.java new file mode 100644 index 000000000..1be7ad791 --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/AbstractFormWriter.java @@ -0,0 +1,83 @@ +/* + * Copyright 2013-2020 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.openfeign.support; + +import java.io.IOException; +import java.util.Iterator; +import java.util.function.Predicate; + +import feign.codec.EncodeException; +import feign.form.multipart.AbstractWriter; +import feign.form.multipart.Output; +import feign.form.util.PojoUtil; + +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +import static feign.form.ContentProcessor.CRLF; +import static feign.form.util.PojoUtil.isUserPojo; + +/** + * @author Darren Foong + */ +public abstract class AbstractFormWriter extends AbstractWriter { + + @Override + public boolean isApplicable(Object object) { + return !isTypeOrCollection(object, o -> o instanceof MultipartFile) + && isTypeOrCollection(object, PojoUtil::isUserPojo); + } + + @Override + public void write(Output output, String key, Object object) throws EncodeException { + try { + String string = new StringBuilder() + .append("Content-Disposition: form-data; name=\"").append(key) + .append('"').append(CRLF).append("Content-Type: ") + .append(getContentType()).append("; charset=") + .append(output.getCharset().name()).append(CRLF).append(CRLF) + .append(writeAsString(object)).toString(); + + output.write(string); + } + catch (IOException e) { + throw new EncodeException(e.getMessage()); + } + } + + protected abstract MediaType getContentType(); + + protected abstract String writeAsString(Object object) throws IOException; + + private boolean isTypeOrCollection(Object object, Predicate isType) { + if (object.getClass().isArray()) { + Object[] array = (Object[]) object; + + return array.length > 1 && isType.test(array[0]); + } + else if (object instanceof Iterable) { + Iterable iterable = (Iterable) object; + Iterator iterator = iterable.iterator(); + + return iterator.hasNext() && isType.test(iterator.next()); + } + else { + return isType.test(object); + } + } + +} diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/JsonFormWriter.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/JsonFormWriter.java new file mode 100644 index 000000000..82aa4be3e --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/JsonFormWriter.java @@ -0,0 +1,46 @@ +/* + * Copyright 2013-2020 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.openfeign.support; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +/** + * @author Darren Foong + */ +@Component +public class JsonFormWriter extends AbstractFormWriter { + + @Autowired + private ObjectMapper objectMapper; + + @Override + protected MediaType getContentType() { + return MediaType.APPLICATION_JSON; + } + + @Override + protected String writeAsString(Object object) throws IOException { + return objectMapper.writeValueAsString(object); + } + +} diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringEncoder.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringEncoder.java index dedee915c..ed99a2b9b 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringEncoder.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringEncoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2019 the original author or authors. + * Copyright 2013-2020 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. @@ -54,16 +54,24 @@ * @author Scien Jus * @author Ahmad Mozafarnia * @author Aaron Whiteside + * @author Darren Foong */ public class SpringEncoder implements Encoder { private static final Log log = LogFactory.getLog(SpringEncoder.class); - private final SpringFormEncoder springFormEncoder = new SpringFormEncoder(); + private final SpringFormEncoder springFormEncoder; private final ObjectFactory messageConverters; public SpringEncoder(ObjectFactory messageConverters) { + this.springFormEncoder = new SpringFormEncoder(); + this.messageConverters = messageConverters; + } + + public SpringEncoder(SpringFormEncoder springFormEncoder, + ObjectFactory messageConverters) { + this.springFormEncoder = springFormEncoder; this.messageConverters = messageConverters; } 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 02dcf8327..433c93358 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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2019 the original author or authors. + * Copyright 2013-2020 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. @@ -28,6 +28,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import feign.Contract; import feign.Feign; @@ -42,6 +43,7 @@ import org.springframework.cloud.openfeign.annotation.RequestHeaderParameterProcessor; import org.springframework.cloud.openfeign.annotation.RequestParamParameterProcessor; import org.springframework.cloud.openfeign.annotation.RequestPartParameterProcessor; +import org.springframework.cloud.openfeign.encoding.HttpEncoding; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.ResourceLoaderAware; import org.springframework.core.DefaultParameterNameDiscoverer; @@ -54,6 +56,7 @@ import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.ResourceLoader; +import org.springframework.http.MediaType; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestMapping; @@ -72,6 +75,7 @@ * @author Olga Maciaszek-Sharma * @author Aaron Whiteside * @author Artyom Romanenko + * @author Darren Foong */ public class SpringMvcContract extends Contract.BaseContract implements ResourceLoaderAware { @@ -291,7 +295,8 @@ protected boolean processAnnotationsOnParameter(MethodMetadata data, } } - if (isHttpAnnotation && data.indexToExpander().get(paramIndex) == null) { + if (!isMultipartFormData(data) && isHttpAnnotation + && data.indexToExpander().get(paramIndex) == null) { TypeDescriptor typeDescriptor = createTypeDescriptor(method, paramIndex); if (this.conversionService.canConvert(typeDescriptor, STRING_TYPE_DESCRIPTOR)) { @@ -388,6 +393,18 @@ private boolean shouldAddParameterName(int parameterIndex, Type[] parameterTypes && parameterTypes != null && parameterTypes.length > parameterIndex; } + private boolean isMultipartFormData(MethodMetadata data) { + Collection contentTypes = data.template().headers() + .get(HttpEncoding.CONTENT_TYPE); + + if (contentTypes != null && !contentTypes.isEmpty()) { + String type = contentTypes.iterator().next(); + return Objects.equals(MediaType.valueOf(type), MediaType.MULTIPART_FORM_DATA); + } + + return false; + } + /** * @deprecated Not used internally anymore. Will be removed in the future. */ 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 0dd60ad51..86ef5f502 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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2019 the original author or authors. + * Copyright 2013-2020 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. @@ -74,7 +74,9 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignFormatterRegistrar; import org.springframework.cloud.openfeign.ribbon.LoadBalancerFeignClient; +import org.springframework.cloud.openfeign.support.AbstractFormWriter; import org.springframework.cloud.openfeign.support.FallbackCommand; +import org.springframework.cloud.openfeign.support.JsonFormWriter; import org.springframework.cloud.openfeign.test.NoSecurityConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -107,6 +109,7 @@ * @author Erik Kringen * @author Halvdan Hoem Grelland * @author Aaron Whiteside + * @author Darren Foong */ @RunWith(SpringJUnit4ClassRunner.class) @SpringBootTest(classes = FeignClientTests.Application.class, @@ -450,6 +453,12 @@ public void testSingleRequestPart() { assertThat(response).isEqualTo("abc"); } + @Test + public void testSinglePojoRequestPart() { + String response = this.multipartClient.singlePojoPart(new Hello(HELLO_WORLD_1)); + assertThat(response).isEqualTo(HELLO_WORLD_1); + } + @Test public void testMultipleRequestParts() { MockMultipartFile file = new MockMultipartFile("file", "hello.bin", null, @@ -458,6 +467,17 @@ public void testMultipleRequestParts() { assertThat(response).isEqualTo("abc123hello.bin"); } + @Test + public void testMultiplePojoRequestParts() { + Hello pojo1 = new Hello(HELLO_WORLD_1); + Hello pojo2 = new Hello(OI_TERRA_2); + MockMultipartFile file = new MockMultipartFile("file", "hello.bin", null, + "hello".getBytes()); + String response = this.multipartClient.multipartPojo("abc", "123", pojo1, pojo2, + file); + assertThat(response).isEqualTo("abc123hello world 1oi terra 2hello.bin"); + } + @Test public void testRequestPartWithListOfMultipartFiles() { List multipartFiles = Arrays.asList( @@ -471,6 +491,20 @@ public void testRequestPartWithListOfMultipartFiles() { assertThat(fileNames).contains("hello1.bin", "hello2.bin"); } + @Test + public void testRequestPartWithListOfPojosAndListOfMultipartFiles() { + Hello pojo1 = new Hello(HELLO_WORLD_1); + Hello pojo2 = new Hello(OI_TERRA_2); + MockMultipartFile file1 = new MockMultipartFile("file1", "hello1.bin", null, + "hello".getBytes()); + MockMultipartFile file2 = new MockMultipartFile("file2", "hello2.bin", null, + "hello".getBytes()); + String response = this.multipartClient + .requestPartListOfPojosAndListOfMultipartFiles( + Arrays.asList(pojo1, pojo2), Arrays.asList(file1, file2)); + assertThat(response).isEqualTo("hello world 1oi terra 2hello1.binhello2.bin"); + } + @Test public void testRequestBodyWithSingleMultipartFile() { String partName = UUID.randomUUID().toString(); @@ -671,6 +705,11 @@ protected interface MultipartClient { produces = MediaType.TEXT_PLAIN_VALUE) String singlePart(@RequestPart("hello") String hello); + @RequestMapping(method = RequestMethod.POST, path = "/singlePojoPart", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + String singlePojoPart(@RequestPart("hello") Hello hello); + @RequestMapping(method = RequestMethod.POST, path = "/multipart", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) @@ -678,6 +717,14 @@ String multipart(@RequestPart("hello") String hello, @RequestPart("world") String world, @RequestPart("file") MultipartFile file); + @RequestMapping(method = RequestMethod.POST, path = "/multipartPojo", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + String multipartPojo(@RequestPart("hello") String hello, + @RequestPart("world") String world, @RequestPart("pojo1") Hello pojo1, + @RequestPart("pojo2") Hello pojo2, + @RequestPart("file") MultipartFile file); + @RequestMapping(method = RequestMethod.POST, path = "/multipartNames", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) @@ -690,6 +737,13 @@ String requestPartListOfMultipartFilesReturnsPartNames( String requestPartListOfMultipartFilesReturnsFileNames( @RequestPart("files") List files); + @RequestMapping(method = RequestMethod.POST, path = "/multipartPojosFiles", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + String requestPartListOfPojosAndListOfMultipartFiles( + @RequestPart("pojos") List pojos, + @RequestPart("files") List files); + @RequestMapping(method = RequestMethod.POST, path = "/multipartNames", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) @@ -924,6 +978,11 @@ public OtherArg parse(String text, Locale locale) }; } + @Bean + public AbstractFormWriter jsonFormWriter() { + return new JsonFormWriter(); + } + @RequestMapping(method = RequestMethod.GET, path = "/hello") public Hello getHello() { return new Hello(HELLO_WORLD_1); @@ -1029,10 +1088,17 @@ Collection getToString(@RequestParam("arg") Collection args) { @RequestMapping(method = RequestMethod.POST, path = "/singlePart", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) - String multipart(@RequestPart("hello") String hello) { + String singlePart(@RequestPart("hello") String hello) { return hello; } + @RequestMapping(method = RequestMethod.POST, path = "/singlePojoPart", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + String singlePojoPart(@RequestPart("hello") Hello hello) { + return hello.getMessage(); + } + @RequestMapping(method = RequestMethod.POST, path = "/multipart", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) @@ -1042,6 +1108,17 @@ String multipart(@RequestPart("hello") String hello, return hello + world + file.getOriginalFilename(); } + @RequestMapping(method = RequestMethod.POST, path = "/multipartPojo", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + String multipartPojo(@RequestPart("hello") String hello, + @RequestPart("world") String world, @RequestPart("pojo1") Hello pojo1, + @RequestPart("pojo2") Hello pojo2, + @RequestPart("file") MultipartFile file) { + return hello + world + pojo1.getMessage() + pojo2.getMessage() + + file.getOriginalFilename(); + } + @RequestMapping(method = RequestMethod.POST, path = "/multipartNames", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) @@ -1058,6 +1135,25 @@ String multipartFilenames(HttpServletRequest request) throws Exception { .collect(Collectors.joining(",")); } + @RequestMapping(method = RequestMethod.POST, path = "/multipartPojosFiles", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + String requestPartListOfPojosAndListOfMultipartFiles( + @RequestPart("pojos") List pojos, + @RequestPart("files") List files) { + StringBuilder result = new StringBuilder(); + + for (Hello pojo : pojos) { + result.append(pojo.getMessage()); + } + + for (MultipartFile file : files) { + result.append(file.getOriginalFilename()); + } + + return result.toString(); + } + } public static class Hello {