Skip to content

Commit 11dd0d6

Browse files
committed
Provide access to raw content in RestTestClient
Closes gh-35399
1 parent 23d1b0e commit 11dd0d6

File tree

4 files changed

+119
-10
lines changed

4 files changed

+119
-10
lines changed

spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616

1717
package org.springframework.test.web.servlet.client;
1818

19+
import java.io.IOException;
1920
import java.net.URI;
2021
import java.nio.charset.Charset;
2122
import java.nio.charset.StandardCharsets;
2223
import java.time.ZonedDateTime;
2324
import java.util.Map;
2425
import java.util.Optional;
26+
import java.util.concurrent.ConcurrentHashMap;
2527
import java.util.concurrent.atomic.AtomicLong;
2628
import java.util.function.Consumer;
2729
import java.util.function.Function;
@@ -33,13 +35,18 @@
3335
import org.springframework.core.ParameterizedTypeReference;
3436
import org.springframework.http.HttpHeaders;
3537
import org.springframework.http.HttpMethod;
38+
import org.springframework.http.HttpRequest;
3639
import org.springframework.http.MediaType;
40+
import org.springframework.http.client.ClientHttpRequestExecution;
41+
import org.springframework.http.client.ClientHttpRequestInterceptor;
42+
import org.springframework.http.client.ClientHttpResponse;
3743
import org.springframework.test.json.JsonAssert;
3844
import org.springframework.test.json.JsonComparator;
3945
import org.springframework.test.json.JsonCompareMode;
4046
import org.springframework.test.util.AssertionErrors;
4147
import org.springframework.test.util.ExceptionCollector;
4248
import org.springframework.test.util.XmlExpectationsHelper;
49+
import org.springframework.util.Assert;
4350
import org.springframework.util.MimeType;
4451
import org.springframework.util.MultiValueMap;
4552
import org.springframework.web.client.RestClient;
@@ -56,6 +63,8 @@ class DefaultRestTestClient implements RestTestClient {
5663

5764
private final RestClient restClient;
5865

66+
private final WiretapInterceptor wiretapInterceptor = new WiretapInterceptor();
67+
5968
private final Consumer<EntityExchangeResult<?>> entityResultConsumer;
6069

6170
private final DefaultRestTestClientBuilder<?> restTestClientBuilder;
@@ -67,7 +76,7 @@ class DefaultRestTestClient implements RestTestClient {
6776
RestClient.Builder builder, Consumer<EntityExchangeResult<?>> entityResultConsumer,
6877
DefaultRestTestClientBuilder<?> restTestClientBuilder) {
6978

70-
this.restClient = builder.build();
79+
this.restClient = builder.requestInterceptor(this.wiretapInterceptor).build();
7180
this.entityResultConsumer = entityResultConsumer;
7281
this.restTestClientBuilder = restTestClientBuilder;
7382
}
@@ -128,12 +137,14 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec {
128137

129138
private final RestClient.RequestBodyUriSpec requestHeadersUriSpec;
130139

140+
private final String requestId;
141+
131142
private @Nullable String uriTemplate;
132143

133144
DefaultRequestBodyUriSpec(RestClient.RequestBodyUriSpec spec) {
134145
this.requestHeadersUriSpec = spec;
135-
String requestId = String.valueOf(requestIndex.incrementAndGet());
136-
this.requestHeadersUriSpec.header(RESTTESTCLIENT_REQUEST_ID, requestId);
146+
this.requestId = String.valueOf(requestIndex.incrementAndGet());
147+
this.requestHeadersUriSpec.header(RESTTESTCLIENT_REQUEST_ID, this.requestId);
137148
}
138149

139150
@Override
@@ -252,7 +263,10 @@ public RequestHeadersSpec<?> body(Object body) {
252263
public ResponseSpec exchange() {
253264
return new DefaultResponseSpec(
254265
this.requestHeadersUriSpec.exchangeForRequiredValue(
255-
(request, response) -> new ExchangeResult(request, response, this.uriTemplate), false),
266+
(request, response) -> {
267+
byte[] requestBody = wiretapInterceptor.getRequestContent(this.requestId);
268+
return new ExchangeResult(request, response, this.uriTemplate, requestBody);
269+
}, false),
256270
DefaultRestTestClient.this.entityResultConsumer);
257271
}
258272
}
@@ -476,4 +490,29 @@ public EntityExchangeResult<byte[]> returnResult() {
476490
return this.result;
477491
}
478492
}
493+
494+
495+
private static class WiretapInterceptor implements ClientHttpRequestInterceptor {
496+
497+
private final Map<String, byte[]> requestContentMap = new ConcurrentHashMap<>();
498+
499+
@Override
500+
public ClientHttpResponse intercept(
501+
HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
502+
503+
String header = RestTestClient.RESTTESTCLIENT_REQUEST_ID;
504+
String requestId = request.getHeaders().getFirst(header);
505+
Assert.state(requestId != null, () -> "No \"" + header + "\" header");
506+
this.requestContentMap.put(requestId, body);
507+
return execution.execute(request, body);
508+
}
509+
510+
public byte[] getRequestContent(String requestId) {
511+
byte[] bytes = this.requestContentMap.remove(requestId);
512+
Assert.state(bytes != null, () ->
513+
"No match for %s=%s".formatted(RestTestClient.RESTTESTCLIENT_REQUEST_ID, requestId));
514+
return bytes;
515+
}
516+
}
517+
479518
}

spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class DefaultRestTestClientBuilder<B extends RestTestClient.Builder<B>> implemen
6161
}
6262

6363
DefaultRestTestClientBuilder(RestClient.Builder restClientBuilder) {
64-
this.restClientBuilder = restClientBuilder;
64+
this.restClientBuilder = restClientBuilder.bufferContent((uri, httpMethod) -> true);
6565
}
6666

6767
DefaultRestTestClientBuilder(DefaultRestTestClientBuilder<B> other) {

spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import java.io.IOException;
2020
import java.net.HttpCookie;
2121
import java.net.URI;
22+
import java.nio.charset.Charset;
23+
import java.nio.charset.StandardCharsets;
2224
import java.util.List;
2325
import java.util.Optional;
2426
import java.util.regex.Matcher;
@@ -34,10 +36,12 @@
3436
import org.springframework.http.HttpRequest;
3537
import org.springframework.http.HttpStatus;
3638
import org.springframework.http.HttpStatusCode;
39+
import org.springframework.http.MediaType;
3740
import org.springframework.http.ResponseCookie;
3841
import org.springframework.util.Assert;
3942
import org.springframework.util.LinkedMultiValueMap;
4043
import org.springframework.util.MultiValueMap;
44+
import org.springframework.util.StreamUtils;
4145
import org.springframework.web.client.RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse;
4246

4347
/**
@@ -54,6 +58,10 @@ public class ExchangeResult {
5458

5559
private static final Pattern PARTITIONED_PATTERN = Pattern.compile("(?i).*;\\s*Partitioned(\\s*;.*|\\s*)$");
5660

61+
private static final List<MediaType> PRINTABLE_MEDIA_TYPES = List.of(
62+
MediaType.parseMediaType("application/*+json"), MediaType.APPLICATION_XML,
63+
MediaType.parseMediaType("text/*"), MediaType.APPLICATION_FORM_URLENCODED);
64+
5765

5866
private static final Log logger = LogFactory.getLog(ExchangeResult.class);
5967

@@ -64,22 +72,26 @@ public class ExchangeResult {
6472

6573
private final @Nullable String uriTemplate;
6674

75+
private final byte[] requestBody;
76+
6777
/** Ensure single logging; for example, for expectAll. */
6878
private boolean diagnosticsLogged;
6979

7080

7181
ExchangeResult(
72-
HttpRequest request, ConvertibleClientHttpResponse response, @Nullable String uriTemplate) {
82+
HttpRequest request, ConvertibleClientHttpResponse response, @Nullable String uriTemplate,
83+
byte[] requestBody) {
7384

7485
Assert.notNull(request, "HttpRequest must not be null");
7586
Assert.notNull(response, "ClientHttpResponse must not be null");
7687
this.request = request;
7788
this.clientResponse = response;
7889
this.uriTemplate = uriTemplate;
90+
this.requestBody = requestBody;
7991
}
8092

8193
ExchangeResult(ExchangeResult result) {
82-
this(result.request, result.clientResponse, result.uriTemplate);
94+
this(result.request, result.clientResponse, result.uriTemplate, result.requestBody);
8395
this.diagnosticsLogged = result.diagnosticsLogged;
8496
}
8597

@@ -159,13 +171,32 @@ private static ResponseCookie toResponseCookie(HttpCookie cookie, @Nullable Stri
159171
.build();
160172
}
161173

174+
/**
175+
* Return the raw request body content written through the request.
176+
*/
177+
public byte[] getRequestBodyContent() {
178+
return this.requestBody;
179+
}
180+
162181
/**
163182
* Provide access to the response. For internal use to decode the body.
164183
*/
165184
ConvertibleClientHttpResponse getClientResponse() {
166185
return this.clientResponse;
167186
}
168187

188+
/**
189+
* Return the raw response body read through the response.
190+
*/
191+
public byte[] getResponseBodyContent() {
192+
try {
193+
return StreamUtils.copyToByteArray(this.clientResponse.getBody());
194+
}
195+
catch (IOException ex) {
196+
throw new IllegalStateException("Failed to get response content: " + ex);
197+
}
198+
}
199+
169200
/**
170201
* Execute the given Runnable, catch any {@link AssertionError}, log details
171202
* about the request and response at ERROR level under the class log
@@ -190,8 +221,12 @@ public String toString() {
190221
"> " + getMethod() + " " + getUrl() + "\n" +
191222
"> " + formatHeaders(getRequestHeaders(), "\n> ") + "\n" +
192223
"\n" +
224+
formatBody(getRequestHeaders().getContentType(), this.requestBody) + "\n" +
225+
"\n" +
193226
"< " + formatStatus(getStatus()) + "\n" +
194-
"< " + formatHeaders(getResponseHeaders(), "\n< ") + "\n";
227+
"< " + formatHeaders(getResponseHeaders(), "\n< ") + "\n" +
228+
"\n" +
229+
formatBody(getResponseHeaders().getContentType(), getResponseBodyContent()) +"\n";
195230
}
196231

197232
private String formatStatus(HttpStatusCode statusCode) {
@@ -208,4 +243,18 @@ private String formatHeaders(HttpHeaders headers, String delimiter) {
208243
.collect(Collectors.joining(delimiter));
209244
}
210245

246+
private String formatBody(@Nullable MediaType contentType, byte[] bytes) {
247+
if (contentType == null) {
248+
return bytes.length + " bytes of content (unknown content-type).";
249+
}
250+
Charset charset = contentType.getCharset();
251+
if (charset != null) {
252+
return new String(bytes, charset);
253+
}
254+
if (PRINTABLE_MEDIA_TYPES.stream().anyMatch(contentType::isCompatibleWith)) {
255+
return new String(bytes, StandardCharsets.UTF_8);
256+
}
257+
return bytes.length + " bytes of content.";
258+
}
259+
211260
}

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
import org.springframework.http.HttpHeaders;
3737
import org.springframework.http.HttpMethod;
3838
import org.springframework.http.MediaType;
39+
import org.springframework.web.bind.annotation.PostMapping;
40+
import org.springframework.web.bind.annotation.RequestBody;
3941
import org.springframework.web.bind.annotation.RequestHeader;
4042
import org.springframework.web.bind.annotation.RequestMapping;
4143
import org.springframework.web.bind.annotation.RestController;
@@ -317,6 +319,19 @@ void testReturnResultParameterizedTypeReference() {
317319
});
318320
assertThat(result.getResponseBody().get("uri")).isEqualTo("/test");
319321
}
322+
323+
@Test
324+
void testResultContent() {
325+
String body = "body-in";
326+
EntityExchangeResult<String> result = RestTestClientTests.this.client.post().uri("/body")
327+
.body(body)
328+
.exchange()
329+
.expectStatus().isOk()
330+
.expectBody(String.class)
331+
.returnResult();
332+
assertThat(result.getRequestBodyContent()).isEqualTo(body.getBytes(StandardCharsets.UTF_8));
333+
assertThat(result.getResponseBodyContent()).isEqualTo((body + "-out").getBytes(StandardCharsets.UTF_8));
334+
}
320335
}
321336

322337

@@ -325,14 +340,20 @@ static class TestController {
325340

326341
@RequestMapping(path = {"/test", "/test/*"}, produces = "application/json")
327342
public Map<String, Object> handle(
328-
@RequestHeader HttpHeaders headers,
329-
HttpServletRequest request, HttpServletResponse response) {
343+
@RequestHeader HttpHeaders headers, HttpServletRequest request, HttpServletResponse response) {
344+
330345
response.addCookie(new Cookie("session", "abc"));
346+
331347
return Map.of(
332348
"method", request.getMethod(),
333349
"uri", request.getRequestURI(),
334350
"headers", headers.toSingleValueMap()
335351
);
336352
}
353+
354+
@PostMapping("/body")
355+
public String echoBody(@RequestBody String body) {
356+
return body + "-out";
357+
}
337358
}
338359
}

0 commit comments

Comments
 (0)