From aac521c116d1745de6eb3648bb3286f59d3070f5 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 26 Mar 2026 15:10:01 +0100 Subject: [PATCH] Add integration tests and docs for multipart support This commit adds integration tests and reference documentation for multipart support in `RestClient` and `RestTestClient`. Closes gh-35569 Closes gh-33263 --- .../ROOT/pages/integration/rest-clients.adoc | 50 ++++++++++++++++- .../ROOT/pages/testing/resttestclient.adoc | 13 +++++ .../multipart/MultipartTests.java | 54 +++++++++++++++++++ .../client/MockMvcRestTestClientTests.java | 48 ++++++++++++++++- .../client/RestClientIntegrationTests.java | 35 ++++++++++++ .../web/client/simple.multipart | 15 ++++++ 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 framework-docs/src/main/java/org/springframework/docs/testing/resttestclient/multipart/MultipartTests.java create mode 100644 spring-web/src/test/resources/org/springframework/web/client/simple.multipart diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index 52f1e25aa6f..327aa7076f0 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -451,11 +451,59 @@ In most cases, you do not have to specify the `Content-Type` for each part. The content type is determined automatically based on the `HttpMessageConverter` chosen to serialize it or, in the case of a `Resource`, based on the file extension. If necessary, you can explicitly provide the `MediaType` with an `HttpEntity` wrapper. -The `Content-Type` is set to `multipart/form-data` by the `MultiPartHttpMessageConverter`. +The `Content-Type` is set to `multipart/form-data` by the `MultipartHttpMessageConverter`. As seen in the previous section, `MultiValueMap` types can also be used for URL encoded forms. It is preferable to explicitly set the media type in the `Content-Type` or `Accept` HTTP request headers to ensure that the expected message converter is used. +`RestClient` can also receive multipart responses. +To decode a multipart response body, use a `ParameterizedTypeReference>`. +The decoded map contains `Part` instances where `FormFieldPart` represents form field values +and `FilePart` represents file parts with a `filename()` and a `transferTo()` method. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim"] +---- + MultiValueMap result = this.restClient.get() + .uri("https://example.com/upload") + .accept(MediaType.MULTIPART_FORM_DATA) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + Part field = result.getFirst("fieldPart"); + if (field instanceof FormFieldPart formField) { + String fieldValue = formField.value(); + } + Part file = result.getFirst("filePart"); + if (file instanceof FilePart filePart) { + filePart.transferTo(Path.of("/tmp/" + filePart.filename())); + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim"] +---- + val result = this.restClient.get() + .uri("https://example.com/upload") + .accept(MediaType.MULTIPART_FORM_DATA) + .retrieve() + .body(object : ParameterizedTypeReference>() {}) + + val field = result?.getFirst("fieldPart") + if (field is FormFieldPart) { + val fieldValue = field.value() + } + val file = result?.getFirst("filePart") + if (file is FilePart) { + file.transferTo(Path.of("/tmp/" + file.filename())) + } +---- +====== + [[rest-request-factories]] === Client Request Factories diff --git a/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc b/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc index 73244a574d5..61bc3eb6bde 100644 --- a/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc +++ b/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc @@ -142,6 +142,9 @@ provides two alternative ways to verify the response: 1. xref:resttestclient-workflow[Built-in Assertions] extend the request workflow with a chain of expectations 2. xref:resttestclient-assertj[AssertJ Integration] to verify the response via `assertThat()` statements +TIP: See the xref:integration/rest-clients.adoc#rest-message-conversion[HTTP Message Conversion] +section for examples on how to prepare a request with any content, including form data and multipart data. + [[resttestclient.workflow]] @@ -213,6 +216,16 @@ To verify JSON content with https://github.com/jayway/JsonPath[JSONPath]: include-code::./JsonTests[tag=jsonPath,indent=0] +[[resttestclient.multipart]] +==== Multipart Content + +When testing endpoints that return multipart responses, you can decode the body to a +`MultiValueMap` and assert individual parts using the `FormFieldPart` +and `FilePart` subtypes. + +include-code::./MultipartTests[tag=multipart,indent=0] + + [[resttestclient.assertj]] === AssertJ Integration diff --git a/framework-docs/src/main/java/org/springframework/docs/testing/resttestclient/multipart/MultipartTests.java b/framework-docs/src/main/java/org/springframework/docs/testing/resttestclient/multipart/MultipartTests.java new file mode 100644 index 00000000000..9f58cd698d5 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/testing/resttestclient/multipart/MultipartTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025-present 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.docs.testing.resttestclient.multipart; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.converter.multipart.FilePart; +import org.springframework.http.converter.multipart.FormFieldPart; +import org.springframework.http.converter.multipart.Part; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.util.MultiValueMap; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MultipartTests { + + RestTestClient client; + + @Test + void multipart() { + // tag::multipart[] + client.get().uri("/upload") + .accept(MediaType.MULTIPART_FORM_DATA) + .exchange() + .expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() {}) + .value(result -> { + Part field = result.getFirst("fieldPart"); + assertThat(field).isInstanceOfSatisfying(FormFieldPart.class, + formField -> assertThat(formField.value()).isEqualTo("fieldValue")); + Part file = result.getFirst("filePart"); + assertThat(file).isInstanceOfSatisfying(FilePart.class, + filePart -> assertThat(filePart.filename()).isEqualTo("logo.png")); + }); + // end::multipart[] + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcRestTestClientTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcRestTestClientTests.java index a3f01f5cef0..c1cc6aa0c4e 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcRestTestClientTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcRestTestClientTests.java @@ -22,19 +22,30 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.converter.multipart.FilePart; +import org.springframework.http.converter.multipart.FormFieldPart; +import org.springframework.http.converter.multipart.Part; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import static org.assertj.core.api.Assertions.assertThat; + /** * Tests that use a {@link RestTestClient} configured with a {@link MockMvc} instance * that uses a standalone controller. * * @author Rob Worsnop * @author Sam Brannen - * @since 7.0 + * @author Brian Clozel */ class MockMvcRestTestClientTests { @@ -75,6 +86,26 @@ class MockMvcRestTestClientTests { .isEqualTo("some really bad request"); } + @Test + void retrieveMultipart() { + client.get() + .uri("/multipart") + .accept(MediaType.MULTIPART_FORM_DATA) + .exchange() + .expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() {}) + .value(result -> { + assertThat(result).hasSize(3); + assertThat(result).containsKeys("text1", "text2", "file1"); + assertThat(result.getFirst("text1")).isInstanceOfSatisfying(FormFieldPart.class, + part -> assertThat(part.value()).isEqualTo("a")); + assertThat(result.getFirst("text2")).isInstanceOfSatisfying(FormFieldPart.class, + part -> assertThat(part.value()).isEqualTo("b")); + assertThat(result.getFirst("file1")).isInstanceOfSatisfying(FilePart.class, + part -> assertThat(part.filename()).isEqualTo("file1.txt")); + }); + } + @RestController static class TestController { @@ -94,6 +125,21 @@ class MockMvcRestTestClientTests { response.sendError(400); response.getWriter().write("some really bad request"); } + + @GetMapping(value = "/multipart", produces = MediaType.MULTIPART_FORM_DATA_VALUE) + MultiValueMap multipart() { + MultiValueMap parts = new LinkedMultiValueMap<>(); + parts.add("text1", "a"); + parts.add("text2", "b"); + Resource resource = new ByteArrayResource("Lorem ipsum dolor sit amet".getBytes()) { + @Override + public String getFilename() { + return "file1.txt"; + } + }; + parts.add("file1", resource); + return parts; + } } } diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java index 8ed7f76d337..15d2b2df28c 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java @@ -24,6 +24,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.Files; import java.util.List; import java.util.function.Consumer; import java.util.function.Function; @@ -40,6 +41,8 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; @@ -54,6 +57,9 @@ import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.http.client.ReactorClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.multipart.FilePart; +import org.springframework.http.converter.multipart.FormFieldPart; +import org.springframework.http.converter.multipart.Part; import org.springframework.util.CollectionUtils; import org.springframework.util.FastByteArrayOutputStream; import org.springframework.util.FileCopyUtils; @@ -354,6 +360,35 @@ class RestClientIntegrationTests { assertThat(result).isNull(); } + @ParameterizedRestClientTest + void retrieveMultipart(ClientHttpRequestFactory requestFactory) throws IOException { + startServer(requestFactory); + Resource resource = new ClassPathResource("simple.multipart", getClass()); + String multipartBody = Files.readString(resource.getFile().toPath()); + + prepareResponse(builder -> builder + .setHeader("Content-Type", "multipart/form-data; boundary=---------------------------testboundary") + .body(multipartBody)); + + MultiValueMap result = this.restClient.get() + .uri("/multipart") + .accept(MediaType.MULTIPART_FORM_DATA) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + assertThat(result).hasSize(3); + assertThat(result).containsKeys("text1", "text2", "file1"); + assertThat(result.get("text1").get(0)).isInstanceOfSatisfying(FormFieldPart.class, part -> assertThat(part.value()).isEqualTo("a")); + assertThat(result.get("text2").get(0)).isInstanceOfSatisfying(FormFieldPart.class, part -> assertThat(part.value()).isEqualTo("b")); + assertThat(result.get("file1").get(0)).isInstanceOfSatisfying(FilePart.class, part -> assertThat(part.filename()).isEqualTo("a.txt")); + + expectRequestCount(1); + expectRequest(request -> { + assertThat(request.getTarget()).isEqualTo("/multipart"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("multipart/form-data"); + }); + } + @ParameterizedRestClientTest void retrieve404(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); diff --git a/spring-web/src/test/resources/org/springframework/web/client/simple.multipart b/spring-web/src/test/resources/org/springframework/web/client/simple.multipart new file mode 100644 index 00000000000..de58b2ea79c --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/web/client/simple.multipart @@ -0,0 +1,15 @@ +-----------------------------testboundary +Content-Disposition: form-data; name="text1" + +a +-----------------------------testboundary +Content-Disposition: form-data; name="text2" + +b +-----------------------------testboundary +Content-Disposition: form-data; name="file1"; filename="a.txt" +Content-Type: text/plain + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam. + +-----------------------------testboundary--