1 /*
2 * Copyright (c) 2002-2026 Gargoyle Software Inc.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 * https://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software
10 * distributed under the License is distributed on an "AS IS" BASIS,
11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 * See the License for the specific language governing permissions and
13 * limitations under the License.
14 */
15 package org.htmlunit;
16
17 import static java.nio.charset.StandardCharsets.ISO_8859_1;
18
19 import java.io.IOException;
20 import java.net.URL;
21 import java.nio.charset.Charset;
22 import java.util.ArrayList;
23 import java.util.Collections;
24 import java.util.HashMap;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.concurrent.atomic.AtomicInteger;
28
29 import org.apache.commons.logging.Log;
30 import org.apache.commons.logging.LogFactory;
31 import org.htmlunit.util.ArrayUtils;
32 import org.htmlunit.util.MimeType;
33 import org.htmlunit.util.NameValuePair;
34
35 /**
36 * A fake {@link WebConnection} designed to mock out the actual HTTP connections.
37 *
38 * @author Mike Bowler
39 * @author Noboru Sinohara
40 * @author Marc Guillemot
41 * @author Brad Clarke
42 * @author Ahmed Ashour
43 * @author Ronald Brill
44 */
45 public class MockWebConnection implements WebConnection {
46
47 private static final Log LOG = LogFactory.getLog(MockWebConnection.class);
48
49 private final Map<String, IOException> throwableMap_ = new HashMap<>();
50 private final Map<String, RawResponseData> responseMap_ = new HashMap<>();
51 private RawResponseData defaultResponse_;
52 private volatile WebRequest lastRequest_;
53 private final AtomicInteger requestCount_ = new AtomicInteger();
54 private final List<URL> requestedUrls_ = Collections.synchronizedList(new ArrayList<>());
55
56 /**
57 * Contains the raw data configured for a response.
58 */
59 public static class RawResponseData {
60 private final List<NameValuePair> headers_;
61 private final byte[] byteContent_;
62 private final String stringContent_;
63 private final int statusCode_;
64 private final String statusMessage_;
65 private final Charset charset_;
66
67 RawResponseData(final byte[] byteContent, final int statusCode, final String statusMessage,
68 final String contentType, final List<NameValuePair> headers) {
69 byteContent_ = byteContent;
70 stringContent_ = null;
71 charset_ = null;
72 statusCode_ = statusCode;
73 statusMessage_ = statusMessage;
74 headers_ = compileHeaders(headers, contentType);
75 }
76
77 RawResponseData(final String stringContent, final Charset charset, final int statusCode,
78 final String statusMessage, final String contentType, final List<NameValuePair> headers) {
79 byteContent_ = null;
80 charset_ = charset;
81 stringContent_ = stringContent;
82 statusCode_ = statusCode;
83 statusMessage_ = statusMessage;
84 headers_ = compileHeaders(headers, contentType);
85 }
86
87 private static List<NameValuePair> compileHeaders(final List<NameValuePair> headers, final String contentType) {
88 final List<NameValuePair> compiledHeaders = new ArrayList<>();
89 if (headers != null) {
90 compiledHeaders.addAll(headers);
91 }
92 if (contentType != null) {
93 compiledHeaders.add(new NameValuePair(HttpHeader.CONTENT_TYPE, contentType));
94 }
95 return compiledHeaders;
96 }
97
98 WebResponseData asWebResponseData() {
99 final byte[] content;
100 if (byteContent_ != null) {
101 content = byteContent_;
102 }
103 else if (stringContent_ == null) {
104 content = ArrayUtils.EMPTY_BYTE_ARRAY;
105 }
106 else {
107 content = stringContent_.getBytes(charset_);
108 }
109 return new WebResponseData(content, statusCode_, statusMessage_, headers_);
110 }
111
112 /**
113 * Returns the configured response headers.
114 *
115 * @return the headers
116 */
117 public List<NameValuePair> getHeaders() {
118 return headers_;
119 }
120
121 /**
122 * Returns the configured response content as a byte array.
123 *
124 * @return the byte content, or {@code null} if string content was configured
125 */
126 public byte[] getByteContent() {
127 return byteContent_;
128 }
129
130 /**
131 * Returns the configured response content as a string.
132 *
133 * @return the string content, or {@code null} if byte content was configured
134 */
135 public String getStringContent() {
136 return stringContent_;
137 }
138
139 /**
140 * Returns the configured HTTP status code.
141 *
142 * @return the status code
143 */
144 public int getStatusCode() {
145 return statusCode_;
146 }
147
148 /**
149 * Returns the configured HTTP status message.
150 *
151 * @return the status message
152 */
153 public String getStatusMessage() {
154 return statusMessage_;
155 }
156
157 /**
158 * Returns the configured charset, or {@code null} if byte content was configured.
159 *
160 * @return the charset, or {@code null} for byte content
161 */
162 public Charset getCharset() {
163 return charset_;
164 }
165 }
166
167 /**
168 * {@inheritDoc}
169 */
170 @Override
171 public WebResponse getResponse(final WebRequest request) throws IOException {
172 final RawResponseData rawResponse = getRawResponse(request);
173 return new WebResponse(rawResponse.asWebResponseData(), request, 0);
174 }
175
176 /**
177 * Returns the raw response configured for the given request.
178 *
179 * <p>The request is always recorded (incrementing {@link #getRequestCount()} and
180 * appending to {@link #getRequestedUrls()}) before any configured {@link IOException}
181 * is thrown, mirroring real HTTP behaviour where the request was dispatched even
182 * if the connection subsequently failed.
183 * </p>
184 *
185 * <p>URL lookup first tries an exact match (including query string), then retries
186 * without the query string, then falls back to the default response. If no default
187 * has been set an {@link IllegalStateException} is thrown.
188 * </p>
189 *
190 * @param request the request
191 * @return the raw response
192 * @throws IOException if an {@link IOException} has been registered for the URL
193 * via {@link #setThrowable(URL, IOException)}
194 * @throws IllegalStateException if no response or default response is configured
195 * for the URL
196 */
197 public RawResponseData getRawResponse(final WebRequest request) throws IOException {
198 final URL url = request.getUrl();
199
200 if (LOG.isDebugEnabled()) {
201 LOG.debug("Getting response for " + url.toExternalForm());
202 }
203
204 lastRequest_ = request;
205 requestCount_.incrementAndGet();
206 requestedUrls_.add(url);
207
208 String urlString = url.toExternalForm();
209 final IOException throwable = throwableMap_.get(urlString);
210 if (throwable != null) {
211 // wrap to produce a stack trace pointing to this call site rather than
212 // to where the IOException was originally constructed
213 throw new IOException(throwable.getMessage(), throwable);
214 }
215
216 RawResponseData rawResponse = responseMap_.get(urlString);
217 if (rawResponse == null) {
218 // try to find without query params
219 final int queryStart = urlString.lastIndexOf('?');
220 if (queryStart > -1) {
221 urlString = urlString.substring(0, queryStart);
222 rawResponse = responseMap_.get(urlString);
223 }
224
225 // fall back to default
226 if (rawResponse == null) {
227 rawResponse = defaultResponse_;
228 if (rawResponse == null) {
229 throw new IllegalStateException("No response specified that can handle URL "
230 + request.getHttpMethod()
231 + " [" + urlString + "]");
232 }
233 }
234 }
235
236 return rawResponse;
237 }
238
239 /**
240 * Returns an unmodifiable list of all URLs requested so far, in request order.
241 *
242 * @return the list of requested URLs
243 */
244 public List<URL> getRequestedUrls() {
245 return Collections.unmodifiableList(requestedUrls_);
246 }
247
248 /**
249 * Returns an unmodifiable list of requested URLs relativized against the given base URL.
250 * If a requested URL starts with {@code relativeTo}, the base is stripped; otherwise
251 * the full URL string is returned as-is.
252 *
253 * <p>Note: the base URL string is compared as a plain prefix. Ensure {@code relativeTo}
254 * has a trailing slash if needed to avoid unintended partial matches
255 * (e.g. {@code http://localhost/} rather than {@code http://localhost}).
256 * </p>
257 *
258 * @param relativeTo the base URL whose prefix should be stripped from each requested URL
259 * @return the list of relative (or absolute, if not matching) URL strings
260 */
261 public List<String> getRequestedUrls(final URL relativeTo) {
262 final String baseUrl = relativeTo.toString();
263 final List<String> response = new ArrayList<>();
264 for (final URL url : requestedUrls_) {
265 String s = url.toString();
266 if (s.startsWith(baseUrl)) {
267 s = s.substring(baseUrl.length());
268 }
269 response.add(s);
270 }
271
272 return Collections.unmodifiableList(response);
273 }
274
275 /**
276 * Returns the HTTP method that was used in the last call to
277 * {@link #getResponse(WebRequest)}.
278 *
279 * @return the HTTP method of the last request
280 * @throws IllegalStateException if no request has been made yet
281 */
282 public HttpMethod getLastMethod() {
283 return getLastWebRequest().getHttpMethod();
284 }
285
286 /**
287 * Returns the request parameters that were used in the last call to
288 * {@link #getResponse(WebRequest)}.
289 *
290 * @return the parameters of the last request
291 * @throws IllegalStateException if no request has been made yet
292 */
293 public List<NameValuePair> getLastParameters() {
294 return getLastWebRequest().getRequestParameters();
295 }
296
297 /**
298 * Sets the response that will be returned when the specified URL is requested.
299 *
300 * @param url the URL that will return the given response
301 * @param content the content to return
302 * @param statusCode the status code to return
303 * @param statusMessage the status message to return
304 * @param contentType the content type to return
305 * @param headers the response headers to return
306 */
307 public void setResponse(final URL url, final String content, final int statusCode,
308 final String statusMessage, final String contentType,
309 final List<NameValuePair> headers) {
310
311 setResponse(
312 url,
313 content,
314 statusCode,
315 statusMessage,
316 contentType,
317 ISO_8859_1,
318 headers);
319 }
320
321 /**
322 * Sets the response that will be returned when the specified URL is requested.
323 *
324 * @param url the URL that will return the given response
325 * @param content the content to return
326 * @param statusCode the status code to return
327 * @param statusMessage the status message to return
328 * @param contentType the content type to return
329 * @param charset the charset
330 * @param headers the response headers to return
331 */
332 public void setResponse(final URL url, final String content, final int statusCode,
333 final String statusMessage, final String contentType, final Charset charset,
334 final List<NameValuePair> headers) {
335
336 final RawResponseData responseEntry = buildRawResponseData(content, charset, statusCode, statusMessage,
337 contentType, headers);
338 responseMap_.put(url.toExternalForm(), responseEntry);
339 }
340
341 /**
342 * Sets the exception that will be thrown when the specified URL is requested.
343 *
344 * <p>The stored exception is wrapped at throw time so that the stack trace
345 * points to the actual call site rather than to where the exception was constructed.
346 * </p>
347 *
348 * @param url the URL that will force the exception
349 * @param throwable the {@link IOException} to throw
350 */
351 public void setThrowable(final URL url, final IOException throwable) {
352 throwableMap_.put(url.toExternalForm(), throwable);
353 }
354
355 /**
356 * Sets the response that will be returned when the specified URL is requested.
357 *
358 * @param url the URL that will return the given response
359 * @param content the content to return
360 * @param statusCode the status code to return
361 * @param statusMessage the status message to return
362 * @param contentType the content type to return
363 * @param headers the response headers to return
364 */
365 public void setResponse(final URL url, final byte[] content, final int statusCode,
366 final String statusMessage, final String contentType,
367 final List<NameValuePair> headers) {
368
369 final RawResponseData responseEntry = buildRawResponseData(content, statusCode, statusMessage, contentType,
370 headers);
371 responseMap_.put(url.toExternalForm(), responseEntry);
372 }
373
374 private static RawResponseData buildRawResponseData(final byte[] content, final int statusCode,
375 final String statusMessage, final String contentType, final List<NameValuePair> headers) {
376 return new RawResponseData(content, statusCode, statusMessage, contentType, headers);
377 }
378
379 private static RawResponseData buildRawResponseData(final String content, Charset charset, final int statusCode,
380 final String statusMessage, final String contentType, final List<NameValuePair> headers) {
381
382 if (charset == null) {
383 charset = ISO_8859_1;
384 }
385 return new RawResponseData(content, charset, statusCode, statusMessage, contentType, headers);
386 }
387
388 /**
389 * Convenient method that is the same as calling
390 * {@link #setResponse(URL,String,int,String,String,List)} with a status
391 * of "200 OK", a content type of "text/html" and no additional headers.
392 *
393 * @param url the URL that will return the given response
394 * @param content the content to return
395 */
396 public void setResponse(final URL url, final String content) {
397 setResponse(url, content, 200, "OK", MimeType.TEXT_HTML, null);
398 }
399
400 /**
401 * Convenient method that is the same as calling
402 * {@link #setResponse(URL,String,int,String,String,List)} with a status
403 * of "200 OK" and no additional headers.
404 *
405 * @param url the URL that will return the given response
406 * @param content the content to return
407 * @param contentType the content type to return
408 */
409 public void setResponse(final URL url, final String content, final String contentType) {
410 setResponse(url, content, 200, "OK", contentType, null);
411 }
412
413 /**
414 * Convenient method that is the same as calling
415 * {@link #setResponse(URL, String, int, String, String, Charset, List)} with a status
416 * of "200 OK" and no additional headers.
417 *
418 * @param url the URL that will return the given response
419 * @param content the content to return
420 * @param contentType the content type to return
421 * @param charset the charset
422 */
423 public void setResponse(final URL url, final String content, final String contentType, final Charset charset) {
424 setResponse(url, content, 200, "OK", contentType, charset, null);
425 }
426
427 /**
428 * Specify a generic HTML page that will be returned when the given URL is specified.
429 * The page will contain only minimal HTML to satisfy the HTML parser but will contain
430 * the specified title so that tests can check for titleText.
431 *
432 * @param url the URL that will return the given response
433 * @param title the title of the page
434 */
435 public void setResponseAsGenericHtml(final URL url, final String title) {
436 final String content = "<!DOCTYPE html><html><head><title>" + title + "</title></head><body></body></html>";
437 setResponse(url, content);
438 }
439
440 /**
441 * Sets the response that will be returned when a URL is requested that does
442 * not have a specific response configured for it.
443 *
444 * @param content the content to return
445 * @param statusCode the status code to return
446 * @param statusMessage the status message to return
447 * @param contentType the content type to return
448 */
449 public void setDefaultResponse(final String content, final int statusCode,
450 final String statusMessage, final String contentType) {
451
452 defaultResponse_ = buildRawResponseData(content, null, statusCode, statusMessage, contentType, null);
453 }
454
455 /**
456 * Sets the response that will be returned when a URL is requested that does
457 * not have a specific response configured for it.
458 *
459 * @param content the content to return
460 * @param statusCode the status code to return
461 * @param statusMessage the status message to return
462 * @param contentType the content type to return
463 */
464 public void setDefaultResponse(final byte[] content, final int statusCode,
465 final String statusMessage, final String contentType) {
466
467 defaultResponse_ = buildRawResponseData(content, statusCode, statusMessage, contentType, null);
468 }
469
470 /**
471 * Sets the response that will be returned when a URL is requested that does
472 * not have a specific response configured for it.
473 *
474 * @param content the content to return
475 */
476 public void setDefaultResponse(final String content) {
477 setDefaultResponse(content, 200, "OK", MimeType.TEXT_HTML);
478 }
479
480 /**
481 * Sets the response that will be returned when a URL is requested that does
482 * not have a specific response configured for it.
483 *
484 * @param content the content to return
485 * @param contentType the content type to return
486 */
487 public void setDefaultResponse(final String content, final String contentType) {
488 setDefaultResponse(content, 200, "OK", contentType, null);
489 }
490
491 /**
492 * Sets the response that will be returned when a URL is requested that does
493 * not have a specific response configured for it.
494 *
495 * @param content the content to return
496 * @param contentType the content type to return
497 * @param charset the charset
498 */
499 public void setDefaultResponse(final String content, final String contentType, final Charset charset) {
500 setDefaultResponse(content, 200, "OK", contentType, charset, null);
501 }
502
503 /**
504 * Sets the response that will be returned when a URL is requested that does
505 * not have a specific response configured for it.
506 *
507 * @param content the content to return
508 * @param statusCode the status code to return
509 * @param statusMessage the status message to return
510 * @param contentType the content type to return
511 * @param headers the response headers to return
512 */
513 public void setDefaultResponse(final String content, final int statusCode,
514 final String statusMessage, final String contentType,
515 final List<NameValuePair> headers) {
516
517 defaultResponse_ = buildRawResponseData(content, null, statusCode, statusMessage, contentType, headers);
518 }
519
520 /**
521 * Sets the response that will be returned when a URL is requested that does
522 * not have a specific response configured for it.
523 *
524 * @param content the content to return
525 * @param statusCode the status code to return
526 * @param statusMessage the status message to return
527 * @param contentType the content type to return
528 * @param charset the charset
529 * @param headers the response headers to return
530 */
531 public void setDefaultResponse(final String content, final int statusCode,
532 final String statusMessage, final String contentType, final Charset charset,
533 final List<NameValuePair> headers) {
534
535 defaultResponse_ = buildRawResponseData(content, charset, statusCode, statusMessage, contentType, headers);
536 }
537
538 /**
539 * Returns the additional headers that were used in the last call
540 * to {@link #getResponse(WebRequest)}.
541 *
542 * @return the additional headers of the last request
543 * @throws IllegalStateException if no request has been made yet
544 */
545 public Map<String, String> getLastAdditionalHeaders() {
546 return getLastWebRequest().getAdditionalHeaders();
547 }
548
549 /**
550 * Returns the {@link WebRequest} that was used in the last call
551 * to {@link #getResponse(WebRequest)}.
552 *
553 * @return the last {@link WebRequest}
554 * @throws IllegalStateException if no request has been made yet
555 */
556 public WebRequest getLastWebRequest() {
557 if (lastRequest_ == null) {
558 throw new IllegalStateException("No request has been made yet.");
559 }
560 return lastRequest_;
561 }
562
563 /**
564 * Returns the number of requests made to this mock web connection.
565 *
566 * @return the number of requests made to this mock web connection
567 */
568 public int getRequestCount() {
569 return requestCount_.get();
570 }
571
572 /**
573 * Returns whether a response has been configured for the given URL.
574 *
575 * @param url the URL to check
576 * @return {@code true} if a response has been configured for the URL; {@code false} otherwise
577 */
578 public boolean hasResponse(final URL url) {
579 return responseMap_.containsKey(url.toExternalForm());
580 }
581
582 /**
583 * Delegates to {@link #clear()}, resetting all configured responses, recorded
584 * requests, and request counts.
585 */
586 @Override
587 public void close() {
588 clear();
589 }
590
591 /**
592 * Resets all state: clears all configured responses and throwables, the default
593 * response, the last request, the request count, and the list of requested URLs.
594 *
595 * <p>Note: {@link #close()} delegates to this method, so using this connection</p>
596 * in a try-with-resources block will reset all configured state on exit.
597 */
598 public void clear() {
599 throwableMap_.clear();
600 responseMap_.clear();
601 defaultResponse_ = null;
602 lastRequest_ = null;
603 requestCount_.set(0);
604 requestedUrls_.clear();
605 }
606 }