From d14477eb84f5d140c2095a580e3c338559f2ed00 Mon Sep 17 00:00:00 2001 From: Ashley Scopes <73482956+ascopes@users.noreply.github.com> Date: Sat, 14 Aug 2021 17:05:17 +0100 Subject: [PATCH] Add methods to DefaultResponseCreator & MockRestResponseCreators New methods in DefaultResponseCreator for adding headers and cookies, for specifying the character encoding when setting a string body on a response, which is useful when working in environments that do not automatically assume UTF-8, such as integrating with legacy applications from a new Spring one. New methods in MockRestResponseCreators support extra commonly used HTTP status codes, including some that occur when working in AWS, CloudFlare, or when using gateways such as Kong, where resilient applications should be able to respond to ratelimits, gateway errors, and gateway timeouts that may occur if a remote service is down. Added test cases for any changes made. See gh-27280 --- .../response/DefaultResponseCreator.java | 68 +++++- .../response/MockRestResponseCreators.java | 66 +++++ .../response/DefaultResponseCreatorTests.java | 230 ++++++++++++++++++ .../response/ResponseCreatorsTests.java | 84 +++++++ 4 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 spring-test/src/test/java/org/springframework/test/web/client/response/DefaultResponseCreatorTests.java diff --git a/spring-test/src/main/java/org/springframework/test/web/client/response/DefaultResponseCreator.java b/spring-test/src/main/java/org/springframework/test/web/client/response/DefaultResponseCreator.java index 081001fe8ab7..c3dc650e442f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/response/DefaultResponseCreator.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/response/DefaultResponseCreator.java @@ -18,18 +18,25 @@ import java.io.IOException; import java.net.URI; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpResponse; import org.springframework.lang.Nullable; import org.springframework.mock.http.client.MockClientHttpResponse; import org.springframework.test.web.client.ResponseCreator; import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; /** * A {@code ResponseCreator} with builder-style methods for adding response details. @@ -67,7 +74,6 @@ protected DefaultResponseCreator(HttpStatusCode statusCode) { this.statusCode = statusCode; } - /** * Set the body as a UTF-8 String. */ @@ -76,6 +82,14 @@ public DefaultResponseCreator body(String content) { return this; } + /** + * Set the body from a string using the given character set. + */ + public DefaultResponseCreator body(String content, Charset charset) { + this.content = content.getBytes(charset); + return this; + } + /** * Set the body as a byte array. */ @@ -85,7 +99,7 @@ public DefaultResponseCreator body(byte[] content) { } /** - * Set the body as a {@link Resource}. + * Set the body from a {@link Resource}. */ public DefaultResponseCreator body(Resource resource) { this.contentResource = resource; @@ -108,6 +122,26 @@ public DefaultResponseCreator location(URI location) { return this; } + /** + * Add a single header. + */ + public DefaultResponseCreator header(String name, String value) { + // This is really just an alias, but it makes the interface more fluent. + return headers(name, value); + } + + /** + * Add one or more headers. + */ + public DefaultResponseCreator headers(String name, String ... value) { + List valueList = Stream.of(value) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + this.headers.addAll(name, valueList); + return this; + } + /** * Copy all given headers. */ @@ -116,6 +150,36 @@ public DefaultResponseCreator headers(HttpHeaders headers) { return this; } + /** + * Add a single cookie. + */ + public DefaultResponseCreator cookie(ResponseCookie cookie) { + // This is really just an alias, but it makes the interface more fluent. + return cookies(cookie); + } + + /** + * Add one or more cookies. + */ + public DefaultResponseCreator cookies(ResponseCookie... cookies) { + for (ResponseCookie cookie : cookies) { + this.headers.add(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + return this; + } + + /** + * Copy all given cookies. + */ + public DefaultResponseCreator cookies(MultiValueMap cookies) { + cookies.values() + .stream() + .flatMap(List::stream) + .forEach(cookie -> this.headers.add(HttpHeaders.SET_COOKIE, cookie.toString())); + + return this; + } @Override public ClientHttpResponse createResponse(@Nullable ClientHttpRequest request) throws IOException { diff --git a/spring-test/src/main/java/org/springframework/test/web/client/response/MockRestResponseCreators.java b/spring-test/src/main/java/org/springframework/test/web/client/response/MockRestResponseCreators.java index 6c3a5b2ac15a..949b32e40118 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/response/MockRestResponseCreators.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/response/MockRestResponseCreators.java @@ -20,6 +20,7 @@ import java.net.URI; import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; @@ -82,6 +83,13 @@ public static DefaultResponseCreator withCreatedEntity(URI location) { return new DefaultResponseCreator(HttpStatus.CREATED).location(location); } + /** + * {@code ResponseCreator} for a 202 response (ACCEPTED). + */ + public static DefaultResponseCreator withAccepted() { + return new DefaultResponseCreator(HttpStatus.ACCEPTED); + } + /** * {@code ResponseCreator} for a 204 response (NO_CONTENT). */ @@ -103,6 +111,43 @@ public static DefaultResponseCreator withUnauthorizedRequest() { return new DefaultResponseCreator(HttpStatus.UNAUTHORIZED); } + /** + * {@code ResponseCreator} for a 403 response (FORBIDDEN). + */ + public static DefaultResponseCreator withForbiddenRequest() { + return new DefaultResponseCreator(HttpStatus.FORBIDDEN); + } + + /** + * {@code ResponseCreator} for a 404 response (NOT_FOUND). + */ + public static DefaultResponseCreator withResourceNotFound() { + return new DefaultResponseCreator(HttpStatus.NOT_FOUND); + } + + /** + * {@code ResponseCreator} for a 409 response (CONFLICT). + */ + public static DefaultResponseCreator withRequestConflict() { + return new DefaultResponseCreator(HttpStatus.CONFLICT); + } + + /** + * {@code ResponseCreator} for a 429 ratelimited response (TOO_MANY_REQUESTS). + */ + public static DefaultResponseCreator withTooManyRequests() { + return new DefaultResponseCreator(HttpStatus.TOO_MANY_REQUESTS); + } + + /** + * {@code ResponseCreator} for a 429 ratelimited response (TOO_MANY_REQUESTS) with a {@code Retry-After} header + * in seconds. + */ + public static DefaultResponseCreator withTooManyRequests(int retryAfter) { + return new DefaultResponseCreator(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, Integer.toString(retryAfter)); + } + /** * {@code ResponseCreator} for a 500 response (SERVER_ERROR). */ @@ -110,6 +155,27 @@ public static DefaultResponseCreator withServerError() { return new DefaultResponseCreator(HttpStatus.INTERNAL_SERVER_ERROR); } + /** + * {@code ResponseCreator} for a 502 response (BAD_GATEWAY). + */ + public static DefaultResponseCreator withBadGateway() { + return new DefaultResponseCreator(HttpStatus.BAD_GATEWAY); + } + + /** + * {@code ResponseCreator} for a 503 response (SERVICE_UNAVAILABLE). + */ + public static DefaultResponseCreator withServiceUnavailable() { + return new DefaultResponseCreator(HttpStatus.SERVICE_UNAVAILABLE); + } + + /** + * {@code ResponseCreator} for a 504 response (GATEWAY_TIMEOUT). + */ + public static DefaultResponseCreator withGatewayTimeout() { + return new DefaultResponseCreator(HttpStatus.GATEWAY_TIMEOUT); + } + /** * {@code ResponseCreator} with a specific HTTP status. * @param status the response status diff --git a/spring-test/src/test/java/org/springframework/test/web/client/response/DefaultResponseCreatorTests.java b/spring-test/src/test/java/org/springframework/test/web/client/response/DefaultResponseCreatorTests.java new file mode 100644 index 000000000000..a11607e40e11 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/client/response/DefaultResponseCreatorTests.java @@ -0,0 +1,230 @@ +/* + * 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.test.web.client.response; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; + +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for the {@link DefaultResponseCreator} factory methods. + * + * @author Ashley Scopes + */ +class DefaultResponseCreatorTests { + @ParameterizedTest(name = "expect status to be set [{0}]") + @ValueSource(ints = {200, 401, 429}) + void expectStatus(int statusValue) throws IOException { + HttpStatus status = HttpStatus.valueOf(statusValue); + ClientHttpResponse response = createResponse(new DefaultResponseCreator(status)); + assertThat(response.getStatusCode()).isEqualTo(status); + } + + @Test + void setBodyFromString() throws IOException { + // Use unicode codepoint for "thinking" emoji to help verify correct encoding is used internally. + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK) + .body("hello, world! \uD83E\uDD14")); + + assertThat(IOUtils.toByteArray(response.getBody())) + .isEqualTo("hello, world! \uD83E\uDD14".getBytes(StandardCharsets.UTF_8)); + } + + @ParameterizedTest(name = "setBodyFromStringWithCharset [{0}]") + @ValueSource(strings = {"Cp1047", "UTF-8", "UTF-16", "US-ASCII", "ISO-8859-1"}) + void setBodyFromStringWithCharset(String charset) throws IOException { + + assumeThat(Charset.isSupported(charset)) + .overridingErrorMessage("charset %s is not supported by this JVM", charset) + .isTrue(); + + Charset charsetObj = Charset.forName(charset); + + String content = "hello! €½$~@><·─"; + + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK) + .body(content, charsetObj)); + + ByteBuffer expectBuff = charsetObj.encode(content); + byte[] expect = new byte[expectBuff.remaining()]; + expectBuff.get(expect); + + assertThat(IOUtils.toByteArray(response.getBody())).isEqualTo(expect); + } + + @Test + void setBodyFromByteArray() throws IOException { + byte[] body = { 0, 9, 18, 27, 36, 45, 54, 63, 72, 81, 90 }; + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK).body(body)); + assertThat(IOUtils.toByteArray(response.getBody())).isEqualTo(body); + } + + @Test + void setBodyFromResource() throws IOException { + byte[] resourceContent = {7, 14, 21, 28, 35}; + + Resource resource = mock(Resource.class); + given(resource.getInputStream()).willReturn(new ByteArrayInputStream(resourceContent)); + + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK).body(resource)); + + then(resource).should().getInputStream(); + + assertThat(IOUtils.toByteArray(response.getBody())).isEqualTo(resourceContent); + } + + @Test + void setContentType() throws IOException { + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK) + .contentType(MediaType.APPLICATION_JSON)); + + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + } + + @Test + void setLocation() throws IOException { + URI uri = UriComponentsBuilder + .fromUriString("https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html") + .build() + .toUri(); + + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK).location(uri)); + assertThat(response.getHeaders().getLocation()).isEqualTo(uri); + } + + @Test + void setHeader() throws IOException { + + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK) + .header("foo", "bar") + .header("baz", "bork") + .headers("lorem", "ipsum", "dolor", "sit", "amet")); + + HttpHeaders headers = response.getHeaders(); + assertThat(headers.get("foo")).isNotNull().isEqualTo(Collections.singletonList("bar")); + assertThat(headers.get("baz")).isNotNull().isEqualTo(Collections.singletonList("bork")); + assertThat(headers.get("lorem")).isNotNull().isEqualTo(Arrays.asList("ipsum", "dolor", "sit", "amet")); + } + + @Test + void setHeaders() throws IOException { + + HttpHeaders firstHeaders = new HttpHeaders(); + firstHeaders.setContentType(MediaType.APPLICATION_JSON); + firstHeaders.setOrigin("https://github.com"); + + HttpHeaders secondHeaders = new HttpHeaders(); + secondHeaders.setAllow(Collections.singleton(HttpMethod.PUT)); + + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK) + .headers(firstHeaders) + .headers(secondHeaders)); + + HttpHeaders responseHeaders = response.getHeaders(); + + assertThat(responseHeaders.getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(responseHeaders.getOrigin()).isEqualTo("https://github.com"); + assertThat(responseHeaders.getAllow()).isEqualTo(Collections.singleton(HttpMethod.PUT)); + } + + @Test + void setCookie() throws IOException { + ResponseCookie firstCookie = ResponseCookie.from("user-id", "1234").build(); + ResponseCookie secondCookie = ResponseCookie.from("group-id", "5432").build(); + ResponseCookie thirdCookie = ResponseCookie.from("cookie-cookie", "cookies").build(); + ResponseCookie fourthCookie = ResponseCookie.from("foobar", "bazbork").build(); + + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK) + .cookie(firstCookie) + .cookie(secondCookie) + .cookies(thirdCookie, fourthCookie)); + + HttpHeaders responseHeaders = response.getHeaders(); + + assertThat(responseHeaders.get(HttpHeaders.SET_COOKIE)) + .isNotNull() + .containsExactly( + firstCookie.toString(), + secondCookie.toString(), + thirdCookie.toString(), + fourthCookie.toString() + ); + } + + @Test + void setCookies() throws IOException { + ResponseCookie firstCookie = ResponseCookie.from("user-id", "1234").build(); + ResponseCookie secondCookie = ResponseCookie.from("group-id", "5432").build(); + MultiValueMap firstCookies = new LinkedMultiValueMap<>(); + firstCookies.add(firstCookie.getName(), firstCookie); + firstCookies.add(secondCookie.getName(), secondCookie); + + ResponseCookie thirdCookie = ResponseCookie.from("cookie-cookie", "cookies").build(); + ResponseCookie fourthCookie = ResponseCookie.from("foobar", "bazbork").build(); + MultiValueMap secondCookies = new LinkedMultiValueMap<>(); + firstCookies.add(thirdCookie.getName(), thirdCookie); + firstCookies.add(fourthCookie.getName(), fourthCookie); + + ClientHttpResponse response = createResponse(new DefaultResponseCreator(HttpStatus.OK) + .cookies(firstCookies) + .cookies(secondCookies)); + + HttpHeaders responseHeaders = response.getHeaders(); + + assertThat(responseHeaders.get(HttpHeaders.SET_COOKIE)) + .isNotNull() + .containsExactly( + firstCookie.toString(), + secondCookie.toString(), + thirdCookie.toString(), + fourthCookie.toString() + ); + } + + private static ClientHttpResponse createResponse(DefaultResponseCreator creator) throws IOException { + URI uri = UriComponentsBuilder.fromUriString("/foo/bar").build().toUri(); + return creator.createResponse(new MockClientHttpRequest(HttpMethod.POST, uri)); + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/client/response/ResponseCreatorsTests.java b/spring-test/src/test/java/org/springframework/test/web/client/response/ResponseCreatorsTests.java index ba50da304f4e..9a043b73e31c 100644 --- a/spring-test/src/test/java/org/springframework/test/web/client/response/ResponseCreatorsTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/client/response/ResponseCreatorsTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; @@ -79,6 +80,15 @@ void created() throws Exception { assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); } + @Test + void accepted() throws Exception { + DefaultResponseCreator responseCreator = MockRestResponseCreators.withAccepted(); + MockClientHttpResponse response = (MockClientHttpResponse) responseCreator.createResponse(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); + } + @Test void noContent() throws Exception { DefaultResponseCreator responseCreator = MockRestResponseCreators.withNoContent(); @@ -109,6 +119,53 @@ void unauthorized() throws Exception { assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); } + @Test + void forbiddenRequest() throws Exception { + DefaultResponseCreator responseCreator = MockRestResponseCreators.withForbiddenRequest(); + MockClientHttpResponse response = (MockClientHttpResponse) responseCreator.createResponse(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); + } + + @Test + void resourceNotFound() throws Exception { + DefaultResponseCreator responseCreator = MockRestResponseCreators.withResourceNotFound(); + MockClientHttpResponse response = (MockClientHttpResponse) responseCreator.createResponse(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); + } + + @Test + void requestConflict() throws Exception { + DefaultResponseCreator responseCreator = MockRestResponseCreators.withRequestConflict(); + MockClientHttpResponse response = (MockClientHttpResponse) responseCreator.createResponse(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); + } + + @Test + void tooManyRequests() throws Exception { + DefaultResponseCreator responseCreator = MockRestResponseCreators.withTooManyRequests(); + MockClientHttpResponse response = (MockClientHttpResponse) responseCreator.createResponse(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + assertThat(response.getHeaders()).doesNotContainKey(HttpHeaders.RETRY_AFTER); + assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); + } + + @Test + void tooManyRequestsWithRetryAfter() throws Exception { + DefaultResponseCreator responseCreator = MockRestResponseCreators.withTooManyRequests(512); + MockClientHttpResponse response = (MockClientHttpResponse) responseCreator.createResponse(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + assertThat(response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER)).isEqualTo("512"); + assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); + } + @Test void serverError() throws Exception { DefaultResponseCreator responseCreator = MockRestResponseCreators.withServerError(); @@ -119,6 +176,33 @@ void serverError() throws Exception { assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); } + @Test + void badGateway() throws Exception { + DefaultResponseCreator responseCreator = MockRestResponseCreators.withBadGateway(); + MockClientHttpResponse response = (MockClientHttpResponse) responseCreator.createResponse(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); + } + + @Test + void serviceUnavailable() throws Exception { + DefaultResponseCreator responseCreator = MockRestResponseCreators.withServiceUnavailable(); + MockClientHttpResponse response = (MockClientHttpResponse) responseCreator.createResponse(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); + } + + @Test + void gatewayTimeout() throws Exception { + DefaultResponseCreator responseCreator = MockRestResponseCreators.withGatewayTimeout(); + MockClientHttpResponse response = (MockClientHttpResponse) responseCreator.createResponse(null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GATEWAY_TIMEOUT); + assertThat(StreamUtils.copyToByteArray(response.getBody()).length).isEqualTo(0); + } + @Test void withStatus() throws Exception { DefaultResponseCreator responseCreator = MockRestResponseCreators.withStatus(HttpStatus.FORBIDDEN);