From adda25b1e7e697b5e9ffc149ef65aa3fb8aa202e Mon Sep 17 00:00:00 2001 From: spencergibb Date: Fri, 5 May 2023 18:23:47 -0400 Subject: [PATCH] Initial support for Gateway Server MVC Includes: - FilterFunctions - HandlerFunctions - TestRestClient Fixes gh-36 --- pom.xml | 1 + spring-cloud-gateway-dependencies/pom.xml | 5 + spring-cloud-gateway-server-mvc/pom.xml | 48 ++ .../gateway/server/mvc/FilterFunctions.java | 45 ++ .../mvc/GatewayServerRequestBuilder.java | 530 +++++++++++++++ .../gateway/server/mvc/HandlerFunctions.java | 125 ++++ .../server/mvc/ServerMvcIntegrationTests.java | 140 ++++ .../server/mvc/test/CookieAssertions.java | 224 ++++++ .../mvc/test/DefaultTestRestClient.java | 566 ++++++++++++++++ .../server/mvc/test/EntityExchangeResult.java | 50 ++ .../server/mvc/test/ExchangeResult.java | 309 +++++++++ .../server/mvc/test/HeaderAssertions.java | 325 +++++++++ .../mvc/test/HttpBinCompatibleController.java | 223 ++++++ .../server/mvc/test/JsonPathAssertions.java | 193 ++++++ .../mvc/test/LocalHostUriBuilderFactory.java | 354 ++++++++++ .../server/mvc/test/StatusAssertions.java | 242 +++++++ .../server/mvc/test/TestRestClient.java | 638 ++++++++++++++++++ .../server/mvc/test/XpathAssertions.java | 214 ++++++ .../src/test/resources/application.yml | 0 19 files changed, 4232 insertions(+) create mode 100644 spring-cloud-gateway-server-mvc/pom.xml create mode 100644 spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/FilterFunctions.java create mode 100644 spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerRequestBuilder.java create mode 100644 spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/HandlerFunctions.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/CookieAssertions.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/DefaultTestRestClient.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/EntityExchangeResult.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/ExchangeResult.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HeaderAssertions.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpBinCompatibleController.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/JsonPathAssertions.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/LocalHostUriBuilderFactory.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/StatusAssertions.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestRestClient.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/XpathAssertions.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/resources/application.yml diff --git a/pom.xml b/pom.xml index 4e65d565e..71abb925b 100644 --- a/pom.xml +++ b/pom.xml @@ -127,6 +127,7 @@ spring-cloud-gateway-mvc spring-cloud-gateway-webflux spring-cloud-gateway-server + spring-cloud-gateway-server-mvc spring-cloud-starter-gateway spring-cloud-gateway-sample spring-cloud-gateway-integration-tests diff --git a/spring-cloud-gateway-dependencies/pom.xml b/spring-cloud-gateway-dependencies/pom.xml index 0c42e1cdc..6e7550377 100644 --- a/spring-cloud-gateway-dependencies/pom.xml +++ b/spring-cloud-gateway-dependencies/pom.xml @@ -37,6 +37,11 @@ spring-cloud-gateway-server ${project.version} + + org.springframework.cloud + spring-cloud-gateway-server-mvc + ${project.version} + org.springframework.cloud spring-cloud-starter-gateway diff --git a/spring-cloud-gateway-server-mvc/pom.xml b/spring-cloud-gateway-server-mvc/pom.xml new file mode 100644 index 000000000..fefe21cad --- /dev/null +++ b/spring-cloud-gateway-server-mvc/pom.xml @@ -0,0 +1,48 @@ + + + + + 4.0.0 + + + org.springframework.cloud + spring-cloud-gateway + 4.1.0-SNAPSHOT + .. + + spring-cloud-gateway-server-mvc + jar + Spring Cloud Gateway Server MVC + Spring Cloud Gateway Server MVC + + ${basedir}/.. + + + + + org.springframework.boot + spring-boot-starter-web + true + + + org.springframework.boot + spring-boot-starter-test + test + + + \ No newline at end of file diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/FilterFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/FilterFunctions.java new file mode 100644 index 000000000..7d22d73fe --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/FilterFunctions.java @@ -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 addRequestHeader(String name, + String... values) { + return (request, next) -> { + ServerRequest modified = new GatewayServerRequestBuilder(request).header(name, values).build(); + return next.handle(modified); + }; + } + + public static HandlerFilterFunction 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); + }; + } +} diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerRequestBuilder.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerRequestBuilder.java new file mode 100644 index 000000000..803ab7b8e --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerRequestBuilder.java @@ -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> messageConverters; + + private HttpMethod method; + + private URI uri; + + private final HttpHeaders headers = new HttpHeaders(); + + private final MultiValueMap cookies = new LinkedMultiValueMap<>(); + + private final Map attributes = new LinkedHashMap<>(); + + private final MultiValueMap 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 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> 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> 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> 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 cookies; + + private final Map attributes; + + private final byte[] body; + + private final List> messageConverters; + + private final MultiValueMap params; + + @Nullable + private final InetSocketAddress remoteAddress; + + public BuiltServerRequest(HttpServletRequest servletRequest, HttpMethod method, URI uri, + HttpHeaders headers, MultiValueMap cookies, + Map attributes, MultiValueMap params, + @Nullable InetSocketAddress remoteAddress, byte[] body, List> 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 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 cookies() { + return this.cookies; + } + + @Override + public Optional remoteAddress() { + return Optional.ofNullable(this.remoteAddress); + } + + @Override + public List> messageConverters() { + return this.messageConverters; + } + + @Override + public T body(Class bodyType) throws IOException, ServletException { + return bodyInternal(bodyType, bodyType); + } + + @Override + public T body(ParameterizedTypeReference bodyType) throws IOException, ServletException { + Type type = bodyType.getType(); + return bodyInternal(type, bodyClass(type)); + } + + @SuppressWarnings("unchecked") + private 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 theConverter = + (HttpMessageConverter) messageConverter; + Class clazz = (Class) bodyClass; + return theConverter.read(clazz, inputMessage); + } + } + throw new HttpMediaTypeNotSupportedException(contentType, Collections.emptyList(), method()); + } + + @Override + public Map attributes() { + return this.attributes; + } + + @Override + public MultiValueMap params() { + return this.params; + } + + @Override + public Map pathVariables() { + @SuppressWarnings("unchecked") + Map pathVariables = (Map) 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() { + 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 accept() { + return this.httpHeaders.getAccept(); + } + + @Override + public List acceptCharset() { + return this.httpHeaders.getAcceptCharset(); + } + + @Override + public List 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 contentType() { + return Optional.ofNullable(this.httpHeaders.getContentType()); + } + + @Override + public InetSocketAddress host() { + return this.httpHeaders.getHost(); + } + + @Override + public List range() { + return this.httpHeaders.getRange(); + } + + @Override + public List header(String headerName) { + List 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(); + } + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/HandlerFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/HandlerFunctions.java new file mode 100644 index 000000000..d62bc83fa --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/HandlerFunctions.java @@ -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 http(String uri) { + return http(URI.create(uri)); + } + + public static HandlerFunction http(URI uri) { + return new ProxyHandlerFunction(req -> uri); + } + + public static HandlerFunction http(URIResolver uriResolver) { + return new ProxyHandlerFunction(uriResolver); + } + + interface URIResolver extends Function { + + } + + static class ProxyHandlerFunction implements HandlerFunction { + + 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 entity = RequestEntity.method(request.method(), url) + .headers(request.headers().asHttpHeaders()) + .build(); + ResponseEntity 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 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 getBean(ServerRequest request, Class 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; + } +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java new file mode 100644 index 000000000..b5037662c --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java @@ -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 response = restTemplate.getForEntity("/get", Map.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Map map0 = response.getBody(); + assertThat(map0).isNotEmpty().containsKey("headers"); + Map headers0 = (Map) 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 map = res.getResponseBody(); + assertThat(map).isNotEmpty().containsKey("headers"); + Map headers = (Map) 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 nonGatewayRouterFunctions(TestHandler testHandler) { + return route(GET("/hello"), testHandler::hello); + } + + @Bean + public RouterFunction 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); } + } +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/CookieAssertions.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/CookieAssertions.java new file mode 100644 index 000000000..52fc341a7 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/CookieAssertions.java @@ -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 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 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 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 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 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 + "'"; + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/DefaultTestRestClient.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/DefaultTestRestClient.java new file mode 100644 index 000000000..ac2a543ea --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/DefaultTestRestClient.java @@ -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> entityResultConsumer; + + + private final AtomicLong requestIndex = new AtomicLong(); + + public DefaultTestRestClient(TestRestTemplate testRestTemplate, UriBuilderFactory uriBuilderFactory, Consumer> 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 cookies; + + private final Map attributes = new LinkedHashMap<>(4); + + @Nullable + private Consumer 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 uriVariables) { + this.uriTemplate = uriTemplate; + return uri(uriBuilderFactory.expand(uriTemplate, uriVariables)); + } + + @Override + public RequestBodySpec uri(Function 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 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 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> 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> 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 request = RequestEntity.method(httpMethod, uri).headers(getHeaders()).body(body); + ResponseEntity 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 responseEntity; + private final Consumer> entityResultConsumer; + + DefaultResponseSpec(ExchangeResult exchangeResult, + ResponseEntity responseEntity, Consumer> 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 BodySpec expectBody(Class bodyType) { + HttpMessageConverterExtractor 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 entityResult = initEntityExchangeResult(body); + return new DefaultBodySpec<>(entityResult); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public BodySpec expectBody(ParameterizedTypeReference bodyType) { + throw new UnsupportedOperationException("expectBody(ParameterizedTypeReference bodyType)"); + /*B body = DefaultConversionService.getSharedInstance().convert(this.responseEntity.getBody(), bodyType); + EntityExchangeResult entityResult = initEntityExchangeResult(body); + return new DefaultBodySpec<>(entityResult);*/ + } + + private EntityExchangeResult initEntityExchangeResult(@Nullable B body) { + EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + result.assertWithDiagnostics(() -> this.entityResultConsumer.accept(result)); + return result; + } + + @Override + public ListBodySpec expectBodyList(Class elementType) { + return null; + } + + @Override + public ListBodySpec expectBodyList(ParameterizedTypeReference elementType) { + return null; + } + + @Override + public BodyContentSpec expectBody() { + return new DefaultBodyContentSpec(null); + } + + /*@Override + public FluxExchangeResult returnResult(Class elementClass) { + return null; + } + + @Override + public FluxExchangeResult returnResult(ParameterizedTypeReference 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> implements TestRestClient.BodySpec { + + private final EntityExchangeResult result; + + DefaultBodySpec(EntityExchangeResult result) { + this.result = result; + } + + protected EntityExchangeResult getResult() { + return this.result; + } + + @Override + public T isEqualTo(B expected) { + this.result.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Response body", expected, this.result.getResponseBody())); + return self(); + } + + @Override + public T value(Matcher matcher) { + this.result.assertWithDiagnostics(() -> MatcherAssert.assertThat(this.result.getResponseBody(), matcher)); + return self(); + } + + @Override + public T value(Function bodyMapper, Matcher matcher) { + this.result.assertWithDiagnostics(() -> { + B body = this.result.getResponseBody(); + MatcherAssert.assertThat(bodyMapper.apply(body), matcher); + }); + return self(); + } + + @Override + public T value(Consumer consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody())); + return self(); + } + + @Override + public T consumeWith(Consumer> consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result)); + return self(); + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + + @Override + public EntityExchangeResult returnResult() { + return this.result; + } + } + + + private static class DefaultListBodySpec extends DefaultTestRestClient.DefaultBodySpec, TestRestClient.ListBodySpec> + implements TestRestClient.ListBodySpec { + + DefaultListBodySpec(EntityExchangeResult> result) { + super(result); + } + + @Override + public TestRestClient.ListBodySpec hasSize(int size) { + List 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 contains(E... elements) { + List expected = Arrays.asList(elements); + List 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 doesNotContain(E... elements) { + List expected = Arrays.asList(elements); + List 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> returnResult() { + return getResult(); + } + } + + + private static class DefaultBodyContentSpec implements TestRestClient.BodyContentSpec { + + private final EntityExchangeResult result; + + private final boolean isEmpty; + + DefaultBodyContentSpec(EntityExchangeResult result) { + this.result = result; + this.isEmpty = (result.getResponseBody() == null || result.getResponseBody().length == 0); + } + + @Override + public EntityExchangeResult 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 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> consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result)); + return this; + } + + @Override + public EntityExchangeResult returnResult() { + return this.result; + } + } + +} \ No newline at end of file diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/EntityExchangeResult.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/EntityExchangeResult.java new file mode 100644 index 000000000..267fb8208 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/EntityExchangeResult.java @@ -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 }. + * + * @author Rossen Stoyanchev + * @since 5.0 + * @param the response body type + * @see FluxExchangeResult + */ +public class EntityExchangeResult 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; + } + +} \ No newline at end of file diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/ExchangeResult.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/ExchangeResult.java new file mode 100644 index 000000000..df22a0921 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/ExchangeResult.java @@ -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}. + * + *

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 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. + *

Note: 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 getResponseCookies() { + return new LinkedMultiValueMap<>(); // TODO: getResponseCookies + } + + /** + * Return the raw request body content written to the response. + *

Note: 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" : ""); + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HeaderAssertions.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HeaderAssertions.java new file mode 100644 index 000000000..0a81bb5cd --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HeaderAssertions.java @@ -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. + *

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 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 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> matcher) { + List 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 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> consumer) { + List values = getRequiredValues(name); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(values)); + return this.responseSpec; + } + + private String getRequiredValue(String name) { + return getRequiredValues(name).get(0); + } + + private List getRequiredValues(String name) { + List 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; + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpBinCompatibleController.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpBinCompatibleController.java new file mode 100644 index 000000000..952564ddd --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpBinCompatibleController.java @@ -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 headers(HttpServletRequest request) { + Map result = new HashMap<>(); + result.put("headers", getHeaders(request)); + return result; + } + + /*@PatchMapping("/headers") + public ResponseEntity> headersPatch(ServerWebExchange exchange, + @RequestBody Map headersToAdd) { + Map 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 multiValueHeaders(ServerWebExchange exchange) { + Map result = new HashMap<>(); + result.put("headers", exchange.getRequest().getHeaders()); + return result; + } + + /*@GetMapping(path = "/delay/{sec}/**", produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> 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 anything(HttpServletRequest request, @PathVariable(required = false) String anything) { + return get(request); + } + + @GetMapping(path = "/get", produces = MediaType.APPLICATION_JSON_VALUE) + public Map get(HttpServletRequest request) { + if (log.isDebugEnabled()) { + log.debug("httpbin /get"); + } + HashMap result = new HashMap<>(); + Map 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> postFormData(@RequestBody Mono> 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(), (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> postUrlEncoded(ServerWebExchange exchange) throws IOException { + return post(exchange, null); + }*/ + + /*@PostMapping(path = "/post", produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> post(ServerWebExchange exchange, @RequestBody(required = false) String body) + throws IOException { + HashMap ret = new HashMap<>(); + ret.put("headers", getHeaders(exchange)); + ret.put("data", body); + HashMap form = new HashMap<>(); + ret.put("form", form); + + return exchange.getFormData().flatMap(map -> { + for (Map.Entry> entry : map.entrySet()) { + for (String value : entry.getValue()) { + form.put(entry.getKey(), value); + } + } + return Mono.just(ret); + }); + }*/ + + @GetMapping("/status/{status}") + public ResponseEntity status(@PathVariable int status) { + return ResponseEntity.status(status).body("Failed with " + status); + } + + @RequestMapping(value = "/responseheaders/{status}", method = { RequestMethod.GET, RequestMethod.POST }) + public ResponseEntity> 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 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> 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 getHeaders(HttpServletRequest req) { + HashMap headers = new HashMap<>(); + Enumeration headerNames = req.getHeaderNames(); + + while (headerNames.hasMoreElements()) { + String name = headerNames.nextElement(); + String value = null; + + Enumeration values = req.getHeaders(name); + if (values.hasMoreElements()) { + value = values.nextElement(); + } + headers.put(name, value); + } + return headers; + // return request.headers().asHttpHeaders().toSingleValueMap(); + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/JsonPathAssertions.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/JsonPathAssertions.java new file mode 100644 index 000000000..1776803ab --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/JsonPathAssertions.java @@ -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; + +/** + * JsonPath assertions. + * + * @author Rossen Stoyanchev + * @since 5.0 + * @see https://github.com/jayway/JsonPath + * @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 TestRestClient.BodyContentSpec value(Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}. + * @since 5.1 + */ + public TestRestClient.BodyContentSpec value(Matcher matcher, Class targetType) { + this.pathHelper.assertValue(this.content, matcher, targetType); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation. + * @since 5.1 + */ + @SuppressWarnings("unchecked") + public TestRestClient.BodyContentSpec value(Consumer 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 TestRestClient.BodyContentSpec value(Consumer consumer, Class 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(); + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/LocalHostUriBuilderFactory.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/LocalHostUriBuilderFactory.java new file mode 100644 index 000000000..41a329c86 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/LocalHostUriBuilderFactory.java @@ -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 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. + *

By default this is set to {@link DefaultUriBuilderFactory.EncodingMode#TEMPLATE_AND_VALUES + * EncodingMode.TEMPLATE_AND_VALUES}. + *

Note: 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 defaultUriVariables) { + this.defaultUriVariables.clear(); + if (defaultUriVariables != null) { + this.defaultUriVariables.putAll(defaultUriVariables); + } + } + + /** + * Return the configured default URI variable values. + */ + public Map 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. + *

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 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 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 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 uriVars) { + if (!defaultUriVariables.isEmpty()) { + Map 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()); + } + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/StatusAssertions.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/StatusAssertions.java new file mode 100644 index 000000000..4fb3e919b --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/StatusAssertions.java @@ -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 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 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; + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestRestClient.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestRestClient.java new file mode 100644 index 000000000..af431eb72 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestRestClient.java @@ -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> { + + /** + * Specify the URI using an absolute, fully constructed {@link java.net.URI}. + *

If a {@link UriBuilderFactory} was configured for the client with + * a base URI, that base URI will not 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)} — 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. + *

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. + *

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 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 uriFunction); + } + + + + /** + * Specification for adding request headers and performing an exchange. + * + * @param a self reference to the spec type + */ + interface RequestHeadersSpec> { + + /** + * 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> cookiesConsumer); + + /** + * Set the value of the {@code If-Modified-Since} header. + *

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 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> 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 { + /** + * 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 the type of the elements contained in the publisher + * @param the type of the {@code Publisher} + * @return spec for further declaration of the request + */ + //> RequestHeadersSpec body(S publisher, Class 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 the type of the elements contained in the publisher + * @param the type of the {@code Publisher} + * @return spec for further declaration of the request + * @since 5.2 + */ + //> RequestHeadersSpec body( + // S publisher, ParameterizedTypeReference 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 inserter); + } + + /** + * Specification for providing request headers and the URI of a request. + * + * @param a self reference to the spec type + */ + interface RequestHeadersUriSpec> extends UriSpec, RequestHeadersSpec { + } + + /** + * Specification for providing the body and the URI of a request. + */ + interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpec { + } + + + /** + * 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. + *

If a single {@link Error} or {@link RuntimeException} is thrown, + * it will be rethrown. + *

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}. + *

This feature is similar to the {@code SoftAssertions} support in + * AssertJ and the {@code assertAll()} support in JUnit Jupiter. + * + *

Example

+ *
+		 * get().uri("/hello").exchange()
+		 *     .expectAll(
+		 *         responseSpec -> responseSpec.expectStatus().isOk(),
+		 *         responseSpec -> responseSpec.expectBody(String.class).isEqualTo("Hello, World!")
+		 *     );
+		 * 
+ * @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 } and then apply assertions. + * @param bodyType the expected body type + */ + BodySpec expectBody(Class bodyType); + + /** + * Alternative to {@link #expectBody(Class)} that accepts information + * about a target type with generics. + */ + BodySpec expectBody(ParameterizedTypeReference bodyType); + + /** + * Consume and decode the response body to {@code List} and then apply + * List-specific assertions. + * @param elementType the expected List element type + */ + ListBodySpec expectBodyList(Class elementType); + + /** + * Alternative to {@link #expectBodyList(Class)} that accepts information + * about a target type with generics. + */ + ListBodySpec expectBodyList(ParameterizedTypeReference 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}. + *

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. + */ + // FluxExchangeResult returnResult(Class elementClass); + + /** + * Alternative to {@link #returnResult(Class)} that accepts information + * about a target type with generics. + */ + // FluxExchangeResult returnResult(ParameterizedTypeReference elementTypeRef); + + /** + * {@link Consumer} of a {@link ResponseSpec}. + * @since 5.3.10 + * @see ResponseSpec#expectAll(ResponseSpec.ResponseSpecConsumer...) + */ + @FunctionalInterface + interface ResponseSpecConsumer extends Consumer { + } + + } + + + /** + * Spec for expectations on the response body decoded to a single Object. + * + * @param a self reference to the spec type + * @param the body type + */ + interface BodySpec> { + + /** + * Assert the extracted body is equal to the given value. + */ + T isEqualTo(B expected); + + /** + * Assert the extracted body with a {@link Matcher}. + * @since 5.1 + */ + T value(Matcher 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 value(Function bodyMapper, Matcher matcher); + + /** + * Assert the extracted body with a {@link Consumer}. + * @since 5.1 + */ + T value(Consumer consumer); + + /** + * Assert the exchange result with the given {@link Consumer}. + */ + T consumeWith(Consumer> consumer); + + /** + * Exit the chained API and return an {@code ExchangeResult} with the + * decoded response content. + */ + EntityExchangeResult returnResult(); + } + + + /** + * Spec for expectations on the response body decoded to a List. + * + * @param the body list element type + */ + interface ListBodySpec extends BodySpec, ListBodySpec> { + + /** + * Assert the extracted list of values is of the given size. + * @param size the expected size + */ + ListBodySpec hasSize(int size); + + /** + * Assert the extracted list of values contains the given elements. + * @param elements the elements to check + */ + @SuppressWarnings("unchecked") + ListBodySpec contains(E... elements); + + /** + * Assert the extracted list of values doesn't contain the given elements. + * @param elements the elements to check + */ + @SuppressWarnings("unchecked") + ListBodySpec 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 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 lenient checking (extensible + * and non-strict array ordering). + *

Use of this method requires the + * JSONassert 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. + *

Can compare in two modes, depending on the {@code strict} parameter value: + *

    + *
  • {@code true}: strict checking. Not extensible and strict array ordering.
  • + *
  • {@code false}: lenient checking. Extensible and non-strict array ordering.
  • + *
+ *

Use of this method requires the + * JSONassert 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. + *

Use of this method requires the + * XMLUnit 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 + * JsonPath expression + * to inspect a specific subset of the body. + *

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. + *

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. + *

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 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> consumer); + + /** + * Exit the chained API and return an {@code ExchangeResult} with the + * raw response content. + */ + EntityExchangeResult returnResult(); + } +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/XpathAssertions.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/XpathAssertions.java new file mode 100644 index 000000000..76ec85579 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/XpathAssertions.java @@ -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 namespaces, Object... args) { + + this.bodySpec = spec; + this.xpathHelper = initXpathHelper(expression, namespaces, args); + } + + private static XpathExpectationsHelper initXpathHelper( + String expression, @Nullable Map 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 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 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 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 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 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 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; + + } +} diff --git a/spring-cloud-gateway-server-mvc/src/test/resources/application.yml b/spring-cloud-gateway-server-mvc/src/test/resources/application.yml new file mode 100644 index 000000000..e69de29bb