Access to request and response byte[] in WebTestClient

The WiretapConnector now decorated the ClientHttpRequest & Response
in order to intercept and save the actual content written and read.

The saved content is now incorporated in the diagnostic output but may
be used for other purposes as well (e.g. REST Docs).

Diagnostic information about an exchange has also been refactored
similar to command line output from curl.
This commit is contained in:
Rossen Stoyanchev 2017-02-20 18:26:31 -05:00
parent 71b021c7cc
commit e6401b29e6
10 changed files with 318 additions and 125 deletions

View File

@ -61,7 +61,7 @@ class DefaultWebTestClient implements WebTestClient {
private final WebClient webClient;
private final WebTestClientConnector webTestClientConnector;
private final WiretapConnector wiretapConnector;
private final Duration timeout;
@ -71,15 +71,15 @@ class DefaultWebTestClient implements WebTestClient {
DefaultWebTestClient(WebClient.Builder webClientBuilder, ClientHttpConnector connector, Duration timeout) {
Assert.notNull(webClientBuilder, "WebClient.Builder is required");
this.webTestClientConnector = new WebTestClientConnector(connector);
this.webClient = webClientBuilder.clientConnector(this.webTestClientConnector).build();
this.wiretapConnector = new WiretapConnector(connector);
this.webClient = webClientBuilder.clientConnector(this.wiretapConnector).build();
this.timeout = (timeout != null ? timeout : Duration.ofSeconds(5));
}
private DefaultWebTestClient(DefaultWebTestClient webTestClient, ExchangeFilterFunction filter) {
this.webClient = webTestClient.webClient.filter(filter);
this.timeout = webTestClient.timeout;
this.webTestClientConnector = webTestClient.webTestClientConnector;
this.wiretapConnector = webTestClient.wiretapConnector;
}
@ -174,7 +174,7 @@ class DefaultWebTestClient implements WebTestClient {
DefaultHeaderSpec(WebClient.HeaderSpec spec) {
this.headerSpec = spec;
this.requestId = String.valueOf(requestIndex.incrementAndGet());
this.headerSpec.header(WebTestClientConnector.REQUEST_ID_HEADER_NAME, this.requestId);
this.headerSpec.header(WiretapConnector.REQUEST_ID_HEADER_NAME, this.requestId);
}
@ -254,9 +254,9 @@ class DefaultWebTestClient implements WebTestClient {
}
private DefaultResponseSpec toResponseSpec(Mono<ClientResponse> mono) {
ClientResponse response = mono.block(getTimeout());
ClientHttpRequest httpRequest = webTestClientConnector.claimRequest(this.requestId);
return new DefaultResponseSpec(httpRequest, response);
ClientResponse clientResponse = mono.block(getTimeout());
ExchangeResult exchangeResult = wiretapConnector.claimRequest(this.requestId);
return new DefaultResponseSpec(exchangeResult, clientResponse);
}
}
@ -268,8 +268,8 @@ class DefaultWebTestClient implements WebTestClient {
private final ClientResponse response;
public UndecodedExchangeResult(ClientHttpRequest httpRequest, ClientResponse response) {
super(httpRequest, response);
public UndecodedExchangeResult(ExchangeResult result, ClientResponse response) {
super(result);
this.response = response;
}
@ -290,7 +290,7 @@ class DefaultWebTestClient implements WebTestClient {
public <T> FluxExchangeResult<T> decodeBody(ResolvableType elementType) {
Flux<T> body = this.response.body(toFlux(elementType));
return new FluxExchangeResult<>(this, body, elementType);
return new FluxExchangeResult<>(this, body);
}
@SuppressWarnings("unchecked")
@ -313,8 +313,8 @@ class DefaultWebTestClient implements WebTestClient {
private final UndecodedExchangeResult result;
public DefaultResponseSpec(ClientHttpRequest httpRequest, ClientResponse response) {
this.result = new UndecodedExchangeResult(httpRequest, response);
public DefaultResponseSpec(ExchangeResult result, ClientResponse response) {
this.result = new UndecodedExchangeResult(result, response);
}
@Override

View File

@ -43,9 +43,4 @@ public class EntityExchangeResult<T> extends ExchangeResult {
return this.body;
}
@Override
protected String formatResponseBody() {
return this.body.toString();
}
}

View File

@ -16,71 +16,66 @@
package org.springframework.test.web.reactive.server;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import reactor.core.publisher.MonoProcessor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.client.ClientResponse;
/**
* Simple container for request and response details from an exchange performed
* through the {@link WebTestClient}.
*
* <p>An {@code ExchangeResult} only exposes the status and the headers from
* the response which is all that's available when a {@link ClientResponse} is
* first created.
* <p>When an {@code ExchangeResult} is first created it has only the status and
* headers of the response available. When the response body is extracted, the
* {@code ExchangeResult} is re-created as either {@link EntityExchangeResult}
* or {@link FluxExchangeResult} that further expose extracted entities.
*
* <p>Sub-types {@link EntityExchangeResult} and {@link FluxExchangeResult}
* further expose the response body either as a fully extracted representation
* or as a {@code Flux} of representations to be consumed.
* <p>Raw request and response content may also be accessed once complete via
* {@link #getRequestContent()} or {@link #getResponseContent()}.
*
* @author Rossen Stoyanchev
* @since 5.0
*
* @see EntityExchangeResult
* @see FluxExchangeResult
*/
public class ExchangeResult {
private final HttpMethod method;
private static final List<MediaType> PRINTABLE_MEDIA_TYPES = Arrays.asList(
MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, MediaType.parseMediaType("text/*"),
MediaType.APPLICATION_FORM_URLENCODED);
private final URI url;
private final HttpHeaders requestHeaders;
private final WiretapClientHttpRequest request;
private final HttpStatus status;
private final HttpHeaders responseHeaders;
private final MultiValueMap<String, ResponseCookie> responseCookies;
private final WiretapClientHttpResponse response;
/**
* Constructor used when a {@code ClientResponse} is first created.
* Constructor used when the {@code ClientHttpResponse} becomes available.
*/
protected ExchangeResult(ClientHttpRequest request, ClientResponse response) {
this.method = request.getMethod();
this.url = request.getURI();
this.requestHeaders = request.getHeaders();
this.status = response.statusCode();
this.responseHeaders = response.headers().asHttpHeaders();
this.responseCookies = response.cookies();
protected ExchangeResult(WiretapClientHttpRequest request, WiretapClientHttpResponse response) {
this.request = request;
this.response = response;
}
/**
* Copy constructor used when the body is decoded or consumed.
*/
protected ExchangeResult(ExchangeResult other) {
this.method = other.getMethod();
this.url = other.getUrl();
this.requestHeaders = other.getRequestHeaders();
this.status = other.getStatus();
this.responseHeaders = other.getResponseHeaders();
this.responseCookies = other.getResponseCookies();
this.request = other.request;
this.response = other.response;
}
@ -88,42 +83,56 @@ public class ExchangeResult {
* Return the method of the request.
*/
public HttpMethod getMethod() {
return this.method;
return this.request.getMethod();
}
/**
* Return the request headers that were sent to the server.
*/
public URI getUrl() {
return this.url;
return this.request.getURI();
}
/**
* Return the request headers sent to the server.
*/
public HttpHeaders getRequestHeaders() {
return this.requestHeaders;
return this.request.getHeaders();
}
/**
* Return a "promise" for the raw request body content once completed.
*/
public MonoProcessor<byte[]> getRequestContent() {
return this.request.getBodyContent();
}
/**
* Return the status of the executed request.
*/
public HttpStatus getStatus() {
return this.status;
return this.response.getStatusCode();
}
/**
* Return the response headers received from the server.
*/
public HttpHeaders getResponseHeaders() {
return this.responseHeaders;
return this.response.getHeaders();
}
/**
* Return response cookies received from the server.
*/
public MultiValueMap<String, ResponseCookie> getResponseCookies() {
return this.responseCookies;
return this.getResponseCookies();
}
/**
* Return a "promise" for the raw response body content once completed.
*/
public MonoProcessor<byte[]> getResponseContent() {
return this.response.getBodyContent();
}
@ -156,39 +165,60 @@ public class ExchangeResult {
@Override
public String toString() {
return "\n\n" +
formatValue("Request", this.method + " " + getUrl()) +
formatValue("Status", this.status + " " + getStatusReason()) +
formatHeading("Response Headers") + formatHeaders(this.responseHeaders) +
formatHeading("Request Headers") + formatHeaders(this.requestHeaders) +
return "\n" +
"> " + getMethod() + " " + getUrl() + "\n" +
"> " + formatHeaders(getRequestHeaders()) + "\n" +
"\n" +
formatValue("Response Body", formatResponseBody());
formatContent(getRequestHeaders().getContentType(), getRequestContent()) + "\n" +
"\n" +
"> " + getStatus() + " " + getStatusReason() + "\n" +
"> " + formatHeaders(getResponseHeaders()) + "\n" +
"\n" +
formatContent(getResponseHeaders().getContentType(), getResponseContent()) + "\n\n";
}
private String getStatusReason() {
String reason = "";
if (this.status != null && this.status.getReasonPhrase() != null) {
reason = this.status.getReasonPhrase();
if (getStatus() != null && getStatus().getReasonPhrase() != null) {
reason = getStatus().getReasonPhrase();
}
return reason;
}
private String formatHeading(String heading) {
return "\n" + String.format("%s", heading) + "\n";
}
private String formatValue(String label, Object value) {
return String.format("%18s: %s", label, value) + "\n";
}
private String formatHeaders(HttpHeaders headers) {
return headers.entrySet().stream()
.map(entry -> formatValue(entry.getKey(), entry.getValue()))
.collect(Collectors.joining());
.map(entry -> entry.getKey() + ": " + entry.getValue())
.collect(Collectors.joining("\n> "));
}
protected String formatResponseBody() {
return "Not read yet";
private String formatContent(MediaType contentType, MonoProcessor<byte[]> body) {
if (body.isSuccess()) {
byte[] bytes = body.blockMillis(0);
if (bytes.length == 0) {
return "No content";
}
if (contentType == null) {
return "Unknown content type (" + bytes.length + " bytes)";
}
Charset charset = contentType.getCharset();
if (charset != null) {
return new String(bytes, charset);
}
if (PRINTABLE_MEDIA_TYPES.stream().anyMatch(contentType::isCompatibleWith)) {
return new String(bytes, StandardCharsets.UTF_8);
}
return "Unknown charset (" + bytes.length + " bytes)";
}
else if (body.isError()) {
return "I/O failure: " + body.getError().getMessage();
}
else {
return "Content not available yet";
}
}
}

View File

@ -17,8 +17,6 @@ package org.springframework.test.web.reactive.server;
import reactor.core.publisher.Flux;
import org.springframework.core.ResolvableType;
/**
* {@code ExchangeResult} variant with the response body as a {@code Flux<T>}.
*
@ -32,13 +30,10 @@ public class FluxExchangeResult<T> extends ExchangeResult {
private final Flux<T> body;
private final ResolvableType elementType;
FluxExchangeResult(ExchangeResult result, Flux<T> body, ResolvableType elementType) {
FluxExchangeResult(ExchangeResult result, Flux<T> body) {
super(result);
this.body = body;
this.elementType = elementType;
}
@ -49,9 +44,4 @@ public class FluxExchangeResult<T> extends ExchangeResult {
return this.body;
}
@Override
protected String formatResponseBody() {
return "Flux<" + this.elementType.toString() + ">";
}
}

View File

@ -0,0 +1,103 @@
/*
* Copyright 2002-2017 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
*
* http://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.test.web.reactive.server;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.MonoProcessor;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
/**
* Client HTTP request decorator that saves the content written to the server.
*
* @author Rossen Stoyanchev
* @since 5.0
*/
class WiretapClientHttpRequest extends ClientHttpRequestDecorator {
private static final DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
private final DataBuffer buffer;
private final MonoProcessor<byte[]> body = MonoProcessor.create();
public WiretapClientHttpRequest(ClientHttpRequest delegate) {
super(delegate);
this.buffer = bufferFactory.allocateBuffer();
}
/**
* Return a "promise" for the request body content.
*/
public MonoProcessor<byte[]> getBodyContent() {
return this.body;
}
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> publisher) {
return super.writeWith(
Flux.from(publisher)
.doOnNext(this::handleBuffer)
.doOnError(this::handleErrorSignal)
.doOnCancel(this::handleCompleteSignal)
.doOnComplete(this::handleCompleteSignal));
}
@Override
public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> publisher) {
return super.writeAndFlushWith(
Flux.from(publisher)
.map(p -> Flux.from(p).doOnNext(this::handleBuffer).doOnError(this::handleErrorSignal))
.doOnError(this::handleErrorSignal)
.doOnCancel(this::handleCompleteSignal)
.doOnComplete(this::handleCompleteSignal));
}
@Override
public Mono<Void> setComplete() {
handleCompleteSignal();
return super.setComplete();
}
private void handleBuffer(DataBuffer buffer) {
this.buffer.write(buffer);
}
private void handleErrorSignal(Throwable ex) {
if (!this.body.isTerminated()) {
this.body.onError(ex);
}
}
private void handleCompleteSignal() {
if (!this.body.isTerminated()) {
byte[] bytes = new byte[this.buffer.readableByteCount()];
this.buffer.read(bytes);
this.body.onNext(bytes);
}
}
}

View File

@ -0,0 +1,73 @@
/*
* Copyright 2002-2017 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
*
* http://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.test.web.reactive.server;
import reactor.core.publisher.Flux;
import reactor.core.publisher.MonoProcessor;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.client.reactive.ClientHttpResponse;
import org.springframework.http.client.reactive.ClientHttpResponseDecorator;
/**
* Client HTTP response decorator that saves the content read from the server.
*
* @author Rossen Stoyanchev
* @since 5.0
*/
class WiretapClientHttpResponse extends ClientHttpResponseDecorator {
private static final DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
private final DataBuffer buffer;
private final MonoProcessor<byte[]> body = MonoProcessor.create();
public WiretapClientHttpResponse(ClientHttpResponse delegate) {
super(delegate);
this.buffer = bufferFactory.allocateBuffer();
}
/**
* Return a "promise" for the response body content.
*/
public MonoProcessor<byte[]> getBodyContent() {
return this.body;
}
@Override
public Flux<DataBuffer> getBody() {
return super.getBody()
.doOnNext(buffer::write)
.doOnError(body::onError)
.doOnCancel(this::handleCompleteSignal)
.doOnComplete(this::handleCompleteSignal);
}
private void handleCompleteSignal() {
if (!this.body.isTerminated()) {
byte[] bytes = new byte[this.buffer.readableByteCount()];
this.buffer.read(bytes);
this.body.onNext(bytes);
}
}
}

View File

@ -31,24 +31,24 @@ import org.springframework.util.Assert;
/**
* Decorate any other {@link ClientHttpConnector} with the purpose of
* intercepting, capturing, and exposing {@code ClientHttpRequest}s reflecting
* the exact and complete details sent to the server.
* intercepting, capturing, and exposing actual request and response content
* transmitted to and received from the server.
*
* @author Rossen Stoyanchev
* @since 5.0
* @see HttpHandlerConnector
*/
class WebTestClientConnector implements ClientHttpConnector {
class WiretapConnector implements ClientHttpConnector {
public static final String REQUEST_ID_HEADER_NAME = "request-id";
private final ClientHttpConnector delegate;
private final Map<String, ClientHttpRequest> capturedRequests = new ConcurrentHashMap<>();
private final Map<String, ExchangeResult> capturedExchanges = new ConcurrentHashMap<>();
public WebTestClientConnector(ClientHttpConnector delegate) {
public WiretapConnector(ClientHttpConnector delegate) {
this.delegate = delegate;
}
@ -57,29 +57,32 @@ class WebTestClientConnector implements ClientHttpConnector {
public Mono<ClientHttpResponse> connect(HttpMethod method, URI uri,
Function<? super ClientHttpRequest, Mono<Void>> requestCallback) {
AtomicReference<ClientHttpRequest> requestRef = new AtomicReference<>();
AtomicReference<WiretapClientHttpRequest> requestRef = new AtomicReference<>();
return this.delegate
.connect(method, uri, request -> {
requestRef.set(request);
return requestCallback.apply(request);
WiretapClientHttpRequest wrapped = new WiretapClientHttpRequest(request);
requestRef.set(wrapped);
return requestCallback.apply(wrapped);
})
.doOnNext(response -> {
ClientHttpRequest request = requestRef.get();
String id = request.getHeaders().getFirst(REQUEST_ID_HEADER_NAME);
if (id != null) {
this.capturedRequests.put(id, request);
}
.map(response -> {
WiretapClientHttpRequest request = requestRef.get();
String requestId = request.getHeaders().getFirst(REQUEST_ID_HEADER_NAME);
Assert.notNull(requestId, "No request-id header");
WiretapClientHttpResponse wrapped = new WiretapClientHttpResponse(response);
ExchangeResult result = new ExchangeResult(request, wrapped);
this.capturedExchanges.put(requestId, result);
return wrapped;
});
}
/**
* Retrieve the request with the given "request-id" header.
*/
public ClientHttpRequest claimRequest(String requestId) {
ClientHttpRequest request = this.capturedRequests.get(requestId);
Assert.notNull(request, "No matching request [" + requestId + "]. Did connect return a response yet?");
return request;
public ExchangeResult claimRequest(String requestId) {
ExchangeResult result = this.capturedExchanges.get(requestId);
Assert.notNull(result, "No match for request with id [" + requestId + "]");
return result;
}
}

View File

@ -23,8 +23,10 @@ import org.junit.Test;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.http.client.reactive.MockClientHttpRequest;
import org.springframework.mock.http.client.reactive.MockClientHttpResponse;
import org.springframework.web.reactive.function.client.ClientResponse;
import static junit.framework.TestCase.assertNotNull;
@ -146,15 +148,14 @@ public class HeaderAssertionsTests {
private HeaderAssertions headerAssertions(HttpHeaders responseHeaders) {
ClientResponse.Headers headers = mock(ClientResponse.Headers.class);
when(headers.asHttpHeaders()).thenReturn(responseHeaders);
ClientResponse response = mock(ClientResponse.class);
when(response.headers()).thenReturn(headers);
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/"));
ExchangeResult result = new ExchangeResult(request, response);
MockClientHttpResponse response = new MockClientHttpResponse(HttpStatus.OK);
response.getHeaders().putAll(responseHeaders);
WiretapClientHttpRequest wiretapRequest = new WiretapClientHttpRequest(request);
WiretapClientHttpResponse wiretapResponse = new WiretapClientHttpResponse(response);
ExchangeResult result = new ExchangeResult(wiretapRequest, wiretapResponse);
return new HeaderAssertions(result, mock(WebTestClient.ResponseSpec.class));
}

View File

@ -23,6 +23,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.client.reactive.MockClientHttpRequest;
import org.springframework.mock.http.client.reactive.MockClientHttpResponse;
import org.springframework.web.reactive.function.client.ClientResponse;
import static org.junit.Assert.fail;
@ -162,17 +163,14 @@ public class StatusAssertionTests {
private StatusAssertions statusAssertions(HttpStatus status) {
ClientResponse.Headers headers = mock(ClientResponse.Headers.class);
when(headers.asHttpHeaders()).thenReturn(new HttpHeaders());
ClientResponse response = mock(ClientResponse.class);
when(response.statusCode()).thenReturn(status);
when(response.headers()).thenReturn(headers);
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/"));
ExchangeResult result = new ExchangeResult(request, response);
MockClientHttpResponse response = new MockClientHttpResponse(status);
return new StatusAssertions(result, mock(WebTestClient.ResponseSpec.class));
WiretapClientHttpRequest wiretapRequest = new WiretapClientHttpRequest(request);
WiretapClientHttpResponse wiretapResponse = new WiretapClientHttpResponse(response);
ExchangeResult exchangeResult = new ExchangeResult(wiretapRequest, wiretapResponse);
return new StatusAssertions(exchangeResult, mock(WebTestClient.ResponseSpec.class));
}
}

View File

@ -36,7 +36,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
/**
* Unit tests for {@link WebTestClientConnector}.
* Unit tests for {@link WiretapConnector}.
*
* @author Rossen Stoyanchev
*/
@ -50,16 +50,16 @@ public class WebTestClientConnectorTests {
ClientHttpConnector connector = (method, uri, fn) -> fn.apply(request).then(Mono.just(response));
ClientRequest clientRequest = ClientRequest.method(HttpMethod.GET, URI.create("/test"))
.header(WebTestClientConnector.REQUEST_ID_HEADER_NAME, "1").build();
.header(WiretapConnector.REQUEST_ID_HEADER_NAME, "1").build();
WebTestClientConnector webTestClientConnector = new WebTestClientConnector(connector);
ExchangeFunction function = ExchangeFunctions.create(webTestClientConnector);
WiretapConnector wiretapConnector = new WiretapConnector(connector);
ExchangeFunction function = ExchangeFunctions.create(wiretapConnector);
function.exchange(clientRequest).blockMillis(0);
ClientHttpRequest actual = webTestClientConnector.claimRequest("1");
ExchangeResult actual = wiretapConnector.claimRequest("1");
assertNotNull(actual);
assertEquals(HttpMethod.GET, actual.getMethod());
assertEquals("/test", actual.getURI().toString());
assertEquals("/test", actual.getUrl().toString());
}
}