Add RestTestClient

See gh-34428

Signed-off-by: Rob Worsnop <rworsnop@gmail.com>
This commit is contained in:
Rob Worsnop
2024-12-16 22:41:01 -05:00
committed by rstoyanchev
parent 336a5d0ac8
commit 37dcca54d2
35 changed files with 5742 additions and 1 deletions
@@ -20,6 +20,9 @@ import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import jakarta.servlet.http.Cookie;
import org.jspecify.annotations.Nullable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@@ -31,6 +34,8 @@ import org.springframework.mock.http.client.MockClientHttpRequest;
import org.springframework.mock.http.client.MockClientHttpResponse;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
@@ -41,7 +46,9 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
*
* @author Rossen Stoyanchev
* @since 3.2
* @deprecated in favor of {@link RestTestClient#bindTo(MockMvc)}
*/
@Deprecated(since = "7.0")
public class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory {
private final MockMvc mockMvc;
@@ -67,8 +74,14 @@ public class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory
HttpMethod httpMethod, URI uri, HttpHeaders requestHeaders, byte[] requestBody) {
try {
Cookie[] cookies = parseCookies(requestHeaders.get(HttpHeaders.COOKIE));
MockHttpServletRequestBuilder requestBuilder = request(httpMethod, uri)
.content(requestBody).headers(requestHeaders);
if (cookies.length > 0) {
requestBuilder.cookie(cookies);
}
MockHttpServletResponse servletResponse = this.mockMvc
.perform(request(httpMethod, uri).content(requestBody).headers(requestHeaders))
.perform(requestBuilder)
.andReturn()
.getResponse();
@@ -92,6 +105,22 @@ public class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory
}
}
private static Cookie[] parseCookies(@Nullable List<String> headerValues) {
if (headerValues == null) {
return new Cookie[0];
}
return headerValues.stream()
.flatMap(header -> StringUtils.commaDelimitedListToSet(header).stream())
.map(MockMvcClientHttpRequestFactory::parseCookie)
.toArray(Cookie[]::new);
}
private static Cookie parseCookie(String cookie) {
String[] parts = StringUtils.split(cookie, "=");
Assert.isTrue(parts != null && parts.length == 2, "Invalid cookie: '" + cookie + "'");
return new Cookie(parts[0], parts[1]);
}
private HttpHeaders getResponseHeaders(MockHttpServletResponse response) {
HttpHeaders headers = new HttpHeaders();
for (String name : response.getHeaderNames()) {
@@ -0,0 +1,236 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.time.Duration;
import java.util.function.Consumer;
import org.hamcrest.Matcher;
import org.hamcrest.MatcherAssert;
import org.springframework.http.ResponseCookie;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.springframework.test.util.AssertionErrors.assertEquals;
import static org.springframework.test.util.AssertionErrors.fail;
/**
* Assertions on cookies of the response.
*
* @author Rob Worsnop
*/
public class CookieAssertions {
private final ExchangeResult exchangeResult;
private final RestTestClient.ResponseSpec responseSpec;
public CookieAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) {
this.exchangeResult = exchangeResult;
this.responseSpec = responseSpec;
}
/**
* Expect a response cookie with the given name to match the specified value.
*/
public RestTestClient.ResponseSpec valueEquals(String name, String value) {
String cookieValue = getCookie(name).getValue();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name);
assertEquals(message, value, cookieValue);
});
return this.responseSpec;
}
/**
* Assert the value of the response cookie with the given name with a Hamcrest
* {@link Matcher}.
*/
public RestTestClient.ResponseSpec value(String name, Matcher<? super String> matcher) {
String value = getCookie(name).getValue();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name);
MatcherAssert.assertThat(message, value, matcher);
});
return this.responseSpec;
}
/**
* Consume the value of the response cookie with the given name.
*/
public RestTestClient.ResponseSpec value(String name, Consumer<String> consumer) {
String value = getCookie(name).getValue();
this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value));
return this.responseSpec;
}
/**
* Expect that the cookie with the given name is present.
*/
public RestTestClient.ResponseSpec exists(String name) {
getCookie(name);
return this.responseSpec;
}
/**
* Expect that the cookie with the given name is not present.
*/
public RestTestClient.ResponseSpec doesNotExist(String name) {
ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name);
if (cookie != null) {
String message = getMessage(name) + " exists with value=[" + cookie.getValue() + "]";
this.exchangeResult.assertWithDiagnostics(() -> fail(message));
}
return this.responseSpec;
}
/**
* Assert a cookie's "Max-Age" attribute.
*/
public RestTestClient.ResponseSpec maxAge(String name, Duration expected) {
Duration maxAge = getCookie(name).getMaxAge();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " maxAge";
assertEquals(message, expected, maxAge);
});
return this.responseSpec;
}
/**
* Assert a cookie's "Max-Age" attribute with a Hamcrest {@link Matcher}.
*/
public RestTestClient.ResponseSpec maxAge(String name, Matcher<? super Long> matcher) {
long maxAge = getCookie(name).getMaxAge().getSeconds();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " maxAge";
assertThat(message, maxAge, matcher);
});
return this.responseSpec;
}
/**
* Assert a cookie's "Path" attribute.
*/
public RestTestClient.ResponseSpec path(String name, String expected) {
String path = getCookie(name).getPath();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " path";
assertEquals(message, expected, path);
});
return this.responseSpec;
}
/**
* Assert a cookie's "Path" attribute with a Hamcrest {@link Matcher}.
*/
public RestTestClient.ResponseSpec path(String name, Matcher<? super String> matcher) {
String path = getCookie(name).getPath();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " path";
assertThat(message, path, matcher);
});
return this.responseSpec;
}
/**
* Assert a cookie's "Domain" attribute.
*/
public RestTestClient.ResponseSpec domain(String name, String expected) {
String path = getCookie(name).getDomain();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " domain";
assertEquals(message, expected, path);
});
return this.responseSpec;
}
/**
* Assert a cookie's "Domain" attribute with a Hamcrest {@link Matcher}.
*/
public RestTestClient.ResponseSpec domain(String name, Matcher<? super String> matcher) {
String domain = getCookie(name).getDomain();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " domain";
assertThat(message, domain, matcher);
});
return this.responseSpec;
}
/**
* Assert a cookie's "Secure" attribute.
*/
public RestTestClient.ResponseSpec secure(String name, boolean expected) {
boolean isSecure = getCookie(name).isSecure();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " secure";
assertEquals(message, expected, isSecure);
});
return this.responseSpec;
}
/**
* Assert a cookie's "HttpOnly" attribute.
*/
public RestTestClient.ResponseSpec httpOnly(String name, boolean expected) {
boolean isHttpOnly = getCookie(name).isHttpOnly();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " httpOnly";
assertEquals(message, expected, isHttpOnly);
});
return this.responseSpec;
}
/**
* Assert a cookie's "Partitioned" attribute.
*/
public RestTestClient.ResponseSpec partitioned(String name, boolean expected) {
boolean isPartitioned = getCookie(name).isPartitioned();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " isPartitioned";
assertEquals(message, expected, isPartitioned);
});
return this.responseSpec;
}
/**
* Assert a cookie's "SameSite" attribute.
*/
public RestTestClient.ResponseSpec sameSite(String name, String expected) {
String sameSite = getCookie(name).getSameSite();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " sameSite";
assertEquals(message, expected, sameSite);
});
return this.responseSpec;
}
private ResponseCookie getCookie(String name) {
ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name);
if (cookie != null) {
return cookie;
}
else {
this.exchangeResult.assertWithDiagnostics(() -> fail("No cookie with name '" + name + "'"));
}
throw new IllegalStateException("This code path should not be reachable");
}
private static String getMessage(String cookie) {
return "Response cookie '" + cookie + "'";
}
}
@@ -0,0 +1,49 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.util.function.Consumer;
import org.springframework.test.web.servlet.MockMvcBuilder;
/**
* Default implementation of {@link RestTestClient.MockServerBuilder}.
* @author Rob Worsnop
* @param <M> the type of the {@link MockMvcBuilder} to use for building the mock server
*/
class DefaultMockServerBuilder<M extends MockMvcBuilder>
extends DefaultRestTestClientBuilder<RestTestClient.MockServerBuilder<M>>
implements RestTestClient.MockServerBuilder<M> {
private final M builder;
public DefaultMockServerBuilder(M builder) {
this.builder = builder;
}
@Override
public RestTestClient.MockServerBuilder<M> configureServer(Consumer<M> consumer) {
consumer.accept(this.builder);
return this;
}
@Override
public RestTestClient build() {
this.restClientBuilder.requestFactory(new MockMvcClientHttpRequestFactory(this.builder.build()));
return super.build();
}
}
@@ -0,0 +1,429 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Function;
import org.hamcrest.Matcher;
import org.hamcrest.MatcherAssert;
import org.jspecify.annotations.Nullable;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.json.JsonAssert;
import org.springframework.test.json.JsonComparator;
import org.springframework.test.json.JsonCompareMode;
import org.springframework.test.util.AssertionErrors;
import org.springframework.test.util.ExceptionCollector;
import org.springframework.test.util.XmlExpectationsHelper;
import org.springframework.util.MimeType;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriBuilder;
/**
* Default implementation of {@link RestTestClient}.
*
* @author Rob Worsnop
*/
class DefaultRestTestClient implements RestTestClient {
private final RestClient restClient;
private final AtomicLong requestIndex = new AtomicLong();
private final RestClient.Builder restClientBuilder;
DefaultRestTestClient(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder.build();
this.restClientBuilder = restClientBuilder;
}
@Override
public RequestHeadersUriSpec<?> get() {
return methodInternal(HttpMethod.GET);
}
@Override
public RequestHeadersUriSpec<?> head() {
return methodInternal(HttpMethod.HEAD);
}
@Override
public RequestBodyUriSpec post() {
return methodInternal(HttpMethod.POST);
}
@Override
public RequestBodyUriSpec put() {
return methodInternal(HttpMethod.PUT);
}
@Override
public RequestBodyUriSpec patch() {
return methodInternal(HttpMethod.PATCH);
}
@Override
public RequestHeadersUriSpec<?> delete() {
return methodInternal(HttpMethod.DELETE);
}
@Override
public RequestHeadersUriSpec<?> options() {
return methodInternal(HttpMethod.OPTIONS);
}
@Override
public RequestBodyUriSpec method(HttpMethod method) {
return methodInternal(method);
}
@Override
public <B extends Builder<B>> Builder<B> mutate() {
return new DefaultRestTestClientBuilder<>(this.restClientBuilder);
}
private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) {
return new DefaultRequestBodyUriSpec(this.restClient.method(httpMethod));
}
private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec {
private final RestClient.RequestBodyUriSpec requestHeadersUriSpec;
private RestClient.RequestBodySpec requestBodySpec;
private final String requestId;
public DefaultRequestBodyUriSpec(RestClient.RequestBodyUriSpec spec) {
this.requestHeadersUriSpec = spec;
this.requestBodySpec = spec;
this.requestId = String.valueOf(requestIndex.incrementAndGet());
}
@Override
public RequestBodySpec accept(MediaType... acceptableMediaTypes) {
this.requestBodySpec = this.requestHeadersUriSpec.accept(acceptableMediaTypes);
return this;
}
@Override
public RequestBodySpec uri(URI uri) {
this.requestBodySpec = this.requestHeadersUriSpec.uri(uri);
return this;
}
@Override
public RequestBodySpec uri(String uriTemplate, Object... uriVariables) {
this.requestBodySpec = this.requestHeadersUriSpec.uri(uriTemplate, uriVariables);
return this;
}
@Override
public RequestBodySpec uri(String uri, Map<String, ?> uriVariables) {
this.requestBodySpec = this.requestHeadersUriSpec.uri(uri, uriVariables);
return this;
}
@Override
public RequestBodySpec uri(Function<UriBuilder, URI> uriFunction) {
this.requestBodySpec = this.requestHeadersUriSpec.uri(uriFunction);
return this;
}
@Override
public RequestBodySpec cookie(String name, String value) {
this.requestBodySpec = this.requestHeadersUriSpec.cookie(name, value);
return this;
}
@Override
public RequestBodySpec cookies(Consumer<MultiValueMap<String, String>> cookiesConsumer) {
this.requestBodySpec = this.requestHeadersUriSpec.cookies(cookiesConsumer);
return this;
}
@Override
public RequestBodySpec header(String headerName, String... headerValues) {
this.requestBodySpec = this.requestHeadersUriSpec.header(headerName, headerValues);
return this;
}
@Override
public RequestBodySpec contentType(MediaType contentType) {
this.requestBodySpec = this.requestHeadersUriSpec.contentType(contentType);
return this;
}
@Override
public RequestHeadersSpec<?> body(Object body) {
this.requestHeadersUriSpec.body(body);
return this;
}
@Override
public RequestBodySpec acceptCharset(Charset... acceptableCharsets) {
this.requestBodySpec = this.requestHeadersUriSpec.acceptCharset(acceptableCharsets);
return this;
}
@Override
public RequestBodySpec ifModifiedSince(ZonedDateTime ifModifiedSince) {
this.requestBodySpec = this.requestHeadersUriSpec.ifModifiedSince(ifModifiedSince);
return this;
}
@Override
public RequestBodySpec ifNoneMatch(String... ifNoneMatches) {
this.requestBodySpec = this.requestHeadersUriSpec.ifNoneMatch(ifNoneMatches);
return this;
}
@Override
public RequestBodySpec headers(Consumer<HttpHeaders> headersConsumer) {
this.requestBodySpec = this.requestHeadersUriSpec.headers(headersConsumer);
return this;
}
@Override
public RequestBodySpec attribute(String name, Object value) {
this.requestBodySpec = this.requestHeadersUriSpec.attribute(name, value);
return this;
}
@Override
public RequestBodySpec attributes(Consumer<Map<String, Object>> attributesConsumer) {
this.requestBodySpec = this.requestHeadersUriSpec.attributes(attributesConsumer);
return this;
}
@Override
public ResponseSpec exchange() {
this.requestBodySpec = this.requestBodySpec.header(RESTTESTCLIENT_REQUEST_ID, this.requestId);
ExchangeResult exchangeResult = this.requestBodySpec.exchange(
(clientRequest, clientResponse) -> new ExchangeResult(clientResponse),
false);
return new DefaultResponseSpec(Objects.requireNonNull(exchangeResult));
}
}
private static class DefaultResponseSpec implements ResponseSpec {
private final ExchangeResult exchangeResult;
public DefaultResponseSpec(ExchangeResult exchangeResult) {
this.exchangeResult = exchangeResult;
}
@Override
public StatusAssertions expectStatus() {
return new StatusAssertions(this.exchangeResult, this);
}
@Override
public BodyContentSpec expectBody() {
byte[] body = this.exchangeResult.getBody(byte[].class);
return new DefaultBodyContentSpec( new EntityExchangeResult<>(this.exchangeResult, body));
}
@Override
public <B> BodySpec<B, ?> expectBody(Class<B> bodyType) {
B body = this.exchangeResult.getBody(bodyType);
return new DefaultBodySpec<>(new EntityExchangeResult<>(this.exchangeResult, body));
}
@Override
public <B> BodySpec<B, ?> expectBody(ParameterizedTypeReference<B> bodyType) {
B body = this.exchangeResult.getBody(bodyType);
return new DefaultBodySpec<>(new EntityExchangeResult<>(this.exchangeResult, body));
}
@Override
public CookieAssertions expectCookie() {
return new CookieAssertions(this.exchangeResult, this);
}
@Override
public HeaderAssertions expectHeader() {
return new HeaderAssertions(this.exchangeResult, this);
}
@Override
public ResponseSpec expectAll(ResponseSpecConsumer... consumers) {
ExceptionCollector exceptionCollector = new ExceptionCollector();
for (ResponseSpecConsumer consumer : consumers) {
exceptionCollector.execute(() -> consumer.accept(this));
}
try {
exceptionCollector.assertEmpty();
}
catch (RuntimeException ex) {
throw ex;
}
catch (Exception ex) {
// In theory, a ResponseSpecConsumer should never throw an Exception
// that is not a RuntimeException, but since ExceptionCollector may
// throw a checked Exception, we handle this to appease the compiler
// and in case someone uses a "sneaky throws" technique.
throw new AssertionError(ex.getMessage(), ex);
}
return this;
}
@Override
public <T> EntityExchangeResult<T> returnResult(Class<T> elementClass) {
return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementClass));
}
@Override
public <T> EntityExchangeResult<T> returnResult(ParameterizedTypeReference<T> elementTypeRef) {
return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementTypeRef));
}
}
private static class DefaultBodyContentSpec implements BodyContentSpec {
private final EntityExchangeResult<byte[]> result;
public DefaultBodyContentSpec(EntityExchangeResult<byte[]> result) {
this.result = result;
}
@Override
public EntityExchangeResult<Void> isEmpty() {
this.result.assertWithDiagnostics(() ->
AssertionErrors.assertTrue("Expected empty body",
this.result.getBody(byte[].class) == null));
return new EntityExchangeResult<>(this.result, null);
}
@Override
public BodyContentSpec json(String expectedJson, JsonCompareMode compareMode) {
return json(expectedJson, JsonAssert.comparator(compareMode));
}
@Override
public BodyContentSpec json(String expectedJson, JsonComparator comparator) {
this.result.assertWithDiagnostics(() -> {
try {
comparator.assertIsMatch(expectedJson, getBodyAsString());
}
catch (Exception ex) {
throw new AssertionError("JSON parsing error", ex);
}
});
return this;
}
@Override
public BodyContentSpec xml(String expectedXml) {
this.result.assertWithDiagnostics(() -> {
try {
new XmlExpectationsHelper().assertXmlEqual(expectedXml, getBodyAsString());
}
catch (Exception ex) {
throw new AssertionError("XML parsing error", ex);
}
});
return this;
}
@Override
public JsonPathAssertions jsonPath(String expression) {
return new JsonPathAssertions(this, getBodyAsString(), expression, null);
}
@Override
public XpathAssertions xpath(String expression, @Nullable Map<String, String> namespaces, Object... args) {
return new XpathAssertions(this, expression, namespaces, args);
}
private String getBodyAsString() {
byte[] body = this.result.getResponseBody();
if (body == null || body.length == 0) {
return "";
}
Charset charset = Optional.ofNullable(this.result.getResponseHeaders().getContentType())
.map(MimeType::getCharset).orElse(StandardCharsets.UTF_8);
return new String(body, charset);
}
@Override
public EntityExchangeResult<byte[]> returnResult() {
return this.result;
}
}
private static class DefaultBodySpec<B, S extends BodySpec<B, S>> implements BodySpec<B, S> {
private final EntityExchangeResult<B> result;
public DefaultBodySpec(@Nullable EntityExchangeResult<B> result) {
this.result = Objects.requireNonNull(result, "exchangeResult must be non-null");
}
@Override
public EntityExchangeResult<B> returnResult() {
return this.result;
}
@Override
public <T extends S> T isEqualTo(B expected) {
this.result.assertWithDiagnostics(() ->
AssertionErrors.assertEquals("Response body", expected, this.result.getResponseBody()));
return self();
}
@Override
@SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129
public <T extends S, R> T value(Function<B, R> bodyMapper, Matcher<? super R> matcher) {
this.result.assertWithDiagnostics(() -> {
B body = this.result.getResponseBody();
MatcherAssert.assertThat(bodyMapper.apply(body), matcher);
});
return self();
}
@Override
public <T extends S> T value(Consumer<B> consumer) {
this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody()));
return self();
}
@Override
public <T extends S> T consumeWith(Consumer<EntityExchangeResult<B>> consumer) {
this.result.assertWithDiagnostics(() -> consumer.accept(this.result));
return self();
}
@SuppressWarnings("unchecked")
private <T extends S> T self() {
return (T) this;
}
}
}
@@ -0,0 +1,89 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.util.function.Consumer;
import org.springframework.http.HttpHeaders;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriBuilderFactory;
/**
* Default implementation of {@link RestTestClient.Builder}.
* @author Rob Worsnop
* @param <B> the type of the builder
*/
class DefaultRestTestClientBuilder<B extends RestTestClient.Builder<B>> implements RestTestClient.Builder<B> {
protected final RestClient.Builder restClientBuilder;
DefaultRestTestClientBuilder() {
this.restClientBuilder = RestClient.builder();
}
DefaultRestTestClientBuilder(RestClient.Builder restClientBuilder) {
this.restClientBuilder = restClientBuilder;
}
@Override
public RestTestClient.Builder<B> apply(Consumer<RestTestClient.Builder<B>> builderConsumer) {
builderConsumer.accept(this);
return this;
}
@Override
public RestTestClient.Builder<B> baseUrl(String baseUrl) {
this.restClientBuilder.baseUrl(baseUrl);
return this;
}
@Override
public RestTestClient.Builder<B> defaultCookie(String cookieName, String... cookieValues) {
this.restClientBuilder.defaultCookie(cookieName, cookieValues);
return this;
}
@Override
public RestTestClient.Builder<B> defaultCookies(Consumer<MultiValueMap<String, String>> cookiesConsumer) {
this.restClientBuilder.defaultCookies(cookiesConsumer);
return this;
}
@Override
public RestTestClient.Builder<B> defaultHeader(String headerName, String... headerValues) {
this.restClientBuilder.defaultHeader(headerName, headerValues);
return this;
}
@Override
public RestTestClient.Builder<B> defaultHeaders(Consumer<HttpHeaders> headersConsumer) {
this.restClientBuilder.defaultHeaders(headersConsumer);
return this;
}
@Override
public RestTestClient.Builder<B> uriBuilderFactory(UriBuilderFactory uriFactory) {
this.restClientBuilder.uriBuilderFactory(uriFactory);
return this;
}
@Override
public RestTestClient build() {
return new DefaultRestTestClient(this.restClientBuilder);
}
}
@@ -0,0 +1,46 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import org.jspecify.annotations.Nullable;
/**
* {@code ExchangeResult} sub-class that exposes the response body fully
* extracted to a representation of type {@code <T>}.
*
* @author Rob Worsnop
* @param <T> the response body type
*/
public class EntityExchangeResult<T> extends ExchangeResult {
private final @Nullable T body;
EntityExchangeResult(ExchangeResult result, @Nullable T body) {
super(result);
this.body = body;
}
/**
* Return the entity extracted from the response body.
*/
public @Nullable T getResponseBody() {
return this.body;
}
}
@@ -0,0 +1,135 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.io.IOException;
import java.net.HttpCookie;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseCookie;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse;
/**
* Container for request and response details for exchanges performed through
* {@link RestTestClient}.
*
* @author Rob Worsnop
*/
public class ExchangeResult {
private static final Pattern SAME_SITE_PATTERN = Pattern.compile("(?i).*SameSite=(Strict|Lax|None).*");
private static final Pattern PARTITIONED_PATTERN = Pattern.compile("(?i).*;\\s*Partitioned(\\s*;.*|\\s*)$");
private static final Log logger = LogFactory.getLog(ExchangeResult.class);
/** Ensure single logging; for example, for expectAll. */
private boolean diagnosticsLogged;
private final ConvertibleClientHttpResponse clientResponse;
ExchangeResult(@Nullable ConvertibleClientHttpResponse clientResponse) {
this.clientResponse = Objects.requireNonNull(clientResponse, "clientResponse must be non-null");
}
ExchangeResult(ExchangeResult result) {
this(result.clientResponse);
this.diagnosticsLogged = result.diagnosticsLogged;
}
public HttpStatusCode getStatus() {
try {
return this.clientResponse.getStatusCode();
}
catch (IOException ex) {
throw new AssertionError(ex);
}
}
public HttpHeaders getResponseHeaders() {
return this.clientResponse.getHeaders();
}
@Nullable
public <T> T getBody(Class<T> bodyType) {
return this.clientResponse.bodyTo(bodyType);
}
@Nullable
public <T> T getBody(ParameterizedTypeReference<T> bodyType) {
return this.clientResponse.bodyTo(bodyType);
}
/**
* Execute the given Runnable, catch any {@link AssertionError}, log details
* about the request and response at ERROR level under the class log
* category, and after that re-throw the error.
*/
public void assertWithDiagnostics(Runnable assertion) {
try {
assertion.run();
}
catch (AssertionError ex) {
if (!this.diagnosticsLogged && logger.isErrorEnabled()) {
this.diagnosticsLogged = true;
logger.error("Request details for assertion failure:\n" + this);
}
throw ex;
}
}
/**
* Return response cookies received from the server.
*/
public MultiValueMap<String, ResponseCookie> getResponseCookies() {
return Optional.ofNullable(this.clientResponse.getHeaders().get(HttpHeaders.SET_COOKIE)).orElse(List.of()).stream()
.flatMap(header -> {
Matcher matcher = SAME_SITE_PATTERN.matcher(header);
String sameSite = (matcher.matches() ? matcher.group(1) : null);
boolean partitioned = PARTITIONED_PATTERN.matcher(header).matches();
return HttpCookie.parse(header).stream().map(cookie -> toResponseCookie(cookie, sameSite, partitioned));
})
.collect(LinkedMultiValueMap::new,
(cookies, cookie) -> cookies.add(cookie.getName(), cookie),
LinkedMultiValueMap::addAll);
}
private static ResponseCookie toResponseCookie(HttpCookie cookie, @Nullable String sameSite, boolean partitioned) {
return ResponseCookie.from(cookie.getName(), cookie.getValue())
.domain(cookie.getDomain())
.httpOnly(cookie.isHttpOnly())
.maxAge(cookie.getMaxAge())
.path(cookie.getPath())
.secure(cookie.getSecure())
.sameSite(sameSite)
.partitioned(partitioned)
.build();
}
}
@@ -0,0 +1,311 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import org.hamcrest.Matcher;
import org.jspecify.annotations.Nullable;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.CollectionUtils;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.springframework.test.util.AssertionErrors.assertEquals;
import static org.springframework.test.util.AssertionErrors.assertNotNull;
import static org.springframework.test.util.AssertionErrors.assertTrue;
import static org.springframework.test.util.AssertionErrors.fail;
/**
* Assertions on headers of the response.
*
* @author Rob Worsnop
* @see RestTestClient.ResponseSpec#expectHeader()
*/
public class HeaderAssertions {
private final ExchangeResult exchangeResult;
private final RestTestClient.ResponseSpec responseSpec;
public HeaderAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) {
this.exchangeResult = exchangeResult;
this.responseSpec = responseSpec;
}
/**
* Expect a header with the given name to match the specified values.
*/
public RestTestClient.ResponseSpec valueEquals(String headerName, String... values) {
return assertHeader(headerName, Arrays.asList(values), getHeaders().getOrEmpty(headerName));
}
/**
* Expect a header with the given name to match the given long value.
*/
public RestTestClient.ResponseSpec valueEquals(String headerName, long value) {
String actual = getHeaders().getFirst(headerName);
this.exchangeResult.assertWithDiagnostics(() ->
assertNotNull("Response does not contain header '" + headerName + "'", actual));
return assertHeader(headerName, value, Long.parseLong(actual));
}
/**
* Expect a header with the given name to match the specified long value
* parsed into a date using the preferred date format described in RFC 7231.
* <p>An {@link AssertionError} is thrown if the response does not contain
* the specified header, or if the supplied {@code value} does not match the
* primary header value.
*/
public RestTestClient.ResponseSpec valueEqualsDate(String headerName, long value) {
this.exchangeResult.assertWithDiagnostics(() -> {
String headerValue = getHeaders().getFirst(headerName);
assertNotNull("Response does not contain header '" + headerName + "'", headerValue);
HttpHeaders headers = new HttpHeaders();
headers.setDate("expected", value);
headers.set("actual", headerValue);
assertEquals(getMessage(headerName) + "='" + headerValue + "' " +
"does not match expected value '" + headers.getFirst("expected") + "'",
headers.getFirstDate("expected"), headers.getFirstDate("actual"));
});
return this.responseSpec;
}
/**
* Match the first value of the response header with a regex.
* @param name the header name
* @param pattern the regex pattern
*/
public RestTestClient.ResponseSpec valueMatches(String name, String pattern) {
String value = getRequiredValue(name);
String message = getMessage(name) + "=[" + value + "] does not match [" + pattern + "]";
this.exchangeResult.assertWithDiagnostics(() -> assertTrue(message, value.matches(pattern)));
return this.responseSpec;
}
/**
* Match all values of the response header with the given regex
* patterns which are applied to the values of the header in the
* same order. Note that the number of patterns must match the
* number of actual values.
* @param name the header name
* @param patterns one or more regex patterns, one per expected value
*/
public RestTestClient.ResponseSpec valuesMatch(String name, String... patterns) {
List<String> values = getRequiredValues(name);
this.exchangeResult.assertWithDiagnostics(() -> {
assertTrue(
getMessage(name) + " has fewer or more values " + values +
" than number of patterns to match with " + Arrays.toString(patterns),
values.size() == patterns.length);
for (int i = 0; i < values.size(); i++) {
String value = values.get(i);
String pattern = patterns[i];
assertTrue(
getMessage(name) + "[" + i + "]='" + value + "' does not match '" + pattern + "'",
value.matches(pattern));
}
});
return this.responseSpec;
}
/**
* Assert the first value of the response header with a Hamcrest {@link Matcher}.
* @param name the header name
* @param matcher the matcher to use
*/
public RestTestClient.ResponseSpec value(String name, Matcher<? super String> matcher) {
String value = getHeaders().getFirst(name);
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name);
assertThat(message, value, matcher);
});
return this.responseSpec;
}
/**
* Assert all values of the response header with a Hamcrest {@link Matcher}.
* @param name the header name
* @param matcher the matcher to use
*/
public RestTestClient.ResponseSpec values(String name, Matcher<? super Iterable<String>> matcher) {
List<String> values = getHeaders().get(name);
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name);
assertThat(message, values, matcher);
});
return this.responseSpec;
}
/**
* Consume the first value of the named response header.
* @param name the header name
* @param consumer the consumer to use
*/
public RestTestClient.ResponseSpec value(String name, Consumer<String> consumer) {
String value = getRequiredValue(name);
this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value));
return this.responseSpec;
}
/**
* Consume all values of the named response header.
* @param name the header name
* @param consumer the consumer to use
*/
public RestTestClient.ResponseSpec values(String name, Consumer<List<String>> consumer) {
List<String> values = getRequiredValues(name);
this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(values));
return this.responseSpec;
}
/**
* Expect that the header with the given name is present.
*/
public RestTestClient.ResponseSpec exists(String name) {
if (!this.exchangeResult.getResponseHeaders().containsHeader(name)) {
String message = getMessage(name) + " does not exist";
this.exchangeResult.assertWithDiagnostics(() -> fail(message));
}
return this.responseSpec;
}
/**
* Expect that the header with the given name is not present.
*/
public RestTestClient.ResponseSpec doesNotExist(String name) {
if (getHeaders().containsHeader(name)) {
String message = getMessage(name) + " exists with value=[" + getHeaders().getFirst(name) + "]";
this.exchangeResult.assertWithDiagnostics(() -> fail(message));
}
return this.responseSpec;
}
/**
* Expect a "Cache-Control" header with the given value.
*/
public RestTestClient.ResponseSpec cacheControl(CacheControl cacheControl) {
return assertHeader("Cache-Control", cacheControl.getHeaderValue(), getHeaders().getCacheControl());
}
/**
* Expect a "Content-Disposition" header with the given value.
*/
public RestTestClient.ResponseSpec contentDisposition(ContentDisposition contentDisposition) {
return assertHeader("Content-Disposition", contentDisposition, getHeaders().getContentDisposition());
}
/**
* Expect a "Content-Length" header with the given value.
*/
public RestTestClient.ResponseSpec contentLength(long contentLength) {
return assertHeader("Content-Length", contentLength, getHeaders().getContentLength());
}
/**
* Expect a "Content-Type" header with the given value.
*/
public RestTestClient.ResponseSpec contentType(MediaType mediaType) {
return assertHeader("Content-Type", mediaType, getHeaders().getContentType());
}
/**
* Expect a "Content-Type" header with the given value.
*/
public RestTestClient.ResponseSpec contentType(String mediaType) {
return contentType(MediaType.parseMediaType(mediaType));
}
/**
* Expect a "Content-Type" header compatible with the given value.
*/
public RestTestClient.ResponseSpec contentTypeCompatibleWith(MediaType mediaType) {
MediaType actual = getHeaders().getContentType();
String message = getMessage("Content-Type") + "=[" + actual + "] is not compatible with [" + mediaType + "]";
this.exchangeResult.assertWithDiagnostics(() ->
assertTrue(message, (actual != null && actual.isCompatibleWith(mediaType))));
return this.responseSpec;
}
/**
* Expect a "Content-Type" header compatible with the given value.
*/
public RestTestClient.ResponseSpec contentTypeCompatibleWith(String mediaType) {
return contentTypeCompatibleWith(MediaType.parseMediaType(mediaType));
}
/**
* Expect an "Expires" header with the given value.
*/
public RestTestClient.ResponseSpec expires(long expires) {
return assertHeader("Expires", expires, getHeaders().getExpires());
}
/**
* Expect a "Last-Modified" header with the given value.
*/
public RestTestClient.ResponseSpec lastModified(long lastModified) {
return assertHeader("Last-Modified", lastModified, getHeaders().getLastModified());
}
/**
* Expect a "Location" header with the given value.
*/
public RestTestClient.ResponseSpec location(String location) {
return assertHeader("Location", URI.create(location), getHeaders().getLocation());
}
private HttpHeaders getHeaders() {
return this.exchangeResult.getResponseHeaders();
}
private String getRequiredValue(String name) {
return getRequiredValues(name).get(0);
}
private List<String> getRequiredValues(String name) {
List<String> values = getHeaders().get(name);
if (!CollectionUtils.isEmpty(values)) {
return values;
}
else {
this.exchangeResult.assertWithDiagnostics(() -> fail(getMessage(name) + " not found"));
}
throw new IllegalStateException("This code path should not be reachable");
}
private RestTestClient.ResponseSpec assertHeader(String name, @Nullable Object expected, @Nullable Object actual) {
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name);
assertEquals(message, expected, actual);
});
return this.responseSpec;
}
private static String getMessage(String headerName) {
return "Response header '" + headerName + "'";
}
}
@@ -0,0 +1,205 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.util.function.Consumer;
import com.jayway.jsonpath.Configuration;
import org.hamcrest.Matcher;
import org.jspecify.annotations.Nullable;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.test.util.JsonPathExpectationsHelper;
import org.springframework.util.Assert;
/**
* <a href="https://github.com/jayway/JsonPath">JsonPath</a> assertions.
*
* @author Rob Worsnop
*
* @see <a href="https://github.com/jayway/JsonPath">https://github.com/jayway/JsonPath</a>
* @see JsonPathExpectationsHelper
*/
public class JsonPathAssertions {
private final RestTestClient.BodyContentSpec bodySpec;
private final String content;
private final JsonPathExpectationsHelper pathHelper;
JsonPathAssertions(RestTestClient.BodyContentSpec spec, String content, String expression, @Nullable Configuration configuration) {
Assert.hasText(expression, "expression must not be null or empty");
this.bodySpec = spec;
this.content = content;
this.pathHelper = new JsonPathExpectationsHelper(expression, configuration);
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValue(String, Object)}.
*/
public RestTestClient.BodyContentSpec isEqualTo(Object expectedValue) {
this.pathHelper.assertValue(this.content, expectedValue);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#exists(String)}.
*/
public RestTestClient.BodyContentSpec exists() {
this.pathHelper.exists(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#doesNotExist(String)}.
*/
public RestTestClient.BodyContentSpec doesNotExist() {
this.pathHelper.doesNotExist(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsEmpty(String)}.
*/
public RestTestClient.BodyContentSpec isEmpty() {
this.pathHelper.assertValueIsEmpty(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsNotEmpty(String)}.
*/
public RestTestClient.BodyContentSpec isNotEmpty() {
this.pathHelper.assertValueIsNotEmpty(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#hasJsonPath}.
*/
public RestTestClient.BodyContentSpec hasJsonPath() {
this.pathHelper.hasJsonPath(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#doesNotHaveJsonPath}.
*/
public RestTestClient.BodyContentSpec doesNotHaveJsonPath() {
this.pathHelper.doesNotHaveJsonPath(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsBoolean(String)}.
*/
public RestTestClient.BodyContentSpec isBoolean() {
this.pathHelper.assertValueIsBoolean(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsNumber(String)}.
*/
public RestTestClient.BodyContentSpec isNumber() {
this.pathHelper.assertValueIsNumber(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsArray(String)}.
*/
public RestTestClient.BodyContentSpec isArray() {
this.pathHelper.assertValueIsArray(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsMap(String)}.
*/
public RestTestClient.BodyContentSpec isMap() {
this.pathHelper.assertValueIsMap(this.content);
return this.bodySpec;
}
/**
* Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher)}.
*/
public <T> RestTestClient.BodyContentSpec value(Matcher<? super T> matcher) {
this.pathHelper.assertValue(this.content, matcher);
return this.bodySpec;
}
/**
* Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}.
*/
public <T> RestTestClient.BodyContentSpec value(Class<T> targetType, Matcher<? super T> matcher) {
this.pathHelper.assertValue(this.content, matcher, targetType);
return this.bodySpec;
}
/**
* Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, ParameterizedTypeReference)}.
*/
public <T> RestTestClient.BodyContentSpec value(ParameterizedTypeReference<T> targetType, Matcher<? super T> matcher) {
this.pathHelper.assertValue(this.content, matcher, targetType);
return this.bodySpec;
}
/**
* Consume the result of the JSONPath evaluation.
*/
@SuppressWarnings("unchecked")
public <T> RestTestClient.BodyContentSpec value(Consumer<T> consumer) {
Object value = this.pathHelper.evaluateJsonPath(this.content);
consumer.accept((T) value);
return this.bodySpec;
}
/**
* Consume the result of the JSONPath evaluation and provide a target class.
*/
public <T> RestTestClient.BodyContentSpec value(Class<T> targetType, Consumer<T> consumer) {
T value = this.pathHelper.evaluateJsonPath(this.content, targetType);
consumer.accept(value);
return this.bodySpec;
}
/**
* Consume the result of the JSONPath evaluation and provide a parameterized type.
*/
public <T> RestTestClient.BodyContentSpec value(ParameterizedTypeReference<T> targetType, Consumer<T> consumer) {
T value = this.pathHelper.evaluateJsonPath(this.content, targetType);
consumer.accept(value);
return this.bodySpec;
}
@Override
public boolean equals(@Nullable Object obj) {
throw new AssertionError("Object#equals is disabled " +
"to avoid being used in error instead of JsonPathAssertions#isEqualTo(String).");
}
@Override
public int hashCode() {
return super.hashCode();
}
}
@@ -0,0 +1,133 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import jakarta.servlet.http.Cookie;
import org.jspecify.annotations.Nullable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.mock.http.client.MockClientHttpRequest;
import org.springframework.mock.http.client.MockClientHttpResponse;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
/**
* A {@link ClientHttpRequestFactory} for requests executed via {@link MockMvc}.
*
* @author Rossen Stoyanchev
* @author Rob Worsnop
* @since 7.0
*/
class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory {
private final MockMvc mockMvc;
MockMvcClientHttpRequestFactory(MockMvc mockMvc) {
Assert.notNull(mockMvc, "MockMvc must not be null");
this.mockMvc = mockMvc;
}
@Override
public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) {
return new MockClientHttpRequest(httpMethod, uri) {
@Override
public ClientHttpResponse executeInternal() {
return getClientHttpResponse(httpMethod, uri, getHeaders(), getBodyAsBytes());
}
};
}
private ClientHttpResponse getClientHttpResponse(
HttpMethod httpMethod, URI uri, HttpHeaders requestHeaders, byte[] requestBody) {
try {
Cookie[] cookies = parseCookies(requestHeaders.get(HttpHeaders.COOKIE));
MockHttpServletRequestBuilder requestBuilder = request(httpMethod, uri)
.content(requestBody).headers(requestHeaders);
if (cookies.length > 0) {
requestBuilder.cookie(cookies);
}
MockHttpServletResponse servletResponse = this.mockMvc
.perform(requestBuilder)
.andReturn()
.getResponse();
HttpStatusCode status = HttpStatusCode.valueOf(servletResponse.getStatus());
byte[] body = servletResponse.getContentAsByteArray();
if (body.length == 0) {
String error = servletResponse.getErrorMessage();
if (StringUtils.hasLength(error)) {
// sendError message as default body
body = error.getBytes(StandardCharsets.UTF_8);
}
}
MockClientHttpResponse clientResponse = new MockClientHttpResponse(body, status);
clientResponse.getHeaders().putAll(getResponseHeaders(servletResponse));
return clientResponse;
}
catch (Exception ex) {
byte[] body = ex.toString().getBytes(StandardCharsets.UTF_8);
return new MockClientHttpResponse(body, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
private static Cookie[] parseCookies(@Nullable List<String> headerValues) {
if (headerValues == null) {
return new Cookie[0];
}
return headerValues.stream()
.flatMap(header -> StringUtils.commaDelimitedListToSet(header).stream())
.map(MockMvcClientHttpRequestFactory::parseCookie)
.toArray(Cookie[]::new);
}
private static Cookie parseCookie(String cookie) {
String[] parts = StringUtils.split(cookie, "=");
Assert.isTrue(parts != null && parts.length == 2, "Invalid cookie: '" + cookie + "'");
return new Cookie(parts[0], parts[1]);
}
private HttpHeaders getResponseHeaders(MockHttpServletResponse response) {
HttpHeaders headers = new HttpHeaders();
for (String name : response.getHeaderNames()) {
List<String> values = response.getHeaders(name);
for (String value : values) {
headers.add(name, value);
}
}
return headers;
}
}
@@ -0,0 +1,656 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.net.URI;
import java.nio.charset.Charset;
import java.time.ZonedDateTime;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
import org.hamcrest.Matcher;
import org.jspecify.annotations.Nullable;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.test.json.JsonComparator;
import org.springframework.test.json.JsonCompareMode;
import org.springframework.test.json.JsonComparison;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MockMvcBuilder;
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder;
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.util.UriBuilder;
import org.springframework.web.util.UriBuilderFactory;
/**
* Client for testing web servers.
*
* @author Rob Worsnop
*/
public interface RestTestClient {
/**
* The name of a request header used to assign a unique id to every request
* performed through the {@code RestTestClient}. This can be useful for
* storing contextual information at all phases of request processing (for example,
* from a server-side component) under that id and later to look up
* that information once an {@link ExchangeResult} is available.
*/
String RESTTESTCLIENT_REQUEST_ID = "RestTestClient-Request-Id";
/**
* Prepare an HTTP GET request.
* @return a spec for specifying the target URL
*/
RequestHeadersUriSpec<?> get();
/**
* Prepare an HTTP HEAD request.
* @return a spec for specifying the target URL
*/
RequestHeadersUriSpec<?> head();
/**
* Prepare an HTTP POST request.
* @return a spec for specifying the target URL
*/
RequestBodyUriSpec post();
/**
* Prepare an HTTP PUT request.
* @return a spec for specifying the target URL
*/
RequestBodyUriSpec put();
/**
* Prepare an HTTP PATCH request.
* @return a spec for specifying the target URL
*/
RequestBodyUriSpec patch();
/**
* Prepare an HTTP DELETE request.
* @return a spec for specifying the target URL
*/
RequestHeadersUriSpec<?> delete();
/**
* Prepare an HTTP OPTIONS request.
* @return a spec for specifying the target URL
*/
RequestHeadersUriSpec<?> options();
/**
* Prepare a request for the specified {@code HttpMethod}.
* @return a spec for specifying the target URL
*/
RequestBodyUriSpec method(HttpMethod method);
/**
* Return a builder to mutate properties of this test client.
*/
<B extends Builder<B>> Builder<B> mutate();
/**
* Begin creating a {@link RestTestClient} by providing the {@code @Controller}
* instance(s) to handle requests with.
* <p>Internally this is delegated to and equivalent to using
* {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)}
* to initialize {@link MockMvc}.
*/
static MockServerBuilder<StandaloneMockMvcBuilder> standaloneSetup(Object... controllers) {
StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(controllers);
return new DefaultMockServerBuilder<>(builder);
}
/**
* Begin creating a {@link RestTestClient} by providing the {@link RouterFunction}
* instance(s) to handle requests with.
* <p>Internally this is delegated to and equivalent to using
* {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#routerFunctions(RouterFunction[])}
* to initialize {@link MockMvc}.
*/
static MockServerBuilder<RouterFunctionMockMvcBuilder> bindToRouterFunction(RouterFunction<?>... routerFunctions) {
RouterFunctionMockMvcBuilder builder = MockMvcBuilders.routerFunctions(routerFunctions);
return new DefaultMockServerBuilder<>(builder);
}
/**
* Begin creating a {@link RestTestClient} by providing a
* {@link WebApplicationContext} with Spring MVC infrastructure and
* controllers.
* <p>Internally this is delegated to and equivalent to using
* {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#webAppContextSetup(WebApplicationContext)}
* to initialize {@code MockMvc}.
*/
static MockServerBuilder<DefaultMockMvcBuilder> bindToApplicationContext(WebApplicationContext context) {
DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(context);
return new DefaultMockServerBuilder<>(builder);
}
/**
* Begin creating a {@link RestTestClient} by providing an already
* initialized {@link MockMvc} instance to use as the server.
*/
static <B extends Builder<B>> Builder<B> bindTo(MockMvc mockMvc) {
ClientHttpRequestFactory requestFactory = new MockMvcClientHttpRequestFactory(mockMvc);
return RestTestClient.bindToServer(requestFactory);
}
/**
* This server setup option allows you to connect to a live server through
* a client connector.
* <p><pre class="code">
* RestTestClient client = RestTestClient.bindToServer()
* .baseUrl("http://localhost:8080")
* .build();
* </pre>
* @return chained API to customize client config
*/
static <B extends Builder<B>> Builder<B> bindToServer() {
return new DefaultRestTestClientBuilder<>();
}
/**
* A variant of {@link #bindToServer()} with a pre-configured request factory.
* @return chained API to customize client config
*/
static <B extends Builder<B>> Builder<B> bindToServer(ClientHttpRequestFactory requestFactory) {
return new DefaultRestTestClientBuilder<>(RestClient.builder().requestFactory(requestFactory));
}
/**
* Specification for providing request headers and the URI of a request.
*
* @param <S> a self reference to the spec type
*/
interface RequestHeadersUriSpec<S extends RequestHeadersSpec<S>> extends UriSpec<S>, RequestHeadersSpec<S> {
}
/**
* Specification for providing the body and the URI of a request.
*/
interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpec<RequestBodySpec> {
}
/**
* Chained API for applying assertions to a response.
*/
interface ResponseSpec {
/**
* Assertions on the response status.
*/
StatusAssertions expectStatus();
/**
* Consume and decode the response body to {@code byte[]} and then apply
* assertions on the raw content (for example, isEmpty, JSONPath, etc.).
*/
BodyContentSpec expectBody();
/**
* Consume and decode the response body to a single object of type
* {@code <B>} and then apply assertions.
* @param bodyType the expected body type
*/
<B> BodySpec<B, ?> expectBody(Class<B> bodyType);
/**
* Alternative to {@link #expectBody(Class)} that accepts information
* about a target type with generics.
*/
<B> BodySpec<B, ?> expectBody(ParameterizedTypeReference<B> bodyType);
/**
* Assertions on the cookies of the response.
*/
CookieAssertions expectCookie();
/**
* Assertions on the headers of the response.
*/
HeaderAssertions expectHeader();
/**
* Apply multiple assertions to a response with the given
* {@linkplain RestTestClient.ResponseSpec.ResponseSpecConsumer consumers}, with the guarantee that
* all assertions will be applied even if one or more assertions fails
* with an exception.
* <p>If a single {@link Error} or {@link RuntimeException} is thrown,
* it will be rethrown.
* <p>If multiple exceptions are thrown, this method will throw an
* {@link AssertionError} whose error message is a summary of all the
* exceptions. In addition, each exception will be added as a
* {@linkplain Throwable#addSuppressed(Throwable) suppressed exception} to
* the {@code AssertionError}.
* <p>This feature is similar to the {@code SoftAssertions} support in
* AssertJ and the {@code assertAll()} support in JUnit Jupiter.
*
* <h4>Example</h4>
* <pre class="code">
* restTestClient.get().uri("/hello").exchange()
* .expectAll(
* responseSpec -&gt; responseSpec.expectStatus().isOk(),
* responseSpec -&gt; responseSpec.expectBody(String.class).isEqualTo("Hello, World!")
* );
* </pre>
* @param consumers the list of {@code ResponseSpec} consumers
*/
ResponseSpec expectAll(ResponseSpecConsumer... consumers);
/**
* Exit the chained flow in order to consume the response body
* externally.
*/
<T> EntityExchangeResult<T> returnResult(Class<T> elementClass);
/**
* Alternative to {@link #returnResult(Class)} that accepts information
* about a target type with generics.
*/
<T> EntityExchangeResult<T> returnResult(ParameterizedTypeReference<T> elementTypeRef);
/**
* {@link Consumer} of a {@link RestTestClient.ResponseSpec}.
* @see RestTestClient.ResponseSpec#expectAll(RestTestClient.ResponseSpec.ResponseSpecConsumer...)
*/
@FunctionalInterface
interface ResponseSpecConsumer extends Consumer<ResponseSpec> {
}
}
/**
* Spec for expectations on the response body content.
*/
interface BodyContentSpec {
/**
* Assert the response body is empty and return the exchange result.
*/
EntityExchangeResult<Void> isEmpty();
/**
* Parse the expected and actual response content as JSON and perform a
* comparison verifying that they contain the same attribute-value pairs
* regardless of formatting with <em>lenient</em> checking (extensible
* and non-strict array ordering).
* <p>Use of this method requires the
* <a href="https://jsonassert.skyscreamer.org/">JSONassert</a> library
* to be on the classpath.
* @param expectedJson the expected JSON content
* @see #json(String, JsonCompareMode)
*/
default BodyContentSpec json(String expectedJson) {
return json(expectedJson, JsonCompareMode.LENIENT);
}
/**
* Parse the expected and actual response content as JSON and perform a
* comparison using the given {@linkplain JsonCompareMode mode}. If the
* comparison failed, throws an {@link AssertionError} with the message
* of the {@link JsonComparison}.
* <p>Use of this method requires the
* <a href="https://jsonassert.skyscreamer.org/">JSONassert</a> library
* to be on the classpath.
* @param expectedJson the expected JSON content
* @param compareMode the compare mode
* @see #json(String)
*/
BodyContentSpec json(String expectedJson, JsonCompareMode compareMode);
/**
* Parse the expected and actual response content as JSON and perform a
* comparison using the given {@link JsonComparator}. If the comparison
* failed, throws an {@link AssertionError} with the message of the
* {@link JsonComparison}.
* @param expectedJson the expected JSON content
* @param comparator the comparator to use
*/
BodyContentSpec json(String expectedJson, JsonComparator comparator);
/**
* Parse expected and actual response content as XML and assert that
* the two are "similar", i.e. they contain the same elements and
* attributes regardless of order.
* <p>Use of this method requires the
* <a href="https://github.com/xmlunit/xmlunit">XMLUnit</a> library on
* the classpath.
* @param expectedXml the expected XML content.
* @see org.springframework.test.util.XmlExpectationsHelper#assertXmlEqual(String, String)
*/
BodyContentSpec xml(String expectedXml);
/**
* Access to response body assertions using an XPath expression to
* inspect a specific subset of the body.
* <p>The XPath expression can be a parameterized string using
* formatting specifiers as defined in {@link String#format}.
* @param expression the XPath expression
* @param args arguments to parameterize the expression
* @see #xpath(String, Map, Object...)
*/
default XpathAssertions xpath(String expression, Object... args) {
return xpath(expression, null, args);
}
/**
* Access to response body assertions with specific namespaces using an
* XPath expression to inspect a specific subset of the body.
* <p>The XPath expression can be a parameterized string using
* formatting specifiers as defined in {@link String#format}.
* @param expression the XPath expression
* @param namespaces the namespaces to use
* @param args arguments to parameterize the expression
*/
XpathAssertions xpath(String expression, @Nullable Map<String, String> namespaces, Object... args);
/**
* Access to response body assertions using a
* <a href="https://github.com/jayway/JsonPath">JsonPath</a> expression
* to inspect a specific subset of the body.
* @param expression the JsonPath expression
*/
JsonPathAssertions jsonPath(String expression);
/**
* Exit the chained API and return an {@code ExchangeResult} with the
* raw response content.
*/
EntityExchangeResult<byte[]> returnResult();
}
/**
* Spec for expectations on the response body decoded to a single Object.
*
* @param <S> a self reference to the spec type
* @param <B> the body type
*/
interface BodySpec<B, S extends BodySpec<B, S>> {
/**
* Transform the extracted the body with a function, for example, extracting a
* property, and assert the mapped value with a {@link Matcher}.
*/
<T extends S, R> T value(Function<B, R> bodyMapper, Matcher<? super R> matcher);
/**
* Assert the extracted body with a {@link Consumer}.
*/
<T extends S> T value(Consumer<B> consumer);
/**
* Assert the exchange result with the given {@link Consumer}.
*/
<T extends S> T consumeWith(Consumer<EntityExchangeResult<B>> consumer);
/**
* Exit the chained API and return an {@code EntityExchangeResult} with the
* decoded response content.
*/
EntityExchangeResult<B> returnResult();
/**
* Assert the extracted body is equal to the given value.
*/
<T extends S> T isEqualTo(B expected);
}
/**
* Specification for providing the URI of a request.
*
* @param <S> a self reference to the spec type
*/
interface UriSpec<S extends RequestHeadersSpec<?>> {
/**
* Specify the URI using an absolute, fully constructed {@link java.net.URI}.
* <p>If a {@link UriBuilderFactory} was configured for the client with
* a base URI, that base URI will <strong>not</strong> be applied to the
* supplied {@code java.net.URI}. If you wish to have a base URI applied to a
* {@code java.net.URI} you must invoke either {@link #uri(String, Object...)}
* or {@link #uri(String, Map)} &mdash; for example, {@code uri(myUri.toString())}.
* @return spec to add headers or perform the exchange
*/
S uri(URI uri);
/**
* Specify the URI for the request using a URI template and URI variables.
* <p>If a {@link UriBuilderFactory} was configured for the client (for example,
* with a base URI) it will be used to expand the URI template.
* @return spec to add headers or perform the exchange
*/
S uri(String uri, Object... uriVariables);
/**
* Specify the URI for the request using a URI template and URI variables.
* <p>If a {@link UriBuilderFactory} was configured for the client (for example,
* with a base URI) it will be used to expand the URI template.
* @return spec to add headers or perform the exchange
*/
S uri(String uri, Map<String, ?> uriVariables);
/**
* Build the URI for the request with a {@link UriBuilder} obtained
* through the {@link UriBuilderFactory} configured for this client.
* @return spec to add headers or perform the exchange
*/
S uri(Function<UriBuilder, URI> uriFunction);
}
/**
* Specification for adding request headers and performing an exchange.
*
* @param <S> a self reference to the spec type
*/
interface RequestHeadersSpec<S extends RequestHeadersSpec<S>> {
/**
* Set the list of acceptable {@linkplain MediaType media types}, as
* specified by the {@code Accept} header.
* @param acceptableMediaTypes the acceptable media types
* @return the same instance
*/
S accept(MediaType... acceptableMediaTypes);
/**
* Set the list of acceptable {@linkplain Charset charsets}, as specified
* by the {@code Accept-Charset} header.
* @param acceptableCharsets the acceptable charsets
* @return the same instance
*/
S acceptCharset(Charset... acceptableCharsets);
/**
* Add a cookie with the given name and value.
* @param name the cookie name
* @param value the cookie value
* @return the same instance
*/
S cookie(String name, String value);
/**
* Manipulate this request's cookies with the given consumer. The
* map provided to the consumer is "live", so that the consumer can be used to
* {@linkplain MultiValueMap#set(Object, Object) overwrite} existing header values,
* {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other
* {@link MultiValueMap} methods.
* @param cookiesConsumer a function that consumes the cookies map
* @return this builder
*/
S cookies(Consumer<MultiValueMap<String, String>> cookiesConsumer);
/**
* Set the value of the {@code If-Modified-Since} header.
* <p>The date should be specified as the number of milliseconds since
* January 1, 1970 GMT.
* @param ifModifiedSince the new value of the header
* @return the same instance
*/
S ifModifiedSince(ZonedDateTime ifModifiedSince);
/**
* Set the values of the {@code If-None-Match} header.
* @param ifNoneMatches the new value of the header
* @return the same instance
*/
S ifNoneMatch(String... ifNoneMatches);
/**
* Add the given, single header value under the given name.
* @param headerName the header name
* @param headerValues the header value(s)
* @return the same instance
*/
S header(String headerName, String... headerValues);
/**
* Manipulate the request's headers with the given consumer. The
* headers provided to the consumer are "live", so that the consumer can be used to
* {@linkplain HttpHeaders#set(String, String) overwrite} existing header values,
* {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other
* {@link HttpHeaders} methods.
* @param headersConsumer a function that consumes the {@code HttpHeaders}
* @return this builder
*/
S headers(Consumer<HttpHeaders> headersConsumer);
/**
* Set the attribute with the given name to the given value.
* @param name the name of the attribute to add
* @param value the value of the attribute to add
* @return this builder
*/
S attribute(String name, Object value);
/**
* Manipulate the request attributes with the given consumer. The attributes provided to
* the consumer are "live", so that the consumer can be used to inspect attributes,
* remove attributes, or use any of the other map-provided methods.
* @param attributesConsumer a function that consumes the attributes
* @return this builder
*/
S attributes(Consumer<Map<String, Object>> attributesConsumer);
/**
* Perform the exchange without a request body.
* @return spec for decoding the response
*/
ResponseSpec exchange();
}
/**
* Specification for providing body of a request.
*/
interface RequestBodySpec extends RequestHeadersSpec<RequestBodySpec> {
/**
* Set the {@linkplain MediaType media type} of the body, as specified
* by the {@code Content-Type} header.
* @param contentType the content type
* @return the same instance
* @see HttpHeaders#setContentType(MediaType)
*/
RequestBodySpec contentType(MediaType contentType);
/**
* Set the body to the given {@code Object} value. This method invokes the
* {@link org.springframework.web.client.RestClient.RequestBodySpec#body(Object)} (Object)
* bodyValue} method on the underlying {@code RestClient}.
* @param body the value to write to the request body
* @return spec for further declaration of the request
*/
RequestHeadersSpec<?> body(Object body);
}
interface Builder<B extends Builder<B>> {
/**
* Apply the given {@code Consumer} to this builder instance.
* <p>This can be useful for applying pre-packaged customizations.
* @param builderConsumer the consumer to apply
*/
Builder<B> apply(Consumer<Builder<B>> builderConsumer);
/**
* Add the given cookie to all requests.
* @param cookieName the cookie name
* @param cookieValues the cookie values
*/
Builder<B> defaultCookie(String cookieName, String... cookieValues);
/**
* Manipulate the default cookies with the given consumer. The
* map provided to the consumer is "live", so that the consumer can be used to
* {@linkplain MultiValueMap#set(Object, Object) overwrite} existing header values,
* {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other
* {@link MultiValueMap} methods.
* @param cookiesConsumer a function that consumes the cookies map
* @return this builder
*/
Builder<B> defaultCookies(Consumer<MultiValueMap<String, String>> cookiesConsumer);
/**
* Add the given header to all requests that haven't added it.
* @param headerName the header name
* @param headerValues the header values
*/
Builder<B> defaultHeader(String headerName, String... headerValues);
/**
* Manipulate the default headers with the given consumer. The
* headers provided to the consumer are "live", so that the consumer can be used to
* {@linkplain HttpHeaders#set(String, String) overwrite} existing header values,
* {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other
* {@link HttpHeaders} methods.
* @param headersConsumer a function that consumes the {@code HttpHeaders}
* @return this builder
*/
Builder<B> defaultHeaders(Consumer<HttpHeaders> headersConsumer);
/**
* Provide a pre-configured {@link UriBuilderFactory} instance as an
* alternative to and effectively overriding {@link #baseUrl(String)}.
*/
Builder<B> uriBuilderFactory(UriBuilderFactory uriFactory);
/**
* Build the {@link RestTestClient} instance.
*/
RestTestClient build();
/**
* Configure a base URI as described in
* {@link RestClient#create(String)
* WebClient.create(String)}.
*/
Builder<B> baseUrl(String baseUrl);
}
interface MockServerBuilder<M extends MockMvcBuilder> extends Builder<MockServerBuilder<M>> {
MockServerBuilder<M> configureServer(Consumer<M> consumer);
}
}
@@ -0,0 +1,250 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.util.function.Consumer;
import org.hamcrest.Matcher;
import org.hamcrest.MatcherAssert;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.test.util.AssertionErrors;
import org.springframework.test.web.servlet.client.RestTestClient.ResponseSpec;
import static org.springframework.test.util.AssertionErrors.assertNotNull;
/**
* Assertions on the response status.
*
* @author Rob Worsnop
*
* @see ResponseSpec#expectStatus()
*/
public class StatusAssertions {
private final ExchangeResult exchangeResult;
private final ResponseSpec responseSpec;
public StatusAssertions(ExchangeResult exchangeResult, ResponseSpec responseSpec) {
this.exchangeResult = exchangeResult;
this.responseSpec = responseSpec;
}
/**
* Assert the response status as an {@link HttpStatusCode}.
*/
public RestTestClient.ResponseSpec isEqualTo(HttpStatusCode status) {
HttpStatusCode actual = this.exchangeResult.getStatus();
this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", status, actual));
return this.responseSpec;
}
/**
* Assert the response status as an integer.
*/
public RestTestClient.ResponseSpec isEqualTo(int status) {
return isEqualTo(HttpStatusCode.valueOf(status));
}
/**
* Assert the response status code is {@code HttpStatus.OK} (200).
*/
public RestTestClient.ResponseSpec isOk() {
return assertStatusAndReturn(HttpStatus.OK);
}
/**
* Assert the response status code is {@code HttpStatus.CREATED} (201).
*/
public RestTestClient.ResponseSpec isCreated() {
return assertStatusAndReturn(HttpStatus.CREATED);
}
/**
* Assert the response status code is {@code HttpStatus.ACCEPTED} (202).
*/
public RestTestClient.ResponseSpec isAccepted() {
return assertStatusAndReturn(HttpStatus.ACCEPTED);
}
/**
* Assert the response status code is {@code HttpStatus.NO_CONTENT} (204).
*/
public RestTestClient.ResponseSpec isNoContent() {
return assertStatusAndReturn(HttpStatus.NO_CONTENT);
}
/**
* Assert the response status code is {@code HttpStatus.FOUND} (302).
*/
public RestTestClient.ResponseSpec isFound() {
return assertStatusAndReturn(HttpStatus.FOUND);
}
/**
* Assert the response status code is {@code HttpStatus.SEE_OTHER} (303).
*/
public RestTestClient.ResponseSpec isSeeOther() {
return assertStatusAndReturn(HttpStatus.SEE_OTHER);
}
/**
* Assert the response status code is {@code HttpStatus.NOT_MODIFIED} (304).
*/
public RestTestClient.ResponseSpec isNotModified() {
return assertStatusAndReturn(HttpStatus.NOT_MODIFIED);
}
/**
* Assert the response status code is {@code HttpStatus.TEMPORARY_REDIRECT} (307).
*/
public RestTestClient.ResponseSpec isTemporaryRedirect() {
return assertStatusAndReturn(HttpStatus.TEMPORARY_REDIRECT);
}
/**
* Assert the response status code is {@code HttpStatus.PERMANENT_REDIRECT} (308).
*/
public RestTestClient.ResponseSpec isPermanentRedirect() {
return assertStatusAndReturn(HttpStatus.PERMANENT_REDIRECT);
}
/**
* Assert the response status code is {@code HttpStatus.BAD_REQUEST} (400).
*/
public RestTestClient.ResponseSpec isBadRequest() {
return assertStatusAndReturn(HttpStatus.BAD_REQUEST);
}
/**
* Assert the response status code is {@code HttpStatus.UNAUTHORIZED} (401).
*/
public RestTestClient.ResponseSpec isUnauthorized() {
return assertStatusAndReturn(HttpStatus.UNAUTHORIZED);
}
/**
* Assert the response status code is {@code HttpStatus.FORBIDDEN} (403).
* @since 5.0.2
*/
public RestTestClient.ResponseSpec isForbidden() {
return assertStatusAndReturn(HttpStatus.FORBIDDEN);
}
/**
* Assert the response status code is {@code HttpStatus.NOT_FOUND} (404).
*/
public RestTestClient.ResponseSpec isNotFound() {
return assertStatusAndReturn(HttpStatus.NOT_FOUND);
}
/**
* Assert the response error message.
*/
public RestTestClient.ResponseSpec reasonEquals(String reason) {
String actual = getReasonPhrase(this.exchangeResult.getStatus());
this.exchangeResult.assertWithDiagnostics(() ->
AssertionErrors.assertEquals("Response status reason", reason, actual));
return this.responseSpec;
}
private static String getReasonPhrase(HttpStatusCode statusCode) {
if (statusCode instanceof HttpStatus status) {
return status.getReasonPhrase();
}
else {
return "";
}
}
/**
* Assert the response status code is in the 1xx range.
*/
public RestTestClient.ResponseSpec is1xxInformational() {
return assertSeriesAndReturn(HttpStatus.Series.INFORMATIONAL);
}
/**
* Assert the response status code is in the 2xx range.
*/
public RestTestClient.ResponseSpec is2xxSuccessful() {
return assertSeriesAndReturn(HttpStatus.Series.SUCCESSFUL);
}
/**
* Assert the response status code is in the 3xx range.
*/
public RestTestClient.ResponseSpec is3xxRedirection() {
return assertSeriesAndReturn(HttpStatus.Series.REDIRECTION);
}
/**
* Assert the response status code is in the 4xx range.
*/
public RestTestClient.ResponseSpec is4xxClientError() {
return assertSeriesAndReturn(HttpStatus.Series.CLIENT_ERROR);
}
/**
* Assert the response status code is in the 5xx range.
*/
public RestTestClient.ResponseSpec is5xxServerError() {
return assertSeriesAndReturn(HttpStatus.Series.SERVER_ERROR);
}
/**
* Match the response status value with a Hamcrest matcher.
* @param matcher the matcher to use
* @since 5.1
*/
public RestTestClient.ResponseSpec value(Matcher<? super Integer> matcher) {
int actual = this.exchangeResult.getStatus().value();
this.exchangeResult.assertWithDiagnostics(() -> MatcherAssert.assertThat("Response status", actual, matcher));
return this.responseSpec;
}
/**
* Consume the response status value as an integer.
* @param consumer the consumer to use
* @since 5.1
*/
public RestTestClient.ResponseSpec value(Consumer<Integer> consumer) {
int actual = this.exchangeResult.getStatus().value();
this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(actual));
return this.responseSpec;
}
private ResponseSpec assertStatusAndReturn(HttpStatus expected) {
assertNotNull("exchangeResult unexpectedly null", this.exchangeResult);
HttpStatusCode actual = this.exchangeResult.getStatus();
this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", expected, actual));
return this.responseSpec;
}
private RestTestClient.ResponseSpec assertSeriesAndReturn(HttpStatus.Series expected) {
HttpStatusCode status = this.exchangeResult.getStatus();
HttpStatus.Series series = HttpStatus.Series.resolve(status.value());
this.exchangeResult.assertWithDiagnostics(() ->
AssertionErrors.assertEquals("Range for response status value " + status, expected, series));
return this.responseSpec;
}
}
@@ -0,0 +1,205 @@
/*
* Copyright 2002-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.test.web.servlet.client;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import javax.xml.xpath.XPathExpressionException;
import org.hamcrest.Matcher;
import org.jspecify.annotations.Nullable;
import org.springframework.http.HttpHeaders;
import org.springframework.test.util.XpathExpectationsHelper;
import org.springframework.util.Assert;
import org.springframework.util.MimeType;
/**
* XPath assertions for the {@link RestTestClient}.
*
* @author Rob Worsnop
*/
public class XpathAssertions {
private final RestTestClient.BodyContentSpec bodySpec;
private final XpathExpectationsHelper xpathHelper;
XpathAssertions(RestTestClient.BodyContentSpec spec,
String expression, @Nullable Map<String, String> namespaces, Object... args) {
this.bodySpec = spec;
this.xpathHelper = initXpathHelper(expression, namespaces, args);
}
private static XpathExpectationsHelper initXpathHelper(
String expression, @Nullable Map<String, String> namespaces, Object[] args) {
try {
return new XpathExpectationsHelper(expression, namespaces, args);
}
catch (XPathExpressionException ex) {
throw new AssertionError("XML parsing error", ex);
}
}
/**
* Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, String)}.
*/
public RestTestClient.BodyContentSpec isEqualTo(String expectedValue) {
return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), expectedValue));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Double)}.
*/
public RestTestClient.BodyContentSpec isEqualTo(Double expectedValue) {
return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), expectedValue));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertBoolean(byte[], String, boolean)}.
*/
public RestTestClient.BodyContentSpec isEqualTo(boolean expectedValue) {
return assertWith(() -> this.xpathHelper.assertBoolean(getContent(), getCharset(), expectedValue));
}
/**
* Delegates to {@link XpathExpectationsHelper#exists(byte[], String)}.
*/
public RestTestClient.BodyContentSpec exists() {
return assertWith(() -> this.xpathHelper.exists(getContent(), getCharset()));
}
/**
* Delegates to {@link XpathExpectationsHelper#doesNotExist(byte[], String)}.
*/
public RestTestClient.BodyContentSpec doesNotExist() {
return assertWith(() -> this.xpathHelper.doesNotExist(getContent(), getCharset()));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, int)}.
*/
public RestTestClient.BodyContentSpec nodeCount(int expectedCount) {
return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), expectedCount));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, Matcher)}.
*/
public RestTestClient.BodyContentSpec string(Matcher<? super String> matcher){
return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), matcher));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Matcher)}.
*/
public RestTestClient.BodyContentSpec number(Matcher<? super Double> matcher){
return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), matcher));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, Matcher)}.
*/
public RestTestClient.BodyContentSpec nodeCount(Matcher<? super Integer> matcher){
return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), matcher));
}
/**
* Consume the result of the XPath evaluation as a String.
*/
public RestTestClient.BodyContentSpec string(Consumer<String> consumer){
return assertWith(() -> {
String value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), String.class);
consumer.accept(value);
});
}
/**
* Consume the result of the XPath evaluation as a Double.
*/
public RestTestClient.BodyContentSpec number(Consumer<Double> consumer){
return assertWith(() -> {
Double value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Double.class);
consumer.accept(value);
});
}
/**
* Consume the count of nodes as result of the XPath evaluation.
*/
public RestTestClient.BodyContentSpec nodeCount(Consumer<Integer> consumer){
return assertWith(() -> {
Integer value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Integer.class);
consumer.accept(value);
});
}
private RestTestClient.BodyContentSpec assertWith(CheckedExceptionTask task) {
try {
task.run();
}
catch (Exception ex) {
throw new AssertionError("XML parsing error", ex);
}
return this.bodySpec;
}
private byte[] getContent() {
byte[] body = this.bodySpec.returnResult().getResponseBody();
Assert.notNull(body, "Expected body content");
return body;
}
private String getCharset() {
return Optional.of(this.bodySpec.returnResult())
.map(EntityExchangeResult::getResponseHeaders)
.map(HttpHeaders::getContentType)
.map(MimeType::getCharset)
.orElse(StandardCharsets.UTF_8)
.name();
}
@Override
public boolean equals(@Nullable Object obj) {
throw new AssertionError("Object#equals is disabled " +
"to avoid being used in error instead of XPathAssertions#isEqualTo(String).");
}
@Override
public int hashCode() {
return super.hashCode();
}
/**
* Lets us be able to use lambda expressions that could throw checked exceptions, since
* {@link XpathExpectationsHelper} throws {@link Exception} on its methods.
*/
private interface CheckedExceptionTask {
void run() throws Exception;
}
}