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}