001/*
002 * Copyright 2002-2019 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.test.web.reactive.server;
018
019import java.net.URI;
020import java.nio.charset.Charset;
021import java.nio.charset.StandardCharsets;
022import java.time.Duration;
023import java.util.Arrays;
024import java.util.List;
025import java.util.stream.Collectors;
026
027import reactor.core.publisher.Mono;
028
029import org.springframework.http.HttpHeaders;
030import org.springframework.http.HttpMethod;
031import org.springframework.http.HttpStatus;
032import org.springframework.http.MediaType;
033import org.springframework.http.ResponseCookie;
034import org.springframework.http.client.reactive.ClientHttpRequest;
035import org.springframework.http.client.reactive.ClientHttpResponse;
036import org.springframework.lang.Nullable;
037import org.springframework.util.Assert;
038import org.springframework.util.MultiValueMap;
039
040/**
041 * Container for request and response details for exchanges performed through
042 * {@link WebTestClient}.
043 *
044 * <p>Note that a decoded response body is not exposed at this level since the
045 * body may not have been decoded and consumed yet. Sub-types
046 * {@link EntityExchangeResult} and {@link FluxExchangeResult} provide access
047 * to a decoded response entity and a decoded (but not consumed) response body
048 * respectively.
049 *
050 * @author Rossen Stoyanchev
051 * @since 5.0
052 * @see EntityExchangeResult
053 * @see FluxExchangeResult
054 */
055public class ExchangeResult {
056
057        private static final List<MediaType> PRINTABLE_MEDIA_TYPES = Arrays.asList(
058                        MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML,
059                        MediaType.parseMediaType("text/*"), MediaType.APPLICATION_FORM_URLENCODED);
060
061
062        private final ClientHttpRequest request;
063
064        private final ClientHttpResponse response;
065
066        private final Mono<byte[]> requestBody;
067
068        private final Mono<byte[]> responseBody;
069
070        private final Duration timeout;
071
072        @Nullable
073        private final String uriTemplate;
074
075
076        /**
077         * Create an instance with an HTTP request and response along with promises
078         * for the serialized request and response body content.
079         *
080         * @param request the HTTP request
081         * @param response the HTTP response
082         * @param requestBody capture of serialized request body content
083         * @param responseBody capture of serialized response body content
084         * @param timeout how long to wait for content to materialize
085         * @param uriTemplate the URI template used to set up the request, if any
086         */
087        ExchangeResult(ClientHttpRequest request, ClientHttpResponse response,
088                        Mono<byte[]> requestBody, Mono<byte[]> responseBody, Duration timeout, @Nullable String uriTemplate) {
089
090                Assert.notNull(request, "ClientHttpRequest is required");
091                Assert.notNull(response, "ClientHttpResponse is required");
092                Assert.notNull(requestBody, "'requestBody' is required");
093                Assert.notNull(responseBody, "'responseBody' is required");
094
095                this.request = request;
096                this.response = response;
097                this.requestBody = requestBody;
098                this.responseBody = responseBody;
099                this.timeout = timeout;
100                this.uriTemplate = uriTemplate;
101        }
102
103        /**
104         * Copy constructor to use after body is decoded and/or consumed.
105         */
106        ExchangeResult(ExchangeResult other) {
107                this.request = other.request;
108                this.response = other.response;
109                this.requestBody = other.requestBody;
110                this.responseBody = other.responseBody;
111                this.timeout = other.timeout;
112                this.uriTemplate = other.uriTemplate;
113        }
114
115
116        /**
117         * Return the method of the request.
118         */
119        public HttpMethod getMethod() {
120                return this.request.getMethod();
121        }
122
123        /**
124         * Return the URI of the request.
125         */
126        public URI getUrl() {
127                return this.request.getURI();
128        }
129
130        /**
131         * Return the original URI template used to prepare the request, if any.
132         */
133        @Nullable
134        public String getUriTemplate() {
135                return this.uriTemplate;
136        }
137
138        /**
139         * Return the request headers sent to the server.
140         */
141        public HttpHeaders getRequestHeaders() {
142                return this.request.getHeaders();
143        }
144
145        /**
146         * Return the raw request body content written through the request.
147         * <p><strong>Note:</strong> If the request content has not been consumed
148         * for any reason yet, use of this method will trigger consumption.
149         * @throws IllegalStateException if the request body is not been fully written.
150         */
151        @Nullable
152        public byte[] getRequestBodyContent() {
153                return this.requestBody.block(this.timeout);
154        }
155
156
157        /**
158         * Return the HTTP status code as an {@link HttpStatus} enum value.
159         */
160        public HttpStatus getStatus() {
161                return this.response.getStatusCode();
162        }
163
164        /**
165         * Return the HTTP status code (potentially non-standard and not resolvable
166         * through the {@link HttpStatus} enum) as an integer.
167         * @since 5.1.10
168         */
169        public int getRawStatusCode() {
170                return this.response.getRawStatusCode();
171        }
172
173        /**
174         * Return the response headers received from the server.
175         */
176        public HttpHeaders getResponseHeaders() {
177                return this.response.getHeaders();
178        }
179
180        /**
181         * Return response cookies received from the server.
182         */
183        public MultiValueMap<String, ResponseCookie> getResponseCookies() {
184                return this.response.getCookies();
185        }
186
187        /**
188         * Return the raw request body content written to the response.
189         * <p><strong>Note:</strong> If the response content has not been consumed
190         * yet, use of this method will trigger consumption.
191         * @throws IllegalStateException if the response is not been fully read.
192         */
193        @Nullable
194        public byte[] getResponseBodyContent() {
195                return this.responseBody.block(this.timeout);
196        }
197
198
199        /**
200         * Execute the given Runnable, catch any {@link AssertionError}, decorate
201         * with {@code AssertionError} containing diagnostic information about the
202         * request and response, and then re-throw.
203         */
204        public void assertWithDiagnostics(Runnable assertion) {
205                try {
206                        assertion.run();
207                }
208                catch (AssertionError ex) {
209                        throw new AssertionError(ex.getMessage() + "\n" + this, ex);
210                }
211        }
212
213
214        @Override
215        public String toString() {
216                return "\n" +
217                                "> " + getMethod() + " " + getUrl() + "\n" +
218                                "> " + formatHeaders(getRequestHeaders(), "\n> ") + "\n" +
219                                "\n" +
220                                formatBody(getRequestHeaders().getContentType(), this.requestBody) + "\n" +
221                                "\n" +
222                                "< " + getStatus() + " " + getStatus().getReasonPhrase() + "\n" +
223                                "< " + formatHeaders(getResponseHeaders(), "\n< ") + "\n" +
224                                "\n" +
225                                formatBody(getResponseHeaders().getContentType(), this.responseBody) +"\n";
226        }
227
228        private String formatHeaders(HttpHeaders headers, String delimiter) {
229                return headers.entrySet().stream()
230                                .map(entry -> entry.getKey() + ": " + entry.getValue())
231                                .collect(Collectors.joining(delimiter));
232        }
233
234        @Nullable
235        private String formatBody(@Nullable MediaType contentType, Mono<byte[]> body) {
236                return body
237                                .map(bytes -> {
238                                        if (contentType == null) {
239                                                return bytes.length + " bytes of content (unknown content-type).";
240                                        }
241                                        Charset charset = contentType.getCharset();
242                                        if (charset != null) {
243                                                return new String(bytes, charset);
244                                        }
245                                        if (PRINTABLE_MEDIA_TYPES.stream().anyMatch(contentType::isCompatibleWith)) {
246                                                return new String(bytes, StandardCharsets.UTF_8);
247                                        }
248                                        return bytes.length + " bytes of content.";
249                                })
250                                .defaultIfEmpty("No content")
251                                .onErrorResume(ex -> Mono.just("Failed to obtain content: " + ex.getMessage()))
252                                .block(this.timeout);
253        }
254
255}