View Javadoc
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 }