Browse Source

Initial support for Gateway Server MVC

Includes:
- FilterFunctions
- HandlerFunctions
- TestRestClient

Fixes gh-36
mvc-server
spencergibb 2 years ago committed by sgibb
parent
commit
adda25b1e7
No known key found for this signature in database
GPG Key ID: 7788A47380690861
  1. 1
      pom.xml
  2. 5
      spring-cloud-gateway-dependencies/pom.xml
  3. 48
      spring-cloud-gateway-server-mvc/pom.xml
  4. 45
      spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/FilterFunctions.java
  5. 530
      spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerRequestBuilder.java
  6. 125
      spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/HandlerFunctions.java
  7. 140
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java
  8. 224
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/CookieAssertions.java
  9. 566
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/DefaultTestRestClient.java
  10. 50
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/EntityExchangeResult.java
  11. 309
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/ExchangeResult.java
  12. 325
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HeaderAssertions.java
  13. 223
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpBinCompatibleController.java
  14. 193
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/JsonPathAssertions.java
  15. 354
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/LocalHostUriBuilderFactory.java
  16. 242
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/StatusAssertions.java
  17. 638
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestRestClient.java
  18. 214
      spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/XpathAssertions.java
  19. 0
      spring-cloud-gateway-server-mvc/src/test/resources/application.yml

1
pom.xml

@ -127,6 +127,7 @@ @@ -127,6 +127,7 @@
<module>spring-cloud-gateway-mvc</module>
<module>spring-cloud-gateway-webflux</module>
<module>spring-cloud-gateway-server</module>
<module>spring-cloud-gateway-server-mvc</module>
<module>spring-cloud-starter-gateway</module>
<module>spring-cloud-gateway-sample</module>
<module>spring-cloud-gateway-integration-tests</module>

5
spring-cloud-gateway-dependencies/pom.xml

@ -37,6 +37,11 @@ @@ -37,6 +37,11 @@
<artifactId>spring-cloud-gateway-server</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway-server-mvc</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>

48
spring-cloud-gateway-server-mvc/pom.xml

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway</artifactId>
<version>4.1.0-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>
<artifactId>spring-cloud-gateway-server-mvc</artifactId>
<packaging>jar</packaging>
<name>Spring Cloud Gateway Server MVC</name>
<description>Spring Cloud Gateway Server MVC</description>
<properties>
<main.basedir>${basedir}/..</main.basedir>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

45
spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/FilterFunctions.java

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
/*
* Copyright 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.cloud.gateway.server.mvc;
import java.net.URI;
import org.springframework.web.servlet.function.HandlerFilterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.util.UriComponentsBuilder;
public class FilterFunctions {
public static HandlerFilterFunction<ServerResponse, ServerResponse> addRequestHeader(String name,
String... values) {
return (request, next) -> {
ServerRequest modified = new GatewayServerRequestBuilder(request).header(name, values).build();
return next.handle(modified);
};
}
public static HandlerFilterFunction<ServerResponse, ServerResponse> prefixPath(String prefix) {
return (request, next) -> {
//TODO: template vars
String newPath = prefix + request.uri().getRawPath();
URI prefixedUri = UriComponentsBuilder.fromUri(request.uri()).replacePath(newPath).build().toUri();
ServerRequest modified = new GatewayServerRequestBuilder(request).uri(prefixedUri).build();
return next.handle(modified);
};
}
}

530
spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerRequestBuilder.java

@ -0,0 +1,530 @@ @@ -0,0 +1,530 @@
/*
* 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.cloud.gateway.server.mvc;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.Part;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRange;
import org.springframework.http.MediaType;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.util.UriBuilder;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Default {@link ServerRequest.Builder} implementation.
*
* @author Arjen Poutsma
* @since 5.2
*/
class GatewayServerRequestBuilder implements ServerRequest.Builder {
private final HttpServletRequest servletRequest;
private final List<HttpMessageConverter<?>> messageConverters;
private HttpMethod method;
private URI uri;
private final HttpHeaders headers = new HttpHeaders();
private final MultiValueMap<String, Cookie> cookies = new LinkedMultiValueMap<>();
private final Map<String, Object> attributes = new LinkedHashMap<>();
private final MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
@Nullable
private InetSocketAddress remoteAddress;
private byte[] body = new byte[0];
public GatewayServerRequestBuilder(ServerRequest other) {
Assert.notNull(other, "ServerRequest must not be null");
this.servletRequest = other.servletRequest();
this.messageConverters = new ArrayList<>(other.messageConverters());
this.method = other.method();
this.uri = other.uri();
headers(headers -> headers.addAll(other.headers().asHttpHeaders()));
cookies(cookies -> cookies.addAll(other.cookies()));
attributes(attributes -> attributes.putAll(other.attributes()));
params(params -> params.addAll(other.params()));
this.remoteAddress = other.remoteAddress().orElse(null);
}
@Override
public ServerRequest.Builder method(HttpMethod method) {
Assert.notNull(method, "HttpMethod must not be null");
this.method = method;
return this;
}
@Override
public ServerRequest.Builder uri(URI uri) {
Assert.notNull(uri, "URI must not be null");
this.uri = uri;
return this;
}
@Override
public ServerRequest.Builder header(String headerName, String... headerValues) {
Assert.notNull(headerName, "Header name must not be null");
for (String headerValue : headerValues) {
this.headers.add(headerName, headerValue);
}
return this;
}
@Override
public ServerRequest.Builder headers(Consumer<HttpHeaders> headersConsumer) {
Assert.notNull(headersConsumer, "Headers consumer must not be null");
headersConsumer.accept(this.headers);
return this;
}
@Override
public ServerRequest.Builder cookie(String name, String... values) {
Assert.notNull(name, "Cookie name must not be null");
for (String value : values) {
this.cookies.add(name, new Cookie(name, value));
}
return this;
}
@Override
public ServerRequest.Builder cookies(Consumer<MultiValueMap<String, Cookie>> cookiesConsumer) {
Assert.notNull(cookiesConsumer, "Cookies consumer must not be null");
cookiesConsumer.accept(this.cookies);
return this;
}
@Override
public ServerRequest.Builder body(byte[] body) {
Assert.notNull(body, "Body must not be null");
this.body = body;
return this;
}
@Override
public ServerRequest.Builder body(String body) {
Assert.notNull(body, "Body must not be null");
return body(body.getBytes(StandardCharsets.UTF_8));
}
@Override
public ServerRequest.Builder attribute(String name, Object value) {
Assert.notNull(name, "Name must not be null");
this.attributes.put(name, value);
return this;
}
@Override
public ServerRequest.Builder attributes(Consumer<Map<String, Object>> attributesConsumer) {
Assert.notNull(attributesConsumer, "Attributes consumer must not be null");
attributesConsumer.accept(this.attributes);
return this;
}
@Override
public ServerRequest.Builder param(String name, String... values) {
Assert.notNull(name, "Name must not be null");
for (String value : values) {
this.params.add(name, value);
}
return this;
}
@Override
public ServerRequest.Builder params(Consumer<MultiValueMap<String, String>> paramsConsumer) {
Assert.notNull(paramsConsumer, "Parameters consumer must not be null");
paramsConsumer.accept(this.params);
return this;
}
@Override
public ServerRequest.Builder remoteAddress(@Nullable InetSocketAddress remoteAddress) {
this.remoteAddress = remoteAddress;
return this;
}
@Override
public ServerRequest build() {
return new BuiltServerRequest(this.servletRequest, this.method, this.uri, this.headers, this.cookies,
this.attributes, this.params, this.remoteAddress, this.body, this.messageConverters);
}
static Class<?> bodyClass(Type type) {
if (type instanceof Class<?> clazz) {
return clazz;
}
if (type instanceof ParameterizedType parameterizedType &&
parameterizedType.getRawType() instanceof Class<?> rawType) {
return rawType;
}
return Object.class;
}
private static class BuiltServerRequest implements ServerRequest {
private final HttpMethod method;
private final URI uri;
private final HttpHeaders headers;
private final HttpServletRequest servletRequest;
private final MultiValueMap<String, Cookie> cookies;
private final Map<String, Object> attributes;
private final byte[] body;
private final List<HttpMessageConverter<?>> messageConverters;
private final MultiValueMap<String, String> params;
@Nullable
private final InetSocketAddress remoteAddress;
public BuiltServerRequest(HttpServletRequest servletRequest, HttpMethod method, URI uri,
HttpHeaders headers, MultiValueMap<String, Cookie> cookies,
Map<String, Object> attributes, MultiValueMap<String, String> params,
@Nullable InetSocketAddress remoteAddress, byte[] body, List<HttpMessageConverter<?>> messageConverters) {
this.servletRequest = servletRequest;
this.method = method;
this.uri = uri;
this.headers = new HttpHeaders(headers);
this.cookies = new LinkedMultiValueMap<>(cookies);
this.attributes = new LinkedHashMap<>(attributes);
this.params = new LinkedMultiValueMap<>(params);
this.remoteAddress = remoteAddress;
this.body = body;
this.messageConverters = messageConverters;
}
@Override
public HttpMethod method() {
return this.method;
}
@Override
@Deprecated
public String methodName() {
return this.method.name();
}
@Override
public MultiValueMap<String, Part> multipartData() throws IOException, ServletException {
return servletRequest().getParts().stream()
.collect(Collectors.groupingBy(Part::getName,
LinkedMultiValueMap::new,
Collectors.toList()));
}
@Override
public URI uri() {
return this.uri;
}
@Override
public UriBuilder uriBuilder() {
return UriComponentsBuilder.fromUri(this.uri);
}
@Override
public Headers headers() {
return new DefaultRequestHeaders(this.headers);
}
@Override
public MultiValueMap<String, Cookie> cookies() {
return this.cookies;
}
@Override
public Optional<InetSocketAddress> remoteAddress() {
return Optional.ofNullable(this.remoteAddress);
}
@Override
public List<HttpMessageConverter<?>> messageConverters() {
return this.messageConverters;
}
@Override
public <T> T body(Class<T> bodyType) throws IOException, ServletException {
return bodyInternal(bodyType, bodyType);
}
@Override
public <T> T body(ParameterizedTypeReference<T> bodyType) throws IOException, ServletException {
Type type = bodyType.getType();
return bodyInternal(type, bodyClass(type));
}
@SuppressWarnings("unchecked")
private <T> T bodyInternal(Type bodyType, Class<?> bodyClass) throws ServletException, IOException {
HttpInputMessage inputMessage = new BuiltInputMessage();
MediaType contentType = headers().contentType().orElse(MediaType.APPLICATION_OCTET_STREAM);
for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
if (messageConverter instanceof GenericHttpMessageConverter<?> genericMessageConverter) {
if (genericMessageConverter.canRead(bodyType, bodyClass, contentType)) {
return (T) genericMessageConverter.read(bodyType, bodyClass, inputMessage);
}
}
if (messageConverter.canRead(bodyClass, contentType)) {
HttpMessageConverter<T> theConverter =
(HttpMessageConverter<T>) messageConverter;
Class<? extends T> clazz = (Class<? extends T>) bodyClass;
return theConverter.read(clazz, inputMessage);
}
}
throw new HttpMediaTypeNotSupportedException(contentType, Collections.emptyList(), method());
}
@Override
public Map<String, Object> attributes() {
return this.attributes;
}
@Override
public MultiValueMap<String, String> params() {
return this.params;
}
@Override
public Map<String, String> pathVariables() {
@SuppressWarnings("unchecked")
Map<String, String> pathVariables = (Map<String, String>) attributes()
.get(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
if (pathVariables != null) {
return pathVariables;
}
else {
return Collections.emptyMap();
}
}
@Override
public HttpSession session() {
return this.servletRequest.getSession();
}
@Override
public Optional<Principal> principal() {
return Optional.ofNullable(this.servletRequest.getUserPrincipal());
}
@Override
public HttpServletRequest servletRequest() {
return this.servletRequest;
}
private class BuiltInputMessage implements HttpInputMessage {
@Override
public InputStream getBody() throws IOException {
return new BodyInputStream(body);
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
}
}
private static class BodyInputStream extends ServletInputStream {
private final InputStream delegate;
public BodyInputStream(byte[] body) {
this.delegate = new ByteArrayInputStream(body);
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException();
}
@Override
public int read() throws IOException {
return this.delegate.read();
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
return this.delegate.read(b, off, len);
}
@Override
public int read(byte[] b) throws IOException {
return this.delegate.read(b);
}
@Override
public long skip(long n) throws IOException {
return this.delegate.skip(n);
}
@Override
public int available() throws IOException {
return this.delegate.available();
}
@Override
public void close() throws IOException {
this.delegate.close();
}
@Override
public synchronized void mark(int readlimit) {
this.delegate.mark(readlimit);
}
@Override
public synchronized void reset() throws IOException {
this.delegate.reset();
}
@Override
public boolean markSupported() {
return this.delegate.markSupported();
}
}
/**
* Default implementation of {@link ServerRequest.Headers}.
*/
static class DefaultRequestHeaders implements ServerRequest.Headers {
private final HttpHeaders httpHeaders;
public DefaultRequestHeaders(HttpHeaders httpHeaders) {
this.httpHeaders = HttpHeaders.readOnlyHttpHeaders(httpHeaders);
}
@Override
public List<MediaType> accept() {
return this.httpHeaders.getAccept();
}
@Override
public List<Charset> acceptCharset() {
return this.httpHeaders.getAcceptCharset();
}
@Override
public List<Locale.LanguageRange> acceptLanguage() {
return this.httpHeaders.getAcceptLanguage();
}
@Override
public OptionalLong contentLength() {
long value = this.httpHeaders.getContentLength();
return (value != -1 ? OptionalLong.of(value) : OptionalLong.empty());
}
@Override
public Optional<MediaType> contentType() {
return Optional.ofNullable(this.httpHeaders.getContentType());
}
@Override
public InetSocketAddress host() {
return this.httpHeaders.getHost();
}
@Override
public List<HttpRange> range() {
return this.httpHeaders.getRange();
}
@Override
public List<String> header(String headerName) {
List<String> headerValues = this.httpHeaders.get(headerName);
return (headerValues != null ? headerValues : Collections.emptyList());
}
@Override
public HttpHeaders asHttpHeaders() {
return this.httpHeaders;
}
@Override
public String toString() {
return this.httpHeaders.toString();
}
}
}

125
spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/HandlerFunctions.java

@ -0,0 +1,125 @@ @@ -0,0 +1,125 @@
/*
* Copyright 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.cloud.gateway.server.mvc;
import java.net.URI;
import java.util.Optional;
import java.util.function.Function;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.function.HandlerFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.util.UriComponentsBuilder;
public class HandlerFunctions {
public static HandlerFunction<ServerResponse> http(String uri) {
return http(URI.create(uri));
}
public static HandlerFunction<ServerResponse> http(URI uri) {
return new ProxyHandlerFunction(req -> uri);
}
public static HandlerFunction<ServerResponse> http(URIResolver uriResolver) {
return new ProxyHandlerFunction(uriResolver);
}
interface URIResolver extends Function<ServerRequest, URI> {
}
static class ProxyHandlerFunction implements HandlerFunction<ServerResponse> {
private RestTemplate restTemplate;
private final URIResolver uriResolver;
ProxyHandlerFunction(URIResolver uriResolver) {
this.uriResolver = uriResolver;
}
@Override
public ServerResponse handle(ServerRequest request) {
RestTemplate restTemplate = getRestTemplate(request);
if (restTemplate != null) {
URI uri = uriResolver.apply(request);
boolean encoded = containsEncodedQuery(request.uri());
URI url = UriComponentsBuilder.fromUri(request.uri())
// .uri(routeUri)
.scheme(uri.getScheme()).host(uri.getHost()).port(uri.getPort()).build(encoded).toUri();
RequestEntity<Void> entity = RequestEntity.method(request.method(), url)
.headers(request.headers().asHttpHeaders())
.build();
ResponseEntity<Object> response = restTemplate.exchange(entity, Object.class);
return ServerResponse.status(response.getStatusCode())
.headers(httpHeaders -> httpHeaders.putAll(response.getHeaders()))
.body(response.getBody());
}
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
private RestTemplate getRestTemplate(ServerRequest request) {
if (this.restTemplate == null) {
this.restTemplate = getBean(request, RestTemplate.class);
}
return this.restTemplate;
}
}
static ApplicationContext getApplicationContext(ServerRequest request) {
Optional<Object> contextAttr = request.attribute(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE);
if (contextAttr.isEmpty()) {
throw new IllegalStateException("No Application Context in request attributes");
}
return (ApplicationContext) contextAttr.get();
}
static <T> T getBean(ServerRequest request, Class<T> type) {
return getApplicationContext(request).getBean(type);
}
static boolean containsEncodedQuery(URI uri) {
boolean encoded = (uri.getRawQuery() != null && uri.getRawQuery().contains("%"))
|| (uri.getRawPath() != null && uri.getRawPath().contains("%"));
// Verify if it is really fully encoded. Treat partial encoded as unencoded.
if (encoded) {
try {
UriComponentsBuilder.fromUri(uri).build(true);
return true;
}
catch (IllegalArgumentException ignored) {
/*if (log.isTraceEnabled()) {
log.trace("Error in containsEncodedParts", ignored);
}*/
}
return false;
}
return encoded;
}
}

140
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java

@ -0,0 +1,140 @@ @@ -0,0 +1,140 @@
/*
* Copyright 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.cloud.gateway.server.mvc;
import java.net.URI;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.cloud.gateway.server.mvc.test.DefaultTestRestClient;
import org.springframework.cloud.gateway.server.mvc.test.HttpBinCompatibleController;
import org.springframework.cloud.gateway.server.mvc.test.LocalHostUriBuilderFactory;
import org.springframework.cloud.gateway.server.mvc.test.TestRestClient;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.cloud.gateway.server.mvc.FilterFunctions.addRequestHeader;
import static org.springframework.cloud.gateway.server.mvc.FilterFunctions.prefixPath;
import static org.springframework.cloud.gateway.server.mvc.HandlerFunctions.http;
import static org.springframework.web.servlet.function.RequestPredicates.GET;
import static org.springframework.web.servlet.function.RouterFunctions.route;
@SpringBootTest(properties = {}, webEnvironment = WebEnvironment.RANDOM_PORT)
public class ServerMvcIntegrationTests {
@LocalServerPort
int port;
@Autowired
TestRestTemplate restTemplate;
@Autowired
TestRestClient restClient;
@Test
public void nonGatewayRouterFunctionWorks() {
restClient.get().uri("/hello").exchange().expectStatus().isOk().expectBody(String.class).isEqualTo("Hello");
}
@Test
public void getGatewayRouterFunctionWorks() {
ResponseEntity<Map> response = restTemplate.getForEntity("/get", Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> map0 = response.getBody();
assertThat(map0).isNotEmpty().containsKey("headers");
Map<String, Object> headers0 = (Map<String, Object>) map0.get("headers");
// TODO: assert headers case insensitive
assertThat(headers0).containsEntry("x-foo", "Bar");
restClient.get().uri("/get").exchange().expectStatus().isOk().expectBody(Map.class).consumeWith(res -> {
Map<String, Object> map = res.getResponseBody();
assertThat(map).isNotEmpty().containsKey("headers");
Map<String, Object> headers = (Map<String, Object>) map.get("headers");
assertThat(headers).containsEntry("x-foo", "Bar");
});
}
@SpringBootConfiguration
@EnableAutoConfiguration
protected static class TestConfiguration {
@Bean
public DefaultTestRestClient testRestClient(TestRestTemplate testRestTemplate, Environment env) {
return new DefaultTestRestClient(testRestTemplate, new LocalHostUriBuilderFactory(env), result -> {});
}
@Bean
public HttpBinCompatibleController httpBinCompatibleController() {
return new HttpBinCompatibleController();
}
@Bean
public RestTemplate gatewayRestTemplate(RestTemplateBuilder builder) {
return builder.build();
}
@Bean
TestHandler testHandler() {
return new TestHandler();
}
@Bean
public RouterFunction<ServerResponse> nonGatewayRouterFunctions(TestHandler testHandler) {
return route(GET("/hello"), testHandler::hello);
}
@Bean
public RouterFunction<ServerResponse> gatewayRouterFunctions() {
return route(GET("/get"), http(new LocalServerPortUriResolver()))
.filter(addRequestHeader("X-Foo", "Bar"))
.filter(prefixPath("/httpbin"));
}
}
protected static class TestHandler {
public ServerResponse hello(ServerRequest request) {
return ServerResponse.ok().body("Hello");
}
}
static class LocalServerPortUriResolver implements HandlerFunctions.URIResolver {
@Override
public URI apply(ServerRequest request) {
ApplicationContext context = HandlerFunctions.getApplicationContext(request);
Integer port = context.getEnvironment().getProperty("local.server.port", Integer.class);
return URI.create("http://localhost:" + port); }
}
}

224
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/CookieAssertions.java

@ -0,0 +1,224 @@ @@ -0,0 +1,224 @@
/*
* Copyright 2002-2021 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.cloud.gateway.server.mvc.test;
import java.time.Duration;
import java.util.Objects;
import java.util.function.Consumer;
import org.hamcrest.Matcher;
import org.hamcrest.MatcherAssert;
import org.springframework.http.ResponseCookie;
import org.springframework.test.util.AssertionErrors;
import static org.hamcrest.MatcherAssert.assertThat;
/**
* Assertions on cookies of the response.
*
* @author Rossen Stoyanchev
* @since 5.3
*/
public class CookieAssertions {
private final ExchangeResult exchangeResult;
private final TestRestClient.ResponseSpec responseSpec;
public CookieAssertions(ExchangeResult exchangeResult, TestRestClient.ResponseSpec responseSpec) {
this.exchangeResult = exchangeResult;
this.responseSpec = responseSpec;
}
/**
* Expect a header with the given name to match the specified values.
*/
public TestRestClient.ResponseSpec valueEquals(String name, String value) {
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name);
AssertionErrors.assertEquals(message, value, getCookie(name).getValue());
});
return this.responseSpec;
}
/**
* Assert the first value of the response cookie with a Hamcrest {@link Matcher}.
*/
public TestRestClient.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.
*/
public TestRestClient.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 TestRestClient.ResponseSpec exists(String name) {
getCookie(name);
return this.responseSpec;
}
/**
* Expect that the cookie with the given name is not present.
*/
public TestRestClient.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(() -> AssertionErrors.fail(message));
}
return this.responseSpec;
}
/**
* Assert a cookie's maxAge attribute.
*/
public TestRestClient.ResponseSpec maxAge(String name, Duration expected) {
Duration maxAge = getCookie(name).getMaxAge();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " maxAge";
AssertionErrors.assertEquals(message, expected, maxAge);
});
return this.responseSpec;
}
/**
* Assert a cookie's maxAge attribute with a Hamcrest {@link Matcher}.
*/
public TestRestClient.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 TestRestClient.ResponseSpec path(String name, String expected) {
String path = getCookie(name).getPath();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " path";
AssertionErrors.assertEquals(message, expected, path);
});
return this.responseSpec;
}
/**
* Assert a cookie's path attribute with a Hamcrest {@link Matcher}.
*/
public TestRestClient.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 TestRestClient.ResponseSpec domain(String name, String expected) {
String path = getCookie(name).getDomain();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " domain";
AssertionErrors.assertEquals(message, expected, path);
});
return this.responseSpec;
}
/**
* Assert a cookie's domain attribute with a Hamcrest {@link Matcher}.
*/
public TestRestClient.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 TestRestClient.ResponseSpec secure(String name, boolean expected) {
boolean isSecure = getCookie(name).isSecure();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " secure";
AssertionErrors.assertEquals(message, expected, isSecure);
});
return this.responseSpec;
}
/**
* Assert a cookie's httpOnly attribute.
*/
public TestRestClient.ResponseSpec httpOnly(String name, boolean expected) {
boolean isHttpOnly = getCookie(name).isHttpOnly();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " httpOnly";
AssertionErrors.assertEquals(message, expected, isHttpOnly);
});
return this.responseSpec;
}
/**
* Assert a cookie's sameSite attribute.
*/
public TestRestClient.ResponseSpec sameSite(String name, String expected) {
String sameSite = getCookie(name).getSameSite();
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name) + " sameSite";
AssertionErrors.assertEquals(message, expected, sameSite);
});
return this.responseSpec;
}
private ResponseCookie getCookie(String name) {
ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name);
if (cookie == null) {
this.exchangeResult.assertWithDiagnostics(() ->
AssertionErrors.fail("No cookie with name '" + name + "'"));
}
return Objects.requireNonNull(cookie);
}
private String getMessage(String cookie) {
return "Response cookie '" + cookie + "'";
}
}

566
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/DefaultTestRestClient.java

@ -0,0 +1,566 @@ @@ -0,0 +1,566 @@
/*
* Copyright 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.cloud.gateway.server.mvc.test;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.lang.Nullable;
import org.springframework.mock.http.client.MockClientHttpResponse;
import org.springframework.test.util.AssertionErrors;
import org.springframework.test.util.ExceptionCollector;
import org.springframework.test.util.JsonExpectationsHelper;
import org.springframework.test.util.XmlExpectationsHelper;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MimeType;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpMessageConverterExtractor;
import org.springframework.web.util.UriBuilder;
import org.springframework.web.util.UriBuilderFactory;
public class DefaultTestRestClient implements TestRestClient {
private final TestRestTemplate testRestTemplate;
private final UriBuilderFactory uriBuilderFactory;
private final Consumer<EntityExchangeResult<?>> entityResultConsumer;
private final AtomicLong requestIndex = new AtomicLong();
public DefaultTestRestClient(TestRestTemplate testRestTemplate, UriBuilderFactory uriBuilderFactory, Consumer<EntityExchangeResult<?>> entityResultConsumer) {
this.uriBuilderFactory = uriBuilderFactory;
this.testRestTemplate = testRestTemplate;
this.entityResultConsumer = entityResultConsumer;
}
@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);
}
private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) {
return new DefaultRequestBodyUriSpec(httpMethod);
}
private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec {
private final HttpMethod httpMethod;
@Nullable
private URI uri;
private final HttpHeaders headers;
@Nullable
private MultiValueMap<String, String> cookies;
private final Map<String, Object> attributes = new LinkedHashMap<>(4);
@Nullable
private Consumer<ClientHttpRequest> httpRequestConsumer;
@Nullable
private String uriTemplate;
private final String requestId;
private Object body;
DefaultRequestBodyUriSpec(HttpMethod httpMethod) {
this.httpMethod = httpMethod;
this.requestId = String.valueOf(requestIndex.incrementAndGet());
this.headers = new HttpHeaders();
this.headers.add(TESTRESTCLIENT_REQUEST_ID, this.requestId);
}
@Override
public RequestBodySpec uri(String uriTemplate, Object... uriVariables) {
this.uriTemplate = uriTemplate;
return uri(uriBuilderFactory.expand(uriTemplate, uriVariables));
}
@Override
public RequestBodySpec uri(String uriTemplate, Map<String, ?> uriVariables) {
this.uriTemplate = uriTemplate;
return uri(uriBuilderFactory.expand(uriTemplate, uriVariables));
}
@Override
public RequestBodySpec uri(Function<UriBuilder, URI> uriFunction) {
this.uriTemplate = null;
return uri(uriFunction.apply(uriBuilderFactory.builder()));
}
@Override
public RequestBodySpec uri(URI uri) {
this.uri = uri;
return this;
}
private HttpHeaders getHeaders() {
return this.headers;
}
private MultiValueMap<String, String> getCookies() {
if (this.cookies == null) {
this.cookies = new LinkedMultiValueMap<>(3);
}
return this.cookies;
}
@Override
public RequestBodySpec header(String headerName, String... headerValues) {
for (String headerValue : headerValues) {
getHeaders().add(headerName, headerValue);
}
return this;
}
@Override
public RequestBodySpec headers(Consumer<HttpHeaders> headersConsumer) {
headersConsumer.accept(getHeaders());
return this;
}
@Override
public RequestBodySpec attribute(String name, Object value) {
this.attributes.put(name, value);
return this;
}
@Override
public RequestBodySpec attributes(Consumer<Map<String, Object>> attributesConsumer) {
attributesConsumer.accept(this.attributes);
return this;
}
@Override
public RequestBodySpec accept(MediaType... acceptableMediaTypes) {
getHeaders().setAccept(Arrays.asList(acceptableMediaTypes));
return this;
}
@Override
public RequestBodySpec acceptCharset(Charset... acceptableCharsets) {
getHeaders().setAcceptCharset(Arrays.asList(acceptableCharsets));
return this;
}
@Override
public RequestBodySpec contentType(MediaType contentType) {
getHeaders().setContentType(contentType);
return this;
}
@Override
public RequestBodySpec contentLength(long contentLength) {
getHeaders().setContentLength(contentLength);
return this;
}
@Override
public RequestBodySpec cookie(String name, String value) {
getCookies().add(name, value);
return this;
}
@Override
public RequestBodySpec cookies(Consumer<MultiValueMap<String, String>> cookiesConsumer) {
cookiesConsumer.accept(getCookies());
return this;
}
@Override
public RequestBodySpec ifModifiedSince(ZonedDateTime ifModifiedSince) {
getHeaders().setIfModifiedSince(ifModifiedSince);
return this;
}
@Override
public RequestBodySpec ifNoneMatch(String... ifNoneMatches) {
getHeaders().setIfNoneMatch(Arrays.asList(ifNoneMatches));
return this;
}
@Override
public RequestHeadersSpec<?> bodyValue(Object body) {
this.body = body;
return this;
}
@Override
public RequestHeadersSpec<?> body(Object producer, Class<?> elementClass) {
return this;
}
@Override
public RequestHeadersSpec<?> body(Object producer, ParameterizedTypeReference<?> elementTypeRef) {
return this;
}
@Override
public ResponseSpec exchange() {
RequestEntity<Object> request = RequestEntity.method(httpMethod, uri).headers(getHeaders()).body(body);
ResponseEntity<byte[]> response = testRestTemplate.exchange(request, byte[].class);
ExchangeResult exchangeResult = new ExchangeResult(request, response);
return new DefaultResponseSpec(exchangeResult, response, DefaultTestRestClient.this.entityResultConsumer);
}
}
private class DefaultResponseSpec implements ResponseSpec {
private final ExchangeResult exchangeResult;
private final ResponseEntity<byte[]> responseEntity;
private final Consumer<EntityExchangeResult<?>> entityResultConsumer;
DefaultResponseSpec(ExchangeResult exchangeResult,
ResponseEntity<byte[]> responseEntity, Consumer<EntityExchangeResult<?>> entityResultConsumer) {
this.exchangeResult = exchangeResult;
this.responseEntity = responseEntity;
this.entityResultConsumer = entityResultConsumer;
}
@Override
public StatusAssertions expectStatus() {
return new StatusAssertions(this.exchangeResult, this);
}
@Override
public HeaderAssertions expectHeader() {
return new HeaderAssertions(this.exchangeResult, this);
}
@Override
public CookieAssertions expectCookie() {
return new CookieAssertions(this.exchangeResult, this);
}
@Override
public <B> BodySpec<B, ?> expectBody(Class<B> bodyType) {
HttpMessageConverterExtractor<B> httpMessageConverterExtractor = new HttpMessageConverterExtractor<>(bodyType, DefaultTestRestClient.this.testRestTemplate.getRestTemplate().getMessageConverters());
try {
MockClientHttpResponse mockResponse = new MockClientHttpResponse(this.responseEntity.getBody(), this.responseEntity.getStatusCode());
mockResponse.getHeaders().putAll(this.responseEntity.getHeaders());
B body = httpMessageConverterExtractor.extractData(mockResponse);
EntityExchangeResult<B> entityResult = initEntityExchangeResult(body);
return new DefaultBodySpec<>(entityResult);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public <B> BodySpec<B, ?> expectBody(ParameterizedTypeReference<B> bodyType) {
throw new UnsupportedOperationException("expectBody(ParameterizedTypeReference<B> bodyType)");
/*B body = DefaultConversionService.getSharedInstance().convert(this.responseEntity.getBody(), bodyType);
EntityExchangeResult<B> entityResult = initEntityExchangeResult(body);
return new DefaultBodySpec<>(entityResult);*/
}
private <B> EntityExchangeResult<B> initEntityExchangeResult(@Nullable B body) {
EntityExchangeResult<B> result = new EntityExchangeResult<>(this.exchangeResult, body);
result.assertWithDiagnostics(() -> this.entityResultConsumer.accept(result));
return result;
}
@Override
public <E> ListBodySpec<E> expectBodyList(Class<E> elementType) {
return null;
}
@Override
public <E> ListBodySpec<E> expectBodyList(ParameterizedTypeReference<E> elementType) {
return null;
}
@Override
public BodyContentSpec expectBody() {
return new DefaultBodyContentSpec(null);
}
/*@Override
public <T> FluxExchangeResult<T> returnResult(Class<T> elementClass) {
return null;
}
@Override
public <T> FluxExchangeResult<T> returnResult(ParameterizedTypeReference<T> elementTypeRef) {
return null;
}*/
@Override
public TestRestClient.ResponseSpec expectAll(TestRestClient.ResponseSpec.ResponseSpecConsumer... consumers) {
ExceptionCollector exceptionCollector = new ExceptionCollector();
for (TestRestClient.ResponseSpec.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.
AssertionError assertionError = new AssertionError(ex.getMessage());
assertionError.initCause(ex);
throw assertionError;
}
return this;
}
}
private static class DefaultBodySpec<B, S extends TestRestClient.BodySpec<B, S>> implements TestRestClient.BodySpec<B, S> {
private final EntityExchangeResult<B> result;
DefaultBodySpec(EntityExchangeResult<B> result) {
this.result = result;
}
protected EntityExchangeResult<B> getResult() {
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
public <T extends S> T value(Matcher<? super B> matcher) {
this.result.assertWithDiagnostics(() -> MatcherAssert.assertThat(this.result.getResponseBody(), matcher));
return self();
}
@Override
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;
}
@Override
public EntityExchangeResult<B> returnResult() {
return this.result;
}
}
private static class DefaultListBodySpec<E> extends DefaultTestRestClient.DefaultBodySpec<List<E>, TestRestClient.ListBodySpec<E>>
implements TestRestClient.ListBodySpec<E> {
DefaultListBodySpec(EntityExchangeResult<List<E>> result) {
super(result);
}
@Override
public TestRestClient.ListBodySpec<E> hasSize(int size) {
List<E> actual = getResult().getResponseBody();
String message = "Response body does not contain " + size + " elements";
getResult().assertWithDiagnostics(() ->
AssertionErrors.assertEquals(message, size, (actual != null ? actual.size() : 0)));
return this;
}
@Override
@SuppressWarnings("unchecked")
public TestRestClient.ListBodySpec<E> contains(E... elements) {
List<E> expected = Arrays.asList(elements);
List<E> actual = getResult().getResponseBody();
String message = "Response body does not contain " + expected;
getResult().assertWithDiagnostics(() ->
AssertionErrors.assertTrue(message, (actual != null && actual.containsAll(expected))));
return this;
}
@Override
@SuppressWarnings("unchecked")
public TestRestClient.ListBodySpec<E> doesNotContain(E... elements) {
List<E> expected = Arrays.asList(elements);
List<E> actual = getResult().getResponseBody();
String message = "Response body should not have contained " + expected;
getResult().assertWithDiagnostics(() ->
AssertionErrors.assertTrue(message, (actual == null || !actual.containsAll(expected))));
return this;
}
@Override
public EntityExchangeResult<List<E>> returnResult() {
return getResult();
}
}
private static class DefaultBodyContentSpec implements TestRestClient.BodyContentSpec {
private final EntityExchangeResult<byte[]> result;
private final boolean isEmpty;
DefaultBodyContentSpec(EntityExchangeResult<byte[]> result) {
this.result = result;
this.isEmpty = (result.getResponseBody() == null || result.getResponseBody().length == 0);
}
@Override
public EntityExchangeResult<Void> isEmpty() {
this.result.assertWithDiagnostics(() ->
AssertionErrors.assertTrue("Expected empty body", this.isEmpty));
return new EntityExchangeResult<>(this.result, null);
}
@Override
public TestRestClient.BodyContentSpec json(String json, boolean strict) {
this.result.assertWithDiagnostics(() -> {
try {
new JsonExpectationsHelper().assertJsonEqual(json, getBodyAsString(), strict);
}
catch (Exception ex) {
throw new AssertionError("JSON parsing error", ex);
}
});
return this;
}
@Override
public TestRestClient.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, Object... args) {
return new JsonPathAssertions(this, getBodyAsString(), expression, args);
}
@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 TestRestClient.BodyContentSpec consumeWith(Consumer<EntityExchangeResult<byte[]>> consumer) {
this.result.assertWithDiagnostics(() -> consumer.accept(this.result));
return this;
}
@Override
public EntityExchangeResult<byte[]> returnResult() {
return this.result;
}
}
}

50
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/EntityExchangeResult.java

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
/*
* Copyright 2002-2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.cloud.gateway.server.mvc.test;
import org.springframework.lang.Nullable;
/**
* {@code ExchangeResult} sub-class that exposes the response body fully
* extracted to a representation of type {@code <T>}.
*
* @author Rossen Stoyanchev
* @since 5.0
* @param <T> the response body type
* @see FluxExchangeResult
*/
public class EntityExchangeResult<T> extends ExchangeResult {
@Nullable
private final T body;
EntityExchangeResult(ExchangeResult result, @Nullable T body) {
super(result);
this.body = body;
}
/**
* Return the entity extracted from the response body.
*/
@Nullable
public T getResponseBody() {
return this.body;
}
}

309
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/ExchangeResult.java

@ -0,0 +1,309 @@ @@ -0,0 +1,309 @@
/*
* Copyright 2002-2022 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.cloud.gateway.server.mvc.test;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ClientHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
/**
* Container for request and response details for exchanges performed through
* {@link WebTestClient}.
*
* <p>Note that a decoded response body is not exposed at this level since the
* body may not have been decoded and consumed yet. Subtypes
* {@link EntityExchangeResult} and {@link FluxExchangeResult} provide access
* to a decoded response entity and a decoded (but not consumed) response body
* respectively.
*
* @author Rossen Stoyanchev
* @author Sam Brannen
* @since 5.0
* @see EntityExchangeResult
* @see FluxExchangeResult
*/
public class ExchangeResult {
private static final Log logger = LogFactory.getLog(ExchangeResult.class);
private static final List<MediaType> PRINTABLE_MEDIA_TYPES = List.of(
MediaType.parseMediaType("application/*+json"), MediaType.APPLICATION_XML,
MediaType.parseMediaType("text/*"), MediaType.APPLICATION_FORM_URLENCODED);
private final RequestEntity request;
private final ResponseEntity response;
private final byte[] requestBody;
private final byte[] responseBody;
private final Duration timeout;
@Nullable
private final String uriTemplate;
@Nullable
private final Object mockServerResult;
/** Ensure single logging, e.g. for expectAll. */
private boolean diagnosticsLogged;
/**
* Create an instance with an HTTP request and response along with promises
* for the serialized request and response body content.
*
* @param request the HTTP request
* @param response the HTTP response
*/
ExchangeResult(RequestEntity request, ResponseEntity response) {
this(request, response, new byte[0], new byte[0], null, null, null);
}
/**
* Create an instance with an HTTP request and response along with promises
* for the serialized request and response body content.
*
* @param request the HTTP request
* @param response the HTTP response
* @param requestBody capture of serialized request body content
* @param responseBody capture of serialized response body content
* @param timeout how long to wait for content to materialize
* @param uriTemplate the URI template used to set up the request, if any
* @param serverResult the result of a mock server exchange if applicable.
*/
ExchangeResult(RequestEntity request, ResponseEntity response,
byte[] requestBody, byte[] responseBody, Duration timeout, @Nullable String uriTemplate,
@Nullable Object serverResult) {
Assert.notNull(request, "ClientHttpRequest is required");
Assert.notNull(response, "ClientHttpResponse is required");
Assert.notNull(requestBody, "'requestBody' is required");
Assert.notNull(responseBody, "'responseBody' is required");
this.request = request;
this.response = response;
this.requestBody = requestBody;
this.responseBody = responseBody;
this.timeout = timeout;
this.uriTemplate = uriTemplate;
this.mockServerResult = serverResult;
}
/**
* Copy constructor to use after body is decoded and/or consumed.
*/
ExchangeResult(ExchangeResult other) {
this.request = other.request;
this.response = other.response;
this.requestBody = other.requestBody;
this.responseBody = other.responseBody;
this.timeout = other.timeout;
this.uriTemplate = other.uriTemplate;
this.mockServerResult = other.mockServerResult;
this.diagnosticsLogged = other.diagnosticsLogged;
}
/**
* Return the method of the request.
*/
public HttpMethod getMethod() {
return this.request.getMethod();
}
/**
* Return the URI of the request.
*/
public URI getUrl() {
return this.request.getUrl();
}
/**
* Return the original URI template used to prepare the request, if any.
*/
@Nullable
public String getUriTemplate() {
return this.uriTemplate;
}
/**
* Return the request headers sent to the server.
*/
public HttpHeaders getRequestHeaders() {
return this.request.getHeaders();
}
/**
* Return the raw request body content written through the request.
* <p><strong>Note:</strong> If the request content has not been consumed
* for any reason yet, use of this method will trigger consumption.
* @throws IllegalStateException if the request body has not been fully written.
*/
@Nullable
public byte[] getRequestBodyContent() {
return this.requestBody;
}
/**
* Return the HTTP status code as an {@link HttpStatusCode} value.
*/
public HttpStatusCode getStatus() {
return this.response.getStatusCode();
}
/**
* Return the HTTP status code as an integer.
* @since 5.1.10
* @deprecated as of 6.0, in favor of {@link #getStatus()}
*/
@Deprecated(since = "6.0", forRemoval = true)
public int getRawStatusCode() {
return getStatus().value();
}
/**
* Return the response headers received from the server.
*/
public HttpHeaders getResponseHeaders() {
return this.response.getHeaders();
}
/**
* Return response cookies received from the server.
*/
public MultiValueMap<String, ResponseCookie> getResponseCookies() {
return new LinkedMultiValueMap<>(); // TODO: getResponseCookies
}
/**
* Return the raw request body content written to the response.
* <p><strong>Note:</strong> If the response content has not been consumed
* yet, use of this method will trigger consumption.
* @throws IllegalStateException if the response has not been fully read.
*/
@Nullable
public byte[] getResponseBodyContent() {
return this.responseBody;
}
/**
* Return the result from the mock server exchange, if applicable, for
* further assertions on the state of the server response.
* @since 5.3
* @see org.springframework.test.web.servlet.client.MockMvcWebTestClient#resultActionsFor(ExchangeResult)
*/
@Nullable
public Object getMockServerResult() {
return this.mockServerResult;
}
/**
* 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;
}
}
@Override
public String toString() {
return "\n" +
"> " + getMethod() + " " + getUrl() + "\n" +
"> " + formatHeaders(getRequestHeaders(), "\n> ") + "\n" +
"\n" +
formatBody(getRequestHeaders().getContentType(), this.requestBody) + "\n" +
"\n" +
"< " + formatStatus(getStatus()) + "\n" +
"< " + formatHeaders(getResponseHeaders(), "\n< ") + "\n" +
"\n" +
formatBody(getResponseHeaders().getContentType(), this.responseBody) +"\n" +
formatMockServerResult();
}
private String formatStatus(HttpStatusCode statusCode) {
String result = statusCode.toString();
if (statusCode instanceof HttpStatus status) {
result += " " + status.getReasonPhrase();
}
return result;
}
private String formatHeaders(HttpHeaders headers, String delimiter) {
return headers.entrySet().stream()
.map(entry -> entry.getKey() + ": " + entry.getValue())
.collect(Collectors.joining(delimiter));
}
@Nullable
private String formatBody(@Nullable MediaType contentType, byte[] bytes) {
if (bytes == null) {
return "No content";
}
if (contentType == null) {
return bytes.length + " bytes of content (unknown content-type).";
}
Charset charset = contentType.getCharset();
if (charset != null) {
return new String(bytes, charset);
}
if (PRINTABLE_MEDIA_TYPES.stream().anyMatch(contentType::isCompatibleWith)) {
return new String(bytes, StandardCharsets.UTF_8);
}
return bytes.length + " bytes of content.";
}
private String formatMockServerResult() {
return (this.mockServerResult != null ?
"\n====================== MockMvc (Server) ===============================\n" +
this.mockServerResult + "\n" : "");
}
}

325
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HeaderAssertions.java

@ -0,0 +1,325 @@ @@ -0,0 +1,325 @@
/*
* Copyright 2002-2021 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.cloud.gateway.server.mvc.test;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import org.hamcrest.Matcher;
import org.hamcrest.MatcherAssert;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.test.util.AssertionErrors;
import org.springframework.util.CollectionUtils;
import static org.springframework.test.util.AssertionErrors.assertEquals;
import static org.springframework.test.util.AssertionErrors.assertNotNull;
import static org.springframework.test.util.AssertionErrors.assertTrue;
/**
* Assertions on headers of the response.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
* @author Sam Brannen
* @since 5.0
* @see TestRestClient.ResponseSpec#expectHeader()
*/
public class HeaderAssertions {
private final ExchangeResult exchangeResult;
private final TestRestClient.ResponseSpec responseSpec;
HeaderAssertions(ExchangeResult result, TestRestClient.ResponseSpec spec) {
this.exchangeResult = result;
this.responseSpec = spec;
}
/**
* Expect a header with the given name to match the specified values.
*/
public TestRestClient.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.
* @since 5.3
*/
public TestRestClient.ResponseSpec valueEquals(String headerName, long value) {
String actual = getHeaders().getFirst(headerName);
this.exchangeResult.assertWithDiagnostics(() ->
assertTrue("Response does not contain header '" + headerName + "'", actual != null));
return assertHeader(headerName, value, Long.parseLong(Objects.requireNonNull(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.
* @since 5.3
*/
public TestRestClient.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("Response header '" + 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 TestRestClient.ResponseSpec valueMatches(String name, String pattern) {
String value = getRequiredValue(name);
String message = getMessage(name) + "=[" + value + "] does not match [" + pattern + "]";
this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.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
* @since 5.3
*/
public TestRestClient.ResponseSpec valuesMatch(String name, String... patterns) {
this.exchangeResult.assertWithDiagnostics(() -> {
List<String> values = getRequiredValues(name);
AssertionErrors.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];
AssertionErrors.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
* @since 5.1
*/
public TestRestClient.ResponseSpec value(String name, Matcher<? super String> matcher) {
String value = getHeaders().getFirst(name);
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name);
MatcherAssert.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
* @since 5.3
*/
public TestRestClient.ResponseSpec values(String name, Matcher<? super Iterable<String>> matcher) {
List<String> values = getHeaders().get(name);
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name);
MatcherAssert.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
* @since 5.1
*/
public TestRestClient.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
* @since 5.3
*/
public TestRestClient.ResponseSpec values(String name, Consumer<List<String>> consumer) {
List<String> values = getRequiredValues(name);
this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(values));
return this.responseSpec;
}
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)) {
this.exchangeResult.assertWithDiagnostics(() ->
AssertionErrors.fail(getMessage(name) + " not found"));
}
return Objects.requireNonNull(values);
}
/**
* Expect that the header with the given name is present.
* @since 5.0.3
*/
public TestRestClient.ResponseSpec exists(String name) {
if (!getHeaders().containsKey(name)) {
String message = getMessage(name) + " does not exist";
this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.fail(message));
}
return this.responseSpec;
}
/**
* Expect that the header with the given name is not present.
*/
public TestRestClient.ResponseSpec doesNotExist(String name) {
if (getHeaders().containsKey(name)) {
String message = getMessage(name) + " exists with value=[" + getHeaders().getFirst(name) + "]";
this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.fail(message));
}
return this.responseSpec;
}
/**
* Expect a "Cache-Control" header with the given value.
*/
public TestRestClient.ResponseSpec cacheControl(CacheControl cacheControl) {
return assertHeader("Cache-Control", cacheControl.getHeaderValue(), getHeaders().getCacheControl());
}
/**
* Expect a "Content-Disposition" header with the given value.
*/
public TestRestClient.ResponseSpec contentDisposition(ContentDisposition contentDisposition) {
return assertHeader("Content-Disposition", contentDisposition, getHeaders().getContentDisposition());
}
/**
* Expect a "Content-Length" header with the given value.
*/
public TestRestClient.ResponseSpec contentLength(long contentLength) {
return assertHeader("Content-Length", contentLength, getHeaders().getContentLength());
}
/**
* Expect a "Content-Type" header with the given value.
*/
public TestRestClient.ResponseSpec contentType(MediaType mediaType) {
return assertHeader("Content-Type", mediaType, getHeaders().getContentType());
}
/**
* Expect a "Content-Type" header with the given value.
*/
public TestRestClient.ResponseSpec contentType(String mediaType) {
return contentType(MediaType.parseMediaType(mediaType));
}
/**
* Expect a "Content-Type" header compatible with the given value.
*/
public TestRestClient.ResponseSpec contentTypeCompatibleWith(MediaType mediaType) {
MediaType actual = getHeaders().getContentType();
String message = getMessage("Content-Type") + "=[" + actual + "] is not compatible with [" + mediaType + "]";
this.exchangeResult.assertWithDiagnostics(() ->
AssertionErrors.assertTrue(message, (actual != null && actual.isCompatibleWith(mediaType))));
return this.responseSpec;
}
/**
* Expect a "Content-Type" header compatible with the given value.
*/
public TestRestClient.ResponseSpec contentTypeCompatibleWith(String mediaType) {
return contentTypeCompatibleWith(MediaType.parseMediaType(mediaType));
}
/**
* Expect an "Expires" header with the given value.
*/
public TestRestClient.ResponseSpec expires(long expires) {
return assertHeader("Expires", expires, getHeaders().getExpires());
}
/**
* Expect a "Last-Modified" header with the given value.
*/
public TestRestClient.ResponseSpec lastModified(long lastModified) {
return assertHeader("Last-Modified", lastModified, getHeaders().getLastModified());
}
/**
* Expect a "Location" header with the given value.
* @since 5.3
*/
public TestRestClient.ResponseSpec location(String location) {
return assertHeader("Location", URI.create(location), getHeaders().getLocation());
}
private HttpHeaders getHeaders() {
return this.exchangeResult.getResponseHeaders();
}
private String getMessage(String headerName) {
return "Response header '" + headerName + "'";
}
private TestRestClient.ResponseSpec assertHeader(String name, @Nullable Object expected, @Nullable Object actual) {
this.exchangeResult.assertWithDiagnostics(() -> {
String message = getMessage(name);
AssertionErrors.assertEquals(message, expected, actual);
});
return this.responseSpec;
}
}

223
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpBinCompatibleController.java

@ -0,0 +1,223 @@ @@ -0,0 +1,223 @@
/*
* Copyright 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.cloud.gateway.server.mvc.test;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebExchange;
@RestController
@RequestMapping("/httpbin")
public class HttpBinCompatibleController {
private static final Log log = LogFactory.getLog(HttpBinCompatibleController.class);
private static final String HEADER_REQ_VARY = "X-Request-Vary";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@GetMapping("/")
public String home() {
return "httpbin compatible home";
}
@RequestMapping(path = "/headers", method = { RequestMethod.GET, RequestMethod.POST },
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> headers(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
result.put("headers", getHeaders(request));
return result;
}
/*@PatchMapping("/headers")
public ResponseEntity<Map<String, Object>> headersPatch(ServerWebExchange exchange,
@RequestBody Map<String, String> headersToAdd) {
Map<String, Object> result = new HashMap<>();
result.put("headers", getHeaders(exchange));
ResponseEntity.BodyBuilder responseEntity = ResponseEntity.status(HttpStatus.OK);
headersToAdd.forEach(responseEntity::header);
return responseEntity.body(result);
}*/
@RequestMapping(path = "/multivalueheaders", method = { RequestMethod.GET, RequestMethod.POST },
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> multiValueHeaders(ServerWebExchange exchange) {
Map<String, Object> result = new HashMap<>();
result.put("headers", exchange.getRequest().getHeaders());
return result;
}
/*@GetMapping(path = "/delay/{sec}/**", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<Map<String, Object>> delay(ServerWebExchange exchange, @PathVariable int sec)
throws InterruptedException {
int delay = Math.min(sec, 10);
return Mono.just(get(exchange)).delayElement(Duration.ofSeconds(delay));
}*/
@GetMapping(path = "/anything/{anything}", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> anything(HttpServletRequest request, @PathVariable(required = false) String anything) {
return get(request);
}
@GetMapping(path = "/get", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> get(HttpServletRequest request) {
if (log.isDebugEnabled()) {
log.debug("httpbin /get");
}
HashMap<String, Object> result = new HashMap<>();
Map<String, String[]> params = request.getParameterMap();
result.put("args", params);
result.put("headers", getHeaders(request));
return result;
}
/*@PostMapping(value = "/post", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<Map<String, Object>> postFormData(@RequestBody Mono<MultiValueMap<String, Part>> parts) {
// StringDecoder decoder = StringDecoder.allMimeTypes(true);
return parts.flux().flatMap(map -> Flux.fromIterable(map.values())).flatMap(Flux::fromIterable)
.filter(part -> part instanceof FilePart).reduce(new HashMap<String, Object>(), (files, part) -> {
MediaType contentType = part.headers().getContentType();
long contentLength = part.headers().getContentLength();
// TODO: get part data
files.put(part.name(), "data:" + contentType + ";base64," + contentLength);
return files;
}).map(files -> Collections.singletonMap("files", files));
}*/
/*@PostMapping(path = "/post", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<Map<String, Object>> postUrlEncoded(ServerWebExchange exchange) throws IOException {
return post(exchange, null);
}*/
/*@PostMapping(path = "/post", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<Map<String, Object>> post(ServerWebExchange exchange, @RequestBody(required = false) String body)
throws IOException {
HashMap<String, Object> ret = new HashMap<>();
ret.put("headers", getHeaders(exchange));
ret.put("data", body);
HashMap<String, Object> form = new HashMap<>();
ret.put("form", form);
return exchange.getFormData().flatMap(map -> {
for (Map.Entry<String, List<String>> entry : map.entrySet()) {
for (String value : entry.getValue()) {
form.put(entry.getKey(), value);
}
}
return Mono.just(ret);
});
}*/
@GetMapping("/status/{status}")
public ResponseEntity<String> status(@PathVariable int status) {
return ResponseEntity.status(status).body("Failed with " + status);
}
@RequestMapping(value = "/responseheaders/{status}", method = { RequestMethod.GET, RequestMethod.POST })
public ResponseEntity<Map<String, Object>> responseHeaders(@PathVariable int status, ServerWebExchange exchange) {
HttpHeaders httpHeaders = exchange.getRequest().getHeaders().entrySet().stream()
.filter(entry -> entry.getKey().startsWith("X-Test-"))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
(list1, list2) -> Stream.concat(list1.stream(), list2.stream()).collect(Collectors.toList()),
HttpHeaders::new));
return ResponseEntity.status(status).headers(httpHeaders).body(Collections.singletonMap("status", status));
}
@PostMapping(path = "/post/empty", produces = MediaType.APPLICATION_JSON_VALUE)
public String emptyResponse() {
return null;
}
/*@GetMapping(path = "/gzip", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<Void> gzip(ServerWebExchange exchange) throws IOException {
if (log.isDebugEnabled()) {
log.debug("httpbin /gzip");
}
String jsonResponse = OBJECT_MAPPER.writeValueAsString("httpbin compatible home");
byte[] bytes = jsonResponse.getBytes(StandardCharsets.UTF_8);
ServerHttpResponse response = exchange.getResponse();
response.getHeaders().add(HttpHeaders.CONTENT_ENCODING, "gzip");
DataBufferFactory dataBufferFactory = response.bufferFactory();
response.setStatusCode(HttpStatus.OK);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
GZIPOutputStream is = new GZIPOutputStream(bos);
FileCopyUtils.copy(bytes, is);
byte[] gzippedResponse = bos.toByteArray();
DataBuffer wrap = dataBufferFactory.wrap(gzippedResponse);
return response.writeWith(Flux.just(wrap));
}*/
@GetMapping("/vary-on-header/**")
public ResponseEntity<Map<String, Object>> varyOnAccept(HttpServletRequest request,
@RequestHeader(name = HEADER_REQ_VARY, required = false) String headerToVary) {
if (headerToVary == null) {
return ResponseEntity.badRequest().body(Map.of("error", HEADER_REQ_VARY + " header is mandatory"));
}
else {
var builder = ResponseEntity.ok();
builder.varyBy(headerToVary);
return builder.body(headers(request));
}
}
public Map<String, String> getHeaders(HttpServletRequest req) {
HashMap<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = req.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
String value = null;
Enumeration<String> values = req.getHeaders(name);
if (values.hasMoreElements()) {
value = values.nextElement();
}
headers.put(name, value);
}
return headers;
// return request.headers().asHttpHeaders().toSingleValueMap();
}
}

193
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/JsonPathAssertions.java

@ -0,0 +1,193 @@ @@ -0,0 +1,193 @@
/*
* Copyright 2002-2020 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.cloud.gateway.server.mvc.test;
import java.util.function.Consumer;
import org.hamcrest.Matcher;
import org.springframework.cloud.gateway.server.mvc.test.TestRestClient;
import org.springframework.lang.Nullable;
import org.springframework.test.util.JsonPathExpectationsHelper;
/**
* <a href="https://github.com/jayway/JsonPath">JsonPath</a> assertions.
*
* @author Rossen Stoyanchev
* @since 5.0
* @see <a href="https://github.com/jayway/JsonPath">https://github.com/jayway/JsonPath</a>
* @see JsonPathExpectationsHelper
*/
public class JsonPathAssertions {
private final TestRestClient.BodyContentSpec bodySpec;
private final String content;
private final JsonPathExpectationsHelper pathHelper;
JsonPathAssertions(TestRestClient.BodyContentSpec spec, String content, String expression, Object... args) {
this.bodySpec = spec;
this.content = content;
this.pathHelper = new JsonPathExpectationsHelper(expression, args);
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValue(String, Object)}.
*/
public TestRestClient.BodyContentSpec isEqualTo(Object expectedValue) {
this.pathHelper.assertValue(this.content, expectedValue);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#exists(String)}.
*/
public TestRestClient.BodyContentSpec exists() {
this.pathHelper.exists(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#doesNotExist(String)}.
*/
public TestRestClient.BodyContentSpec doesNotExist() {
this.pathHelper.doesNotExist(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsEmpty(String)}.
*/
public TestRestClient.BodyContentSpec isEmpty() {
this.pathHelper.assertValueIsEmpty(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsNotEmpty(String)}.
*/
public TestRestClient.BodyContentSpec isNotEmpty() {
this.pathHelper.assertValueIsNotEmpty(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#hasJsonPath}.
* @since 5.0.3
*/
public TestRestClient.BodyContentSpec hasJsonPath() {
this.pathHelper.hasJsonPath(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#doesNotHaveJsonPath}.
* @since 5.0.3
*/
public TestRestClient.BodyContentSpec doesNotHaveJsonPath() {
this.pathHelper.doesNotHaveJsonPath(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsBoolean(String)}.
*/
public TestRestClient.BodyContentSpec isBoolean() {
this.pathHelper.assertValueIsBoolean(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsNumber(String)}.
*/
public TestRestClient.BodyContentSpec isNumber() {
this.pathHelper.assertValueIsNumber(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsArray(String)}.
*/
public TestRestClient.BodyContentSpec isArray() {
this.pathHelper.assertValueIsArray(this.content);
return this.bodySpec;
}
/**
* Applies {@link JsonPathExpectationsHelper#assertValueIsMap(String)}.
*/
public TestRestClient.BodyContentSpec isMap() {
this.pathHelper.assertValueIsMap(this.content);
return this.bodySpec;
}
/**
* Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher)}.
* @since 5.1
*/
public <T> TestRestClient.BodyContentSpec value(Matcher<? super T> matcher) {
this.pathHelper.assertValue(this.content, matcher);
return this.bodySpec;
}
/**
* Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}.
* @since 5.1
*/
public <T> TestRestClient.BodyContentSpec value(Matcher<? super T> matcher, Class<T> targetType) {
this.pathHelper.assertValue(this.content, matcher, targetType);
return this.bodySpec;
}
/**
* Consume the result of the JSONPath evaluation.
* @since 5.1
*/
@SuppressWarnings("unchecked")
public <T> TestRestClient.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.
* @since 5.1
*/
@SuppressWarnings("unchecked")
public <T> TestRestClient.BodyContentSpec value(Consumer<T> consumer, Class<T> targetType) {
Object value = this.pathHelper.evaluateJsonPath(this.content, targetType);
consumer.accept((T) 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();
}
}

354
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/LocalHostUriBuilderFactory.java

@ -0,0 +1,354 @@ @@ -0,0 +1,354 @@
/*
* Copyright 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.cloud.gateway.server.mvc.test;
import java.net.URI;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.core.env.Environment;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.MultiValueMap;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.util.DefaultUriBuilderFactory;
import org.springframework.web.util.UriBuilder;
import org.springframework.web.util.UriBuilderFactory;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
public class LocalHostUriBuilderFactory implements UriBuilderFactory {
private static final String PREFIX = "server.servlet.";
private final Environment environment;
private final String scheme;
private DefaultUriBuilderFactory.EncodingMode encodingMode = DefaultUriBuilderFactory.EncodingMode.TEMPLATE_AND_VALUES;
private final Map<String, Object> defaultUriVariables = new HashMap<>();
private boolean parsePath = true;
/**
* Create a new {@code LocalHostUriTemplateHandler} that will generate {@code http}
* URIs using the given {@code environment} to determine the context path and port.
*
* @param environment the environment used to determine the port
*/
public LocalHostUriBuilderFactory(Environment environment) {
this(environment, "http");
}
/**
* Create a new {@code LocalHostUriTemplateHandler} that will generate URIs with the
* given {@code scheme} and use the given {@code environment} to determine the
* context-path and port.
*
* @param environment the environment used to determine the port
* @param scheme the scheme of the root uri
* @since 1.4.1
*/
public LocalHostUriBuilderFactory(Environment environment, String scheme) {
Assert.notNull(environment, "Environment must not be null");
Assert.notNull(scheme, "Scheme must not be null");
this.environment = environment;
this.scheme = scheme;
}
public String getPort() {
return this.environment.getProperty("local.server.port", "8080");
}
public String getContextPath() {
return this.environment.getProperty(PREFIX + "context-path", "");
}
public String getScheme() {
return scheme;
}
/**
* Set the {@link DefaultUriBuilderFactory.EncodingMode encoding mode} to use.
* <p>By default this is set to {@link DefaultUriBuilderFactory.EncodingMode#TEMPLATE_AND_VALUES
* EncodingMode.TEMPLATE_AND_VALUES}.
* <p><strong>Note:</strong> Prior to 5.1 the default was
* {@link DefaultUriBuilderFactory.EncodingMode#URI_COMPONENT EncodingMode.URI_COMPONENT}
* therefore the {@code WebClient} {@code RestTemplate} have switched their
* default behavior.
* @param encodingMode the encoding mode to use
*/
public void setEncodingMode(DefaultUriBuilderFactory.EncodingMode encodingMode) {
this.encodingMode = encodingMode;
}
/**
* Return the configured encoding mode.
*/
public DefaultUriBuilderFactory.EncodingMode getEncodingMode() {
return this.encodingMode;
}
/**
* Provide default URI variable values to use when expanding URI templates
* with a Map of variables.
* @param defaultUriVariables default URI variable values
*/
public void setDefaultUriVariables(@Nullable Map<String, ?> defaultUriVariables) {
this.defaultUriVariables.clear();
if (defaultUriVariables != null) {
this.defaultUriVariables.putAll(defaultUriVariables);
}
}
/**
* Return the configured default URI variable values.
*/
public Map<String, ?> getDefaultUriVariables() {
return Collections.unmodifiableMap(this.defaultUriVariables);
}
/**
* Whether to parse the input path into path segments if the encoding mode
* is set to {@link DefaultUriBuilderFactory.EncodingMode#URI_COMPONENT EncodingMode.URI_COMPONENT},
* which ensures that URI variables in the path are encoded according to
* path segment rules and for example a '/' is encoded.
* <p>By default this is set to {@code true}.
* @param parsePath whether to parse the path into path segments
*/
public void setParsePath(boolean parsePath) {
this.parsePath = parsePath;
}
/**
* Whether to parse the path into path segments if the encoding mode is set
* to {@link DefaultUriBuilderFactory.EncodingMode#URI_COMPONENT EncodingMode.URI_COMPONENT}.
*/
public boolean shouldParsePath() {
return this.parsePath;
}
@Override
public UriBuilder uriString(String uriTemplate) {
return new DefaultUriBuilder(uriTemplate);
}
@Override
public UriBuilder builder() {
return new DefaultUriBuilder("");
}
@Override
public URI expand(String uriTemplate, Map<String, ?> uriVars) {
return uriString(uriTemplate).build(uriVars);
}
@Override
public URI expand(String uriTemplate, Object... uriVars) {
return uriString(uriTemplate).build(uriVars);
}
/**
* {@link DefaultUriBuilderFactory} specific implementation of UriBuilder.
*/
private class DefaultUriBuilder implements UriBuilder {
private final UriComponentsBuilder uriComponentsBuilder;
public DefaultUriBuilder(String uriTemplate) {
this.uriComponentsBuilder = initUriComponentsBuilder(uriTemplate);
}
private UriComponentsBuilder initUriComponentsBuilder(String uriTemplate) {
UriComponentsBuilder result = UriComponentsBuilder.fromUriString(uriTemplate);
if (encodingMode.equals(DefaultUriBuilderFactory.EncodingMode.TEMPLATE_AND_VALUES)) {
result.encode();
}
parsePathIfNecessary(result);
return result;
}
private void parsePathIfNecessary(UriComponentsBuilder result) {
if (parsePath && encodingMode.equals(DefaultUriBuilderFactory.EncodingMode.URI_COMPONENT)) {
UriComponents uric = result.build();
String path = uric.getPath();
result.replacePath(null);
for (String segment : uric.getPathSegments()) {
result.pathSegment(segment);
}
if (path != null && path.endsWith("/")) {
result.path("/");
}
}
}
@Override
public DefaultUriBuilder scheme(@Nullable String scheme) {
this.uriComponentsBuilder.scheme(scheme);
return this;
}
@Override
public DefaultUriBuilder userInfo(@Nullable String userInfo) {
this.uriComponentsBuilder.userInfo(userInfo);
return this;
}
@Override
public DefaultUriBuilder host(@Nullable String host) {
this.uriComponentsBuilder.host(host);
return this;
}
@Override
public DefaultUriBuilder port(int port) {
this.uriComponentsBuilder.port(port);
return this;
}
@Override
public DefaultUriBuilder port(@Nullable String port) {
this.uriComponentsBuilder.port(port);
return this;
}
@Override
public DefaultUriBuilder path(String path) {
this.uriComponentsBuilder.path(path);
return this;
}
@Override
public DefaultUriBuilder replacePath(@Nullable String path) {
this.uriComponentsBuilder.replacePath(path);
return this;
}
@Override
public DefaultUriBuilder pathSegment(String... pathSegments) {
this.uriComponentsBuilder.pathSegment(pathSegments);
return this;
}
@Override
public DefaultUriBuilder query(String query) {
this.uriComponentsBuilder.query(query);
return this;
}
@Override
public DefaultUriBuilder replaceQuery(@Nullable String query) {
this.uriComponentsBuilder.replaceQuery(query);
return this;
}
@Override
public DefaultUriBuilder queryParam(String name, Object... values) {
this.uriComponentsBuilder.queryParam(name, values);
return this;
}
@Override
public DefaultUriBuilder queryParam(String name, @Nullable Collection<?> values) {
this.uriComponentsBuilder.queryParam(name, values);
return this;
}
@Override
public DefaultUriBuilder queryParamIfPresent(String name, Optional<?> value) {
this.uriComponentsBuilder.queryParamIfPresent(name, value);
return this;
}
@Override
public DefaultUriBuilder queryParams(MultiValueMap<String, String> params) {
this.uriComponentsBuilder.queryParams(params);
return this;
}
@Override
public DefaultUriBuilder replaceQueryParam(String name, Object... values) {
this.uriComponentsBuilder.replaceQueryParam(name, values);
return this;
}
@Override
public DefaultUriBuilder replaceQueryParam(String name, @Nullable Collection<?> values) {
this.uriComponentsBuilder.replaceQueryParam(name, values);
return this;
}
@Override
public DefaultUriBuilder replaceQueryParams(MultiValueMap<String, String> params) {
this.uriComponentsBuilder.replaceQueryParams(params);
return this;
}
@Override
public DefaultUriBuilder fragment(@Nullable String fragment) {
this.uriComponentsBuilder.fragment(fragment);
return this;
}
@Override
public URI build(Map<String, ?> uriVars) {
if (!defaultUriVariables.isEmpty()) {
Map<String, Object> map = new HashMap<>();
map.putAll(defaultUriVariables);
map.putAll(uriVars);
uriVars = map;
}
if (encodingMode.equals(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY)) {
uriVars = UriUtils.encodeUriVariables(uriVars);
}
this.uriComponentsBuilder.scheme(getScheme());
this.uriComponentsBuilder.port(getPort());
this.uriComponentsBuilder.replacePath(getContextPath());
UriComponents uric = this.uriComponentsBuilder.build().expand(uriVars);
return createUri(uric);
}
@Override
public URI build(Object... uriVars) {
if (ObjectUtils.isEmpty(uriVars) && !defaultUriVariables.isEmpty()) {
return build(Collections.emptyMap());
}
if (encodingMode.equals(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY)) {
uriVars = UriUtils.encodeUriVariables(uriVars);
}
UriComponents uric = this.uriComponentsBuilder.build().expand(uriVars);
return createUri(uric);
}
private URI createUri(UriComponents uric) {
if (encodingMode.equals(DefaultUriBuilderFactory.EncodingMode.URI_COMPONENT)) {
uric = uric.encode();
}
return URI.create(uric.toString());
}
}
}

242
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/StatusAssertions.java

@ -0,0 +1,242 @@ @@ -0,0 +1,242 @@
/*
* Copyright 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.cloud.gateway.server.mvc.test;
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.reactive.server.WebTestClient;
public class StatusAssertions {
private final ExchangeResult exchangeResult;
private final TestRestClient.ResponseSpec responseSpec;
StatusAssertions(ExchangeResult result, TestRestClient.ResponseSpec spec) {
this.exchangeResult = result;
this.responseSpec = spec;
}
/**
* Assert the response status as an {@link HttpStatusCode}.
*/
public TestRestClient.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 TestRestClient.ResponseSpec isEqualTo(int status) {
return isEqualTo(HttpStatusCode.valueOf(status));
}
/**
* Assert the response status code is {@code HttpStatus.OK} (200).
*/
public TestRestClient.ResponseSpec isOk() {
return assertStatusAndReturn(HttpStatus.OK);
}
/**
* Assert the response status code is {@code HttpStatus.CREATED} (201).
*/
public TestRestClient.ResponseSpec isCreated() {
return assertStatusAndReturn(HttpStatus.CREATED);
}
/**
* Assert the response status code is {@code HttpStatus.ACCEPTED} (202).
*/
public TestRestClient.ResponseSpec isAccepted() {
return assertStatusAndReturn(HttpStatus.ACCEPTED);
}
/**
* Assert the response status code is {@code HttpStatus.NO_CONTENT} (204).
*/
public TestRestClient.ResponseSpec isNoContent() {
return assertStatusAndReturn(HttpStatus.NO_CONTENT);
}
/**
* Assert the response status code is {@code HttpStatus.FOUND} (302).
*/
public TestRestClient.ResponseSpec isFound() {
return assertStatusAndReturn(HttpStatus.FOUND);
}
/**
* Assert the response status code is {@code HttpStatus.SEE_OTHER} (303).
*/
public TestRestClient.ResponseSpec isSeeOther() {
return assertStatusAndReturn(HttpStatus.SEE_OTHER);
}
/**
* Assert the response status code is {@code HttpStatus.NOT_MODIFIED} (304).
*/
public TestRestClient.ResponseSpec isNotModified() {
return assertStatusAndReturn(HttpStatus.NOT_MODIFIED);
}
/**
* Assert the response status code is {@code HttpStatus.TEMPORARY_REDIRECT} (307).
*/
public TestRestClient.ResponseSpec isTemporaryRedirect() {
return assertStatusAndReturn(HttpStatus.TEMPORARY_REDIRECT);
}
/**
* Assert the response status code is {@code HttpStatus.PERMANENT_REDIRECT} (308).
*/
public TestRestClient.ResponseSpec isPermanentRedirect() {
return assertStatusAndReturn(HttpStatus.PERMANENT_REDIRECT);
}
/**
* Assert the response status code is {@code HttpStatus.BAD_REQUEST} (400).
*/
public TestRestClient.ResponseSpec isBadRequest() {
return assertStatusAndReturn(HttpStatus.BAD_REQUEST);
}
/**
* Assert the response status code is {@code HttpStatus.UNAUTHORIZED} (401).
*/
public TestRestClient.ResponseSpec isUnauthorized() {
return assertStatusAndReturn(HttpStatus.UNAUTHORIZED);
}
/**
* Assert the response status code is {@code HttpStatus.FORBIDDEN} (403).
* @since 5.0.2
*/
public TestRestClient.ResponseSpec isForbidden() {
return assertStatusAndReturn(HttpStatus.FORBIDDEN);
}
/**
* Assert the response status code is {@code HttpStatus.NOT_FOUND} (404).
*/
public TestRestClient.ResponseSpec isNotFound() {
return assertStatusAndReturn(HttpStatus.NOT_FOUND);
}
/**
* Assert the response error message.
*/
public TestRestClient.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 TestRestClient.ResponseSpec is1xxInformational() {
return assertSeriesAndReturn(HttpStatus.Series.INFORMATIONAL);
}
/**
* Assert the response status code is in the 2xx range.
*/
public TestRestClient.ResponseSpec is2xxSuccessful() {
return assertSeriesAndReturn(HttpStatus.Series.SUCCESSFUL);
}
/**
* Assert the response status code is in the 3xx range.
*/
public TestRestClient.ResponseSpec is3xxRedirection() {
return assertSeriesAndReturn(HttpStatus.Series.REDIRECTION);
}
/**
* Assert the response status code is in the 4xx range.
*/
public TestRestClient.ResponseSpec is4xxClientError() {
return assertSeriesAndReturn(HttpStatus.Series.CLIENT_ERROR);
}
/**
* Assert the response status code is in the 5xx range.
*/
public TestRestClient.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 TestRestClient.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 TestRestClient.ResponseSpec value(Consumer<Integer> consumer) {
int actual = this.exchangeResult.getStatus().value();
this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(actual));
return this.responseSpec;
}
private TestRestClient.ResponseSpec assertStatusAndReturn(HttpStatusCode expected) {
HttpStatusCode actual = this.exchangeResult.getStatus();
this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", expected, actual));
return this.responseSpec;
}
private TestRestClient.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;
}
}

638
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestRestClient.java

@ -0,0 +1,638 @@ @@ -0,0 +1,638 @@
/*
* Copyright 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.cloud.gateway.server.mvc.test;
import java.net.URI;
import java.nio.charset.Charset;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
import org.hamcrest.Matcher;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriBuilder;
import org.springframework.web.util.UriBuilderFactory;
public interface TestRestClient {
/**
* The name of a request header used to assign a unique id to every request
* performed through the {@code WebTestClient}. This can be useful for
* storing contextual information at all phases of request processing (e.g.
* from a server-side component) under that id and later to look up
* that information once an {@link ExchangeResult} is available.
*/
String TESTRESTCLIENT_REQUEST_ID = "TestRestClient-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);
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 (e.g.
* 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 (e.g.
* 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(Object) 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 length of the body in bytes, as specified by the
* {@code Content-Length} header.
* @param contentLength the content length
* @return the same instance
* @see HttpHeaders#setContentLength(long)
*/
RequestBodySpec contentLength(long contentLength);
/**
* 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.reactive.function.client.WebClient.RequestBodySpec#bodyValue(Object)
* bodyValue} method on the underlying {@code WebClient}.
* @param body the value to write to the request body
* @return spec for further declaration of the request
* @since 5.2
*/
RequestHeadersSpec<?> bodyValue(Object body);
/**
* Set the body from the given {@code Publisher}. Shortcut for
* {@link #body(BodyInserter)} with a
* {@linkplain BodyInserters#fromPublisher Publisher inserter}.
* @param publisher the request body data
* @param elementClass the class of elements contained in the publisher
* @param <T> the type of the elements contained in the publisher
* @param <S> the type of the {@code Publisher}
* @return spec for further declaration of the request
*/
//<T, S extends Publisher<T>> RequestHeadersSpec<?> body(S publisher, Class<T> elementClass);
/**
* Variant of {@link #body(Publisher, Class)} that allows providing
* element type information with generics.
* @param publisher the request body data
* @param elementTypeRef the type reference of elements contained in the publisher
* @param <T> the type of the elements contained in the publisher
* @param <S> the type of the {@code Publisher}
* @return spec for further declaration of the request
* @since 5.2
*/
//<T, S extends Publisher<T>> RequestHeadersSpec<?> body(
// S publisher, ParameterizedTypeReference<T> elementTypeRef);
/**
* Set the body from the given producer. This method invokes the
* {@link org.springframework.web.reactive.function.client.WebClient.RequestBodySpec#body(Object, Class)
* body(Object, Class)} method on the underlying {@code WebClient}.
* @param producer the producer to write to the request. This must be a
* {@link Publisher} or another producer adaptable to a
* {@code Publisher} via {@link ReactiveAdapterRegistry}
* @param elementClass the class of elements contained in the producer
* @return spec for further declaration of the request
* @since 5.2
*/
RequestHeadersSpec<?> body(Object producer, Class<?> elementClass);
/**
* Set the body from the given producer. This method invokes the
* {@link org.springframework.web.reactive.function.client.WebClient.RequestBodySpec#body(Object, ParameterizedTypeReference)
* body(Object, ParameterizedTypeReference)} method on the underlying {@code WebClient}.
* @param producer the producer to write to the request. This must be a
* {@link Publisher} or another producer adaptable to a
* {@code Publisher} via {@link ReactiveAdapterRegistry}
* @param elementTypeRef the type reference of elements contained in the producer
* @return spec for further declaration of the request
* @since 5.2
*/
RequestHeadersSpec<?> body(Object producer, ParameterizedTypeReference<?> elementTypeRef);
/**
* Set the body of the request to the given {@code BodyInserter}.
* This method invokes the
* {@link org.springframework.web.reactive.function.client.WebClient.RequestBodySpec#body(BodyInserter)
* body(BodyInserter)} method on the underlying {@code WebClient}.
* @param inserter the body inserter to use
* @return spec for further declaration of the request
* @see org.springframework.web.reactive.function.BodyInserters
*/
//RequestHeadersSpec<?> body(BodyInserter<?, ? super ClientHttpRequest> inserter);
}
/**
* 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 {
/**
* Apply multiple assertions to a response with the given
* {@linkplain 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">
* 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
* @since 5.3.10
*/
ResponseSpec expectAll(ResponseSpec.ResponseSpecConsumer... consumers);
/**
* Assertions on the response status.
*/
StatusAssertions expectStatus();
/**
* Assertions on the headers of the response.
*/
HeaderAssertions expectHeader();
/**
* Assertions on the cookies of the response.
* @since 5.3
*/
CookieAssertions expectCookie();
/**
* 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);
/**
* Consume and decode the response body to {@code List<E>} and then apply
* List-specific assertions.
* @param elementType the expected List element type
*/
<E> ListBodySpec<E> expectBodyList(Class<E> elementType);
/**
* Alternative to {@link #expectBodyList(Class)} that accepts information
* about a target type with generics.
*/
<E> ListBodySpec<E> expectBodyList(ParameterizedTypeReference<E> elementType);
/**
* Consume and decode the response body to {@code byte[]} and then apply
* assertions on the raw content (e.g. isEmpty, JSONPath, etc.)
*/
BodyContentSpec expectBody();
/**
* Exit the chained flow in order to consume the response body
* externally, e.g. via {@link reactor.test.StepVerifier}.
* <p>Note that when {@code Void.class} is passed in, the response body
* is consumed and released. If no content is expected, then consider
* using {@code .expectBody().isEmpty()} instead which asserts that
* there is no content.
*/
//<T> FluxExchangeResult<T> returnResult(Class<T> elementClass);
/**
* Alternative to {@link #returnResult(Class)} that accepts information
* about a target type with generics.
*/
//<T> FluxExchangeResult<T> returnResult(ParameterizedTypeReference<T> elementTypeRef);
/**
* {@link Consumer} of a {@link ResponseSpec}.
* @since 5.3.10
* @see ResponseSpec#expectAll(ResponseSpec.ResponseSpecConsumer...)
*/
@FunctionalInterface
interface ResponseSpecConsumer extends Consumer<ResponseSpec> {
}
}
/**
* 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>> {
/**
* Assert the extracted body is equal to the given value.
*/
<T extends S> T isEqualTo(B expected);
/**
* Assert the extracted body with a {@link Matcher}.
* @since 5.1
*/
<T extends S> T value(Matcher<? super B> matcher);
/**
* Transform the extracted the body with a function, e.g. extracting a
* property, and assert the mapped value with a {@link Matcher}.
* @since 5.1
*/
<T extends S, R> T value(Function<B, R> bodyMapper, Matcher<? super R> matcher);
/**
* Assert the extracted body with a {@link Consumer}.
* @since 5.1
*/
<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 ExchangeResult} with the
* decoded response content.
*/
EntityExchangeResult<B> returnResult();
}
/**
* Spec for expectations on the response body decoded to a List.
*
* @param <E> the body list element type
*/
interface ListBodySpec<E> extends BodySpec<List<E>, ListBodySpec<E>> {
/**
* Assert the extracted list of values is of the given size.
* @param size the expected size
*/
ListBodySpec<E> hasSize(int size);
/**
* Assert the extracted list of values contains the given elements.
* @param elements the elements to check
*/
@SuppressWarnings("unchecked")
ListBodySpec<E> contains(E... elements);
/**
* Assert the extracted list of values doesn't contain the given elements.
* @param elements the elements to check
*/
@SuppressWarnings("unchecked")
ListBodySpec<E> doesNotContain(E... elements);
}
/**
* 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, boolean)
*/
default BodyContentSpec json(String expectedJson) {
return json(expectedJson, false);
}
/**
* 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.
* <p>Can compare in two modes, depending on the {@code strict} parameter value:
* <ul>
* <li>{@code true}: strict checking. Not extensible and strict array ordering.</li>
* <li>{@code false}: lenient checking. Extensible and non-strict array ordering.</li>
* </ul>
* <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 strict enables strict checking if {@code true}
* @since 5.3.16
* @see #json(String)
*/
BodyContentSpec json(String expectedJson, boolean strict);
/**
* 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 JSON content.
* @since 5.1
* @see org.springframework.test.util.XmlExpectationsHelper#assertXmlEqual(String, String)
*/
BodyContentSpec xml(String expectedXml);
/**
* 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.
* <p>The JSON path expression can be a parameterized string using
* formatting specifiers as defined in {@link String#format}.
* @param expression the JsonPath expression
* @param args arguments to parameterize the expression
*/
JsonPathAssertions jsonPath(String expression, Object... args);
/**
* 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
* @since 5.1
* @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
* @since 5.1
*/
XpathAssertions xpath(String expression, @Nullable Map<String, String> namespaces, Object... args);
/**
* Assert the response body content with the given {@link Consumer}.
* @param consumer the consumer for the response body; the input
* {@code byte[]} may be {@code null} if there was no response body.
*/
BodyContentSpec consumeWith(Consumer<EntityExchangeResult<byte[]>> consumer);
/**
* Exit the chained API and return an {@code ExchangeResult} with the
* raw response content.
*/
EntityExchangeResult<byte[]> returnResult();
}
}

214
spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/XpathAssertions.java

@ -0,0 +1,214 @@ @@ -0,0 +1,214 @@
/*
* 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.cloud.gateway.server.mvc.test;
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.springframework.cloud.gateway.server.mvc.test.TestRestClient;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.Nullable;
import org.springframework.test.util.XpathExpectationsHelper;
import org.springframework.util.Assert;
import org.springframework.util.MimeType;
/**
* XPath assertions for the {@link TestRestClient}.
*
* @author Eric Deandrea
* @author Rossen Stoyanchev
* @since 5.1
*/
public class XpathAssertions {
private final TestRestClient.BodyContentSpec bodySpec;
private final XpathExpectationsHelper xpathHelper;
XpathAssertions(TestRestClient.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 TestRestClient.BodyContentSpec isEqualTo(String expectedValue) {
return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), expectedValue));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Double)}.
*/
public TestRestClient.BodyContentSpec isEqualTo(Double expectedValue) {
return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), expectedValue));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertBoolean(byte[], String, boolean)}.
*/
public TestRestClient.BodyContentSpec isEqualTo(boolean expectedValue) {
return assertWith(() -> this.xpathHelper.assertBoolean(getContent(), getCharset(), expectedValue));
}
/**
* Delegates to {@link XpathExpectationsHelper#exists(byte[], String)}.
*/
public TestRestClient.BodyContentSpec exists() {
return assertWith(() -> this.xpathHelper.exists(getContent(), getCharset()));
}
/**
* Delegates to {@link XpathExpectationsHelper#doesNotExist(byte[], String)}.
*/
public TestRestClient.BodyContentSpec doesNotExist() {
return assertWith(() -> this.xpathHelper.doesNotExist(getContent(), getCharset()));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, int)}.
*/
public TestRestClient.BodyContentSpec nodeCount(int expectedCount) {
return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), expectedCount));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, Matcher)}.
* @since 5.1
*/
public TestRestClient.BodyContentSpec string(Matcher<? super String> matcher){
return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), matcher));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Matcher)}.
* @since 5.1
*/
public TestRestClient.BodyContentSpec number(Matcher<? super Double> matcher){
return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), matcher));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, Matcher)}.
* @since 5.1
*/
public TestRestClient.BodyContentSpec nodeCount(Matcher<? super Integer> matcher){
return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), matcher));
}
/**
* Consume the result of the XPath evaluation as a String.
* @since 5.1
*/
public TestRestClient.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.
* @since 5.1
*/
public TestRestClient.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.
* @since 5.1
*/
public TestRestClient.BodyContentSpec nodeCount(Consumer<Integer> consumer){
return assertWith(() -> {
Integer value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Integer.class);
consumer.accept(value);
});
}
private TestRestClient.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;
}
}

0
spring-cloud-gateway-server-mvc/src/test/resources/application.yml

Loading…
Cancel
Save