|
16 | 16 | package org.springframework.test.web.reactive.server;
|
17 | 17 |
|
18 | 18 | import java.net.URI;
|
| 19 | +import java.nio.charset.Charset; |
| 20 | +import java.nio.charset.StandardCharsets; |
| 21 | +import java.util.Arrays; |
| 22 | +import java.util.List; |
19 | 23 | import java.util.function.Supplier;
|
20 | 24 | import java.util.stream.Collectors;
|
21 | 25 |
|
| 26 | +import reactor.core.publisher.MonoProcessor; |
| 27 | + |
22 | 28 | import org.springframework.http.HttpHeaders;
|
23 | 29 | import org.springframework.http.HttpMethod;
|
24 | 30 | import org.springframework.http.HttpStatus;
|
| 31 | +import org.springframework.http.MediaType; |
25 | 32 | import org.springframework.http.ResponseCookie;
|
26 |
| -import org.springframework.http.client.reactive.ClientHttpRequest; |
27 | 33 | import org.springframework.util.MultiValueMap;
|
28 |
| -import org.springframework.web.reactive.function.client.ClientResponse; |
29 | 34 |
|
30 | 35 | /**
|
31 | 36 | * Simple container for request and response details from an exchange performed
|
32 | 37 | * through the {@link WebTestClient}.
|
33 | 38 | *
|
34 |
| - * <p>An {@code ExchangeResult} only exposes the status and the headers from |
35 |
| - * the response which is all that's available when a {@link ClientResponse} is |
36 |
| - * first created. |
| 39 | + * <p>When an {@code ExchangeResult} is first created it has only the status and |
| 40 | + * headers of the response available. When the response body is extracted, the |
| 41 | + * {@code ExchangeResult} is re-created as either {@link EntityExchangeResult} |
| 42 | + * or {@link FluxExchangeResult} that further expose extracted entities. |
37 | 43 | *
|
38 |
| - * <p>Sub-types {@link EntityExchangeResult} and {@link FluxExchangeResult} |
39 |
| - * further expose the response body either as a fully extracted representation |
40 |
| - * or as a {@code Flux} of representations to be consumed. |
| 44 | + * <p>Raw request and response content may also be accessed once complete via |
| 45 | + * {@link #getRequestContent()} or {@link #getResponseContent()}. |
41 | 46 | *
|
42 | 47 | * @author Rossen Stoyanchev
|
43 | 48 | * @since 5.0
|
| 49 | + * |
44 | 50 | * @see EntityExchangeResult
|
45 | 51 | * @see FluxExchangeResult
|
46 | 52 | */
|
47 | 53 | public class ExchangeResult {
|
48 | 54 |
|
49 |
| - private final HttpMethod method; |
50 |
| - |
51 |
| - private final URI url; |
52 |
| - |
53 |
| - private final HttpHeaders requestHeaders; |
| 55 | + private static final List<MediaType> PRINTABLE_MEDIA_TYPES = Arrays.asList( |
| 56 | + MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, MediaType.parseMediaType("text/*"), |
| 57 | + MediaType.APPLICATION_FORM_URLENCODED); |
54 | 58 |
|
55 |
| - private final HttpStatus status; |
56 | 59 |
|
57 |
| - private final HttpHeaders responseHeaders; |
| 60 | + private final WiretapClientHttpRequest request; |
58 | 61 |
|
59 |
| - private final MultiValueMap<String, ResponseCookie> responseCookies; |
| 62 | + private final WiretapClientHttpResponse response; |
60 | 63 |
|
61 | 64 |
|
62 | 65 | /**
|
63 |
| - * Constructor used when a {@code ClientResponse} is first created. |
| 66 | + * Constructor used when the {@code ClientHttpResponse} becomes available. |
64 | 67 | */
|
65 |
| - protected ExchangeResult(ClientHttpRequest request, ClientResponse response) { |
66 |
| - this.method = request.getMethod(); |
67 |
| - this.url = request.getURI(); |
68 |
| - this.requestHeaders = request.getHeaders(); |
69 |
| - this.status = response.statusCode(); |
70 |
| - this.responseHeaders = response.headers().asHttpHeaders(); |
71 |
| - this.responseCookies = response.cookies(); |
| 68 | + protected ExchangeResult(WiretapClientHttpRequest request, WiretapClientHttpResponse response) { |
| 69 | + this.request = request; |
| 70 | + this.response = response; |
72 | 71 | }
|
73 | 72 |
|
74 | 73 | /**
|
75 | 74 | * Copy constructor used when the body is decoded or consumed.
|
76 | 75 | */
|
77 | 76 | protected ExchangeResult(ExchangeResult other) {
|
78 |
| - this.method = other.getMethod(); |
79 |
| - this.url = other.getUrl(); |
80 |
| - this.requestHeaders = other.getRequestHeaders(); |
81 |
| - this.status = other.getStatus(); |
82 |
| - this.responseHeaders = other.getResponseHeaders(); |
83 |
| - this.responseCookies = other.getResponseCookies(); |
| 77 | + this.request = other.request; |
| 78 | + this.response = other.response; |
84 | 79 | }
|
85 | 80 |
|
86 | 81 |
|
87 | 82 | /**
|
88 | 83 | * Return the method of the request.
|
89 | 84 | */
|
90 | 85 | public HttpMethod getMethod() {
|
91 |
| - return this.method; |
| 86 | + return this.request.getMethod(); |
92 | 87 | }
|
93 | 88 |
|
94 | 89 | /**
|
95 | 90 | * Return the request headers that were sent to the server.
|
96 | 91 | */
|
97 | 92 | public URI getUrl() {
|
98 |
| - return this.url; |
| 93 | + return this.request.getURI(); |
99 | 94 | }
|
100 | 95 |
|
101 | 96 | /**
|
102 | 97 | * Return the request headers sent to the server.
|
103 | 98 | */
|
104 | 99 | public HttpHeaders getRequestHeaders() {
|
105 |
| - return this.requestHeaders; |
| 100 | + return this.request.getHeaders(); |
| 101 | + } |
| 102 | + |
| 103 | + /** |
| 104 | + * Return a "promise" for the raw request body content once completed. |
| 105 | + */ |
| 106 | + public MonoProcessor<byte[]> getRequestContent() { |
| 107 | + return this.request.getBodyContent(); |
106 | 108 | }
|
107 | 109 |
|
108 | 110 | /**
|
109 | 111 | * Return the status of the executed request.
|
110 | 112 | */
|
111 | 113 | public HttpStatus getStatus() {
|
112 |
| - return this.status; |
| 114 | + return this.response.getStatusCode(); |
113 | 115 | }
|
114 | 116 |
|
115 | 117 | /**
|
116 | 118 | * Return the response headers received from the server.
|
117 | 119 | */
|
118 | 120 | public HttpHeaders getResponseHeaders() {
|
119 |
| - return this.responseHeaders; |
| 121 | + return this.response.getHeaders(); |
120 | 122 | }
|
121 | 123 |
|
122 | 124 | /**
|
123 | 125 | * Return response cookies received from the server.
|
124 | 126 | */
|
125 | 127 | public MultiValueMap<String, ResponseCookie> getResponseCookies() {
|
126 |
| - return this.responseCookies; |
| 128 | + return this.getResponseCookies(); |
| 129 | + } |
| 130 | + |
| 131 | + /** |
| 132 | + * Return a "promise" for the raw response body content once completed. |
| 133 | + */ |
| 134 | + public MonoProcessor<byte[]> getResponseContent() { |
| 135 | + return this.response.getBodyContent(); |
127 | 136 | }
|
128 | 137 |
|
129 | 138 |
|
@@ -156,39 +165,60 @@ public <T> T assertWithDiagnosticsAndReturn(Supplier<T> assertion) {
|
156 | 165 |
|
157 | 166 | @Override
|
158 | 167 | public String toString() {
|
159 |
| - return "\n\n" + |
160 |
| - formatValue("Request", this.method + " " + getUrl()) + |
161 |
| - formatValue("Status", this.status + " " + getStatusReason()) + |
162 |
| - formatHeading("Response Headers") + formatHeaders(this.responseHeaders) + |
163 |
| - formatHeading("Request Headers") + formatHeaders(this.requestHeaders) + |
| 168 | + return "\n" + |
| 169 | + "> " + getMethod() + " " + getUrl() + "\n" + |
| 170 | + "> " + formatHeaders(getRequestHeaders()) + "\n" + |
| 171 | + "\n" + |
| 172 | + formatContent(getRequestHeaders().getContentType(), getRequestContent()) + "\n" + |
164 | 173 | "\n" +
|
165 |
| - formatValue("Response Body", formatResponseBody()); |
| 174 | + "> " + getStatus() + " " + getStatusReason() + "\n" + |
| 175 | + "> " + formatHeaders(getResponseHeaders()) + "\n" + |
| 176 | + "\n" + |
| 177 | + formatContent(getResponseHeaders().getContentType(), getResponseContent()) + "\n\n"; |
166 | 178 | }
|
167 | 179 |
|
168 | 180 | private String getStatusReason() {
|
169 | 181 | String reason = "";
|
170 |
| - if (this.status != null && this.status.getReasonPhrase() != null) { |
171 |
| - reason = this.status.getReasonPhrase(); |
| 182 | + if (getStatus() != null && getStatus().getReasonPhrase() != null) { |
| 183 | + reason = getStatus().getReasonPhrase(); |
172 | 184 | }
|
173 | 185 | return reason;
|
174 | 186 | }
|
175 | 187 |
|
176 |
| - private String formatHeading(String heading) { |
177 |
| - return "\n" + String.format("%s", heading) + "\n"; |
178 |
| - } |
179 |
| - |
180 |
| - private String formatValue(String label, Object value) { |
181 |
| - return String.format("%18s: %s", label, value) + "\n"; |
182 |
| - } |
183 |
| - |
184 | 188 | private String formatHeaders(HttpHeaders headers) {
|
185 | 189 | return headers.entrySet().stream()
|
186 |
| - .map(entry -> formatValue(entry.getKey(), entry.getValue())) |
187 |
| - .collect(Collectors.joining()); |
| 190 | + .map(entry -> entry.getKey() + ": " + entry.getValue()) |
| 191 | + .collect(Collectors.joining("\n> ")); |
188 | 192 | }
|
189 | 193 |
|
190 |
| - protected String formatResponseBody() { |
191 |
| - return "Not read yet"; |
| 194 | + private String formatContent(MediaType contentType, MonoProcessor<byte[]> body) { |
| 195 | + if (body.isSuccess()) { |
| 196 | + byte[] bytes = body.blockMillis(0); |
| 197 | + if (bytes.length == 0) { |
| 198 | + return "No content"; |
| 199 | + } |
| 200 | + |
| 201 | + if (contentType == null) { |
| 202 | + return "Unknown content type (" + bytes.length + " bytes)"; |
| 203 | + } |
| 204 | + |
| 205 | + Charset charset = contentType.getCharset(); |
| 206 | + if (charset != null) { |
| 207 | + return new String(bytes, charset); |
| 208 | + } |
| 209 | + |
| 210 | + if (PRINTABLE_MEDIA_TYPES.stream().anyMatch(contentType::isCompatibleWith)) { |
| 211 | + return new String(bytes, StandardCharsets.UTF_8); |
| 212 | + } |
| 213 | + |
| 214 | + return "Unknown charset (" + bytes.length + " bytes)"; |
| 215 | + } |
| 216 | + else if (body.isError()) { |
| 217 | + return "I/O failure: " + body.getError().getMessage(); |
| 218 | + } |
| 219 | + else { |
| 220 | + return "Content not available yet"; |
| 221 | + } |
192 | 222 | }
|
193 | 223 |
|
194 | 224 | }
|
0 commit comments