org.springframework.test.web.reactive.server.WiretapConnector Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of spring-test Show documentation
Show all versions of spring-test Show documentation
Spring TestContext Framework
/*
* 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.test.web.reactive.server;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import org.reactivestreams.Publisher;
import reactor.core.Scannable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpMethod;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ClientHttpRequestDecorator;
import org.springframework.http.client.reactive.ClientHttpResponse;
import org.springframework.http.client.reactive.ClientHttpResponseDecorator;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Decorate another {@link ClientHttpConnector} with the purpose of
* intercepting, capturing, and exposing actual request and response data
* transmitted to and received from the server.
*
* @author Rossen Stoyanchev
* @since 5.0
* @see HttpHandlerConnector
*/
class WiretapConnector implements ClientHttpConnector {
private final ClientHttpConnector delegate;
private final Map exchanges = new ConcurrentHashMap<>();
WiretapConnector(ClientHttpConnector delegate) {
this.delegate = delegate;
}
@Override
public Mono connect(HttpMethod method, URI uri,
Function> requestCallback) {
AtomicReference requestRef = new AtomicReference<>();
return this.delegate
.connect(method, uri, request -> {
WiretapClientHttpRequest wrapped = new WiretapClientHttpRequest(request);
requestRef.set(wrapped);
return requestCallback.apply(wrapped);
})
.map(response -> {
WiretapClientHttpRequest wrappedRequest = requestRef.get();
String header = WebTestClient.WEBTESTCLIENT_REQUEST_ID;
String requestId = wrappedRequest.getHeaders().getFirst(header);
Assert.state(requestId != null, () -> "No \"" + header + "\" header");
WiretapClientHttpResponse wrappedResponse = new WiretapClientHttpResponse(response);
this.exchanges.put(requestId, new ClientExchangeInfo(wrappedRequest, wrappedResponse));
return wrappedResponse;
});
}
/**
* Create the {@link ExchangeResult} for the given "request-id" header value.
*/
ExchangeResult getExchangeResult(String requestId, @Nullable String uriTemplate, Duration timeout) {
ClientExchangeInfo clientInfo = this.exchanges.remove(requestId);
Assert.state(clientInfo != null, () -> "No match for %s=%s".formatted(
WebTestClient.WEBTESTCLIENT_REQUEST_ID, requestId));
return new ExchangeResult(clientInfo.getRequest(), clientInfo.getResponse(),
clientInfo.getRequest().getRecorder().getContent(),
clientInfo.getResponse().getRecorder().getContent(),
timeout, uriTemplate,
clientInfo.getResponse().getMockServerResult());
}
/**
* Holder for {@link WiretapClientHttpRequest} and {@link WiretapClientHttpResponse}.
*/
private static class ClientExchangeInfo {
private final WiretapClientHttpRequest request;
private final WiretapClientHttpResponse response;
public ClientExchangeInfo(WiretapClientHttpRequest request, WiretapClientHttpResponse response) {
this.request = request;
this.response = response;
}
public WiretapClientHttpRequest getRequest() {
return this.request;
}
public WiretapClientHttpResponse getResponse() {
return this.response;
}
}
/**
* Tap into a Publisher of data buffers to save the content.
*/
static final class WiretapRecorder {
@Nullable
private final Flux publisher;
@Nullable
private final Flux> publisherNested;
private final DataBuffer buffer = DefaultDataBufferFactory.sharedInstance.allocateBuffer(256);
// unsafe(): we're intercepting, already serialized Publisher signals
private final Sinks.One content = Sinks.unsafe().one();
private boolean hasContentConsumer;
public WiretapRecorder(@Nullable Publisher publisher,
@Nullable Publisher> publisherNested) {
if (publisher != null && publisherNested != null) {
throw new IllegalArgumentException("At most one publisher expected");
}
this.publisher = publisher != null ?
Flux.from(publisher)
.doOnSubscribe(s -> this.hasContentConsumer = true)
.doOnNext(this.buffer::write)
.doOnError(this::handleOnError)
.doOnCancel(this::handleOnComplete)
.doOnComplete(this::handleOnComplete) : null;
this.publisherNested = publisherNested != null ?
Flux.from(publisherNested)
.doOnSubscribe(s -> this.hasContentConsumer = true)
.map(p -> Flux.from(p).doOnNext(this.buffer::write).doOnError(this::handleOnError))
.doOnError(this::handleOnError)
.doOnCancel(this::handleOnComplete)
.doOnComplete(this::handleOnComplete) : null;
if (publisher == null && publisherNested == null) {
this.content.tryEmitEmpty();
}
}
public Publisher getPublisherToUse() {
Assert.notNull(this.publisher, "Publisher not in use.");
return this.publisher;
}
public Publisher> getNestedPublisherToUse() {
Assert.notNull(this.publisherNested, "Nested publisher not in use.");
return this.publisherNested;
}
public Mono getContent() {
return Mono.defer(() -> {
if (this.content.scan(Scannable.Attr.TERMINATED) == Boolean.TRUE) {
return this.content.asMono();
}
if (!this.hasContentConsumer) {
// Couple of possible cases:
// 1. Mock server never consumed request body (e.g. error before read)
// 2. FluxExchangeResult: getResponseBodyContent called before getResponseBody
//noinspection ConstantConditions
(this.publisher != null ? this.publisher : this.publisherNested)
.onErrorMap(ex -> new IllegalStateException(
"Content has not been consumed, and " +
"an error was raised while attempting to produce it.", ex))
.subscribe();
}
return this.content.asMono();
});
}
private void handleOnError(Throwable ex) {
// Ignore result: signals cannot compete
this.content.tryEmitError(ex);
}
private void handleOnComplete() {
byte[] bytes = new byte[this.buffer.readableByteCount()];
this.buffer.read(bytes);
// Ignore result: signals cannot compete
this.content.tryEmitValue(bytes);
}
}
/**
* ClientHttpRequestDecorator that intercepts and saves the request body.
*/
private static class WiretapClientHttpRequest extends ClientHttpRequestDecorator {
@Nullable
private WiretapRecorder recorder;
public WiretapClientHttpRequest(ClientHttpRequest delegate) {
super(delegate);
}
public WiretapRecorder getRecorder() {
Assert.notNull(this.recorder, "No WiretapRecorder: was the client request written?");
return this.recorder;
}
@Override
public Mono writeWith(Publisher publisher) {
this.recorder = new WiretapRecorder(publisher, null);
return super.writeWith(this.recorder.getPublisherToUse());
}
@Override
public Mono writeAndFlushWith(Publisher> publisher) {
this.recorder = new WiretapRecorder(null, publisher);
return super.writeAndFlushWith(this.recorder.getNestedPublisherToUse());
}
@Override
public Mono setComplete() {
this.recorder = new WiretapRecorder(null, null);
return super.setComplete();
}
}
/**
* ClientHttpResponseDecorator that intercepts and saves the response body.
*/
private static class WiretapClientHttpResponse extends ClientHttpResponseDecorator {
private final WiretapRecorder recorder;
public WiretapClientHttpResponse(ClientHttpResponse delegate) {
super(delegate);
this.recorder = new WiretapRecorder(super.getBody(), null);
}
public WiretapRecorder getRecorder() {
return this.recorder;
}
@Override
@SuppressWarnings("ConstantConditions")
public Flux getBody() {
return Flux.from(this.recorder.getPublisherToUse());
}
@Nullable
public Object getMockServerResult() {
return (getDelegate() instanceof MockServerClientHttpResponse mockResponse ?
mockResponse.getServerResult() : null);
}
}
}