Browse Source

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
pull/36549/head
Brian Clozel 4 days ago
parent
commit
aac521c116
  1. 50
      framework-docs/modules/ROOT/pages/integration/rest-clients.adoc
  2. 13
      framework-docs/modules/ROOT/pages/testing/resttestclient.adoc
  3. 54
      framework-docs/src/main/java/org/springframework/docs/testing/resttestclient/multipart/MultipartTests.java
  4. 48
      spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcRestTestClientTests.java
  5. 35
      spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java
  6. 15
      spring-web/src/test/resources/org/springframework/web/client/simple.multipart

50
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. @@ -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<MultiValueMap<String, Part>>`.
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<String, Part> 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<MultiValueMap<String, Part>>() {})
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

13
framework-docs/modules/ROOT/pages/testing/resttestclient.adoc

@ -142,6 +142,9 @@ provides two alternative ways to verify the response: @@ -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]: @@ -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<String, Part>` and assert individual parts using the `FormFieldPart`
and `FilePart` subtypes.
include-code::./MultipartTests[tag=multipart,indent=0]
[[resttestclient.assertj]]
=== AssertJ Integration

54
framework-docs/src/main/java/org/springframework/docs/testing/resttestclient/multipart/MultipartTests.java

@ -0,0 +1,54 @@ @@ -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<MultiValueMap<String, Part>>() {})
.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[]
}
}

48
spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcRestTestClientTests.java

@ -22,19 +22,30 @@ import jakarta.servlet.http.Cookie; @@ -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 { @@ -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<MultiValueMap<String, Part>>() {})
.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 { @@ -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<String, Object> multipart() {
MultiValueMap<String, Object> 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;
}
}
}

35
spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java

@ -24,6 +24,7 @@ import java.lang.annotation.RetentionPolicy; @@ -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; @@ -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; @@ -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 { @@ -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<String, Part> 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);

15
spring-web/src/test/resources/org/springframework/web/client/simple.multipart

@ -0,0 +1,15 @@ @@ -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--
Loading…
Cancel
Save