From e69a1d22f97f2907260cd9b992eb113700abf193 Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Fri, 23 Jun 2023 16:47:05 +0200 Subject: [PATCH] Add MultipartFile support to HTTP interface client See gh-30728 --- .../invoker/HttpServiceProxyFactory.java | 1 + .../MultipartFileArgumentResolver.java | 70 ++++++++++++ .../MultipartFileArgumentResolverTests.java | 101 ++++++++++++++++++ .../WebClientHttpServiceProxyTests.java | 29 ++++- 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 spring-web/src/main/java/org/springframework/web/service/invoker/MultipartFileArgumentResolver.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/invoker/MultipartFileArgumentResolverTests.java diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java index 5d9a2ac81b9c..3da8067543c9 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java @@ -252,6 +252,7 @@ private List initArgumentResolvers() { // Specific type resolvers.add(new UrlArgumentResolver()); resolvers.add(new HttpMethodArgumentResolver()); + resolvers.add(new MultipartFileArgumentResolver()); return resolvers; } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/MultipartFileArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/MultipartFileArgumentResolver.java new file mode 100644 index 000000000000..68c2d427baa0 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/MultipartFileArgumentResolver.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2023 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.web.service.invoker; + +import java.util.Optional; + +import org.springframework.core.MethodParameter; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; +import org.springframework.web.multipart.MultipartFile; + +/** + * {@link HttpServiceArgumentResolver} for arguments of type {@link MultipartFile}. + * The arguments should not be annotated. To allow for non-required arguments, + * the {@link MultipartFile} parameters can also be wrapped with {@link Optional}. + * + * @author Olga Maciaszek-Sharma + * @since 6.1 + */ +public class MultipartFileArgumentResolver extends AbstractNamedValueArgumentResolver { + + private static final String MULTIPART_FILE_LABEL = "multipart file"; + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + if (!parameter.nestedIfOptional().getNestedParameterType().equals(MultipartFile.class)) { + return null; + } + return new NamedValueInfo("", true, null, MULTIPART_FILE_LABEL, true); + + } + + @Override + protected void addRequestValue(String name, Object value, MethodParameter parameter, + HttpRequestValues.Builder requestValues) { + Assert.state(value instanceof MultipartFile, + "The value has to be of type 'MultipartFile'"); + + MultipartFile file = (MultipartFile) value; + requestValues.addRequestPart(name, toHttpEntity(name, file)); + } + + private HttpEntity toHttpEntity(String name, MultipartFile file) { + HttpHeaders headers = new HttpHeaders(); + if (file.getOriginalFilename() != null) { + headers.setContentDispositionFormData(name, file.getOriginalFilename()); + } + if (file.getContentType() != null) { + headers.add(HttpHeaders.CONTENT_TYPE, file.getContentType()); + } + return new HttpEntity<>(file.getResource(), headers); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/MultipartFileArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/MultipartFileArgumentResolverTests.java new file mode 100644 index 000000000000..0a9abe8e1e40 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/MultipartFileArgumentResolverTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-2023 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.web.service.invoker; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.testfixture.servlet.MockMultipartFile; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link MultipartFileArgumentResolver}. + * Tests for base class functionality of this resolver can be found in {@link NamedValueArgumentResolverTests}. + * + * @author Olga Maciaszek-Sharma + */ +@SuppressWarnings("unchecked") +class MultipartFileArgumentResolverTests { + + private final TestHttpClientAdapter clientAdapter = new TestHttpClientAdapter(); + + private TestClient client; + + @BeforeEach + void setUp() { + HttpServiceProxyFactory proxyFactory = HttpServiceProxyFactory.builder(this.clientAdapter).build(); + this.client = proxyFactory.createClient(TestClient.class); + } + + @Test + void multipartFile() { + String fileName = "testFileName"; + String originalFileName = "originalTestFileName"; + MultipartFile testFile = new MockMultipartFile(fileName, originalFileName, + MediaType.APPLICATION_JSON_VALUE, "test".getBytes()); + this.client.postMultipartFile(testFile); + + Object body = clientAdapter.getRequestValues().getBodyValue(); + + assertThat(body).isInstanceOf(MultiValueMap.class); + MultiValueMap> map = (MultiValueMap>) body; + assertThat(map.size()).isEqualTo(1); + assertThat(map.getFirst("file")).isNotNull(); + HttpEntity fileEntity = map.getFirst("file"); + assertThat(fileEntity.getBody()).isEqualTo(testFile.getResource()); + HttpHeaders headers = fileEntity.getHeaders(); + assertThat(headers.getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + ContentDisposition contentDisposition = headers.getContentDisposition(); + assertThat(contentDisposition.getType()).isEqualTo("form-data"); + assertThat(contentDisposition.getName()).isEqualTo("file"); + assertThat(contentDisposition.getFilename()).isEqualTo(originalFileName); + } + + @Test + void optionalMultipartFile() { + this.client.postOptionalMultipartFile(Optional.empty(), "anotherPart"); + + Object body = clientAdapter.getRequestValues().getBodyValue(); + + assertThat(body).isInstanceOf(MultiValueMap.class); + MultiValueMap> map = (MultiValueMap>) body; + assertThat(map.size()).isEqualTo(1); + assertThat(map.getFirst("anotherPart")).isNotNull(); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private interface TestClient { + + @PostExchange + void postMultipartFile(MultipartFile file); + + @PostExchange + void postOptionalMultipartFile(Optional file, @RequestPart String anotherPart); + + } +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyTests.java index 80d490f17e98..a3b841dcaa2a 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -33,16 +33,20 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import org.springframework.http.MediaType; import org.springframework.lang.Nullable; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.PostExchange; import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import org.springframework.web.testfixture.servlet.MockMultipartFile; import static org.assertj.core.api.Assertions.assertThat; @@ -52,6 +56,7 @@ * using {@link WebClient} and {@link MockWebServer}. * * @author Rossen Stoyanchev + * @author Olga Maciaszek-Sharma */ public class WebClientHttpServiceProxyTests { @@ -133,6 +138,25 @@ void formData() throws Exception { assertThat(request.getBody().readUtf8()).isEqualTo("param1=value+1¶m2=value+2"); } + @Test // gh-30342 + void multipart() throws InterruptedException { + prepareResponse(response -> response.setResponseCode(201)); + String fileName = "testFileName"; + String originalFileName = "originalTestFileName"; + MultipartFile file = new MockMultipartFile(fileName, originalFileName, + MediaType.APPLICATION_JSON_VALUE, "test".getBytes()); + + initHttpService().postMultipart(file, "test2"); + + RecordedRequest request = this.server.takeRequest(); + assertThat(request.getHeaders().get("Content-Type")).startsWith("multipart/form-data;boundary="); + assertThat(request.getBody().readUtf8()) + .containsSubsequence("Content-Disposition: form-data; name=\"file\"; filename=\"originalTestFileName\"", + "Content-Type: application/json", "Content-Length: 4", "test", + "Content-Disposition: form-data; name=\"anotherPart\"", + "Content-Type: text/plain;charset=UTF-8", "Content-Length: 5", "test2"); + } + private TestHttpService initHttpService() { WebClient webClient = WebClient.builder().baseUrl(this.server.url("/").toString()).build(); return initHttpService(webClient); @@ -166,6 +190,9 @@ private interface TestHttpService { @PostExchange(contentType = "application/x-www-form-urlencoded") void postForm(@RequestParam MultiValueMap params); + @PostExchange(contentType = MediaType.MULTIPART_FORM_DATA_VALUE) + void postMultipart(MultipartFile file, @RequestPart String anotherPart); + } }