View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.tools.plugin.javadoc;
20  
21  import java.io.BufferedReader;
22  import java.io.FileNotFoundException;
23  import java.io.IOException;
24  import java.io.InputStreamReader;
25  import java.io.Reader;
26  import java.net.MalformedURLException;
27  import java.net.SocketTimeoutException;
28  import java.net.URI;
29  import java.net.URISyntaxException;
30  import java.net.URL;
31  import java.util.AbstractMap;
32  import java.util.Arrays;
33  import java.util.Collection;
34  import java.util.Collections;
35  import java.util.EnumMap;
36  import java.util.EnumSet;
37  import java.util.HashMap;
38  import java.util.List;
39  import java.util.Map;
40  import java.util.Objects;
41  import java.util.Optional;
42  import java.util.function.BiFunction;
43  import java.util.regex.Pattern;
44  
45  import org.apache.http.HttpHeaders;
46  import org.apache.http.HttpHost;
47  import org.apache.http.HttpResponse;
48  import org.apache.http.HttpStatus;
49  import org.apache.http.auth.AuthScope;
50  import org.apache.http.auth.Credentials;
51  import org.apache.http.auth.UsernamePasswordCredentials;
52  import org.apache.http.client.CredentialsProvider;
53  import org.apache.http.client.config.CookieSpecs;
54  import org.apache.http.client.config.RequestConfig;
55  import org.apache.http.client.methods.HttpGet;
56  import org.apache.http.client.protocol.HttpClientContext;
57  import org.apache.http.config.Registry;
58  import org.apache.http.config.RegistryBuilder;
59  import org.apache.http.conn.socket.ConnectionSocketFactory;
60  import org.apache.http.conn.socket.PlainConnectionSocketFactory;
61  import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
62  import org.apache.http.impl.client.BasicCredentialsProvider;
63  import org.apache.http.impl.client.CloseableHttpClient;
64  import org.apache.http.impl.client.HttpClientBuilder;
65  import org.apache.http.impl.client.HttpClients;
66  import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
67  import org.apache.http.message.BasicHeader;
68  import org.apache.maven.settings.Proxy;
69  import org.apache.maven.settings.Settings;
70  import org.apache.maven.tools.plugin.javadoc.FullyQualifiedJavadocReference.MemberType;
71  import org.apache.maven.wagon.proxy.ProxyInfo;
72  import org.apache.maven.wagon.proxy.ProxyUtils;
73  import org.codehaus.plexus.util.StringUtils;
74  
75  /**
76   * Allows to create links to a site generated by javadoc (incl. deep-linking).
77   * The site may be either accessible (online) or non-accessible (offline) when using this class.
78   */
79  class JavadocSite {
80      private static final String PREFIX_MODULE = "module:";
81  
82      final URI baseUri;
83  
84      final Settings settings;
85  
86      final Map<String, String> containedPackageNamesAndModules; // empty in case this an offline site
87  
88      final boolean requireModuleNameInPath;
89  
90      static final EnumMap<
91                      FullyQualifiedJavadocReference.MemberType, EnumSet<JavadocLinkGenerator.JavadocToolVersionRange>>
92              VERSIONS_PER_TYPE;
93  
94      static {
95          VERSIONS_PER_TYPE = new EnumMap<>(FullyQualifiedJavadocReference.MemberType.class);
96          VERSIONS_PER_TYPE.put(
97                  MemberType.CONSTRUCTOR,
98                  EnumSet.of(
99                          JavadocLinkGenerator.JavadocToolVersionRange.JDK7_OR_LOWER,
100                         JavadocLinkGenerator.JavadocToolVersionRange.JDK8_OR_9,
101                         JavadocLinkGenerator.JavadocToolVersionRange.JDK10_OR_HIGHER));
102         VERSIONS_PER_TYPE.put(
103                 MemberType.METHOD,
104                 EnumSet.of(
105                         JavadocLinkGenerator.JavadocToolVersionRange.JDK7_OR_LOWER,
106                         JavadocLinkGenerator.JavadocToolVersionRange.JDK8_OR_9,
107                         JavadocLinkGenerator.JavadocToolVersionRange.JDK10_OR_HIGHER));
108         VERSIONS_PER_TYPE.put(
109                 MemberType.FIELD,
110                 EnumSet.of(
111                         JavadocLinkGenerator.JavadocToolVersionRange.JDK7_OR_LOWER,
112                         JavadocLinkGenerator.JavadocToolVersionRange.JDK8_OR_9));
113     }
114 
115     JavadocLinkGenerator.JavadocToolVersionRange version; // null in case not yet known for online sites
116 
117     /**
118      * Constructor for online sites having an accessible {@code package-list} or {@code element-list}.
119      * @param url
120      * @param settings
121      * @throws IOException
122      */
123     JavadocSite(final URI url, final Settings settings) throws IOException {
124         Map<String, String> containedPackageNamesAndModules;
125         boolean requireModuleNameInPath = false;
126         try {
127             // javadoc > 1.2 && < 10
128             containedPackageNamesAndModules = getPackageListWithModules(url.resolve("package-list"), settings);
129         } catch (FileNotFoundException e) {
130             try {
131                 // javadoc 10+
132                 containedPackageNamesAndModules = getPackageListWithModules(url.resolve("element-list"), settings);
133 
134                 Optional<String> firstModuleName = containedPackageNamesAndModules.values().stream()
135                         .filter(StringUtils::isNotBlank)
136                         .findFirst();
137                 if (firstModuleName.isPresent()) {
138                     // are module names part of the URL (since JDK11)?
139                     try (Reader reader = getReader(
140                             url.resolve(firstModuleName.get() + "/module-summary.html")
141                                     .toURL(),
142                             null)) {
143                         requireModuleNameInPath = true;
144                     } catch (IOException ioe) {
145                         // ignore
146                     }
147                 }
148             } catch (FileNotFoundException e2) {
149                 throw new IOException("Found neither 'package-list' nor 'element-list' below url " + url
150                         + ". The given URL does probably not specify the root of a javadoc site or has been generated with"
151                         + " javadoc 1.2 or older.");
152             }
153         }
154         this.containedPackageNamesAndModules = containedPackageNamesAndModules;
155         this.baseUri = url;
156         this.settings = settings;
157         this.version = null;
158         this.requireModuleNameInPath = requireModuleNameInPath;
159     }
160 
161     /** Constructor for offline sites. This throws {@link UnsupportedOperationException}
162      *  for {@link #hasEntryFor(Optional, Optional)}. */
163     JavadocSite(final URI url, JavadocLinkGenerator.JavadocToolVersionRange version, boolean requireModuleNameInPath) {
164         Objects.requireNonNull(url);
165         this.baseUri = url;
166         Objects.requireNonNull(version);
167         this.version = version;
168         this.settings = null;
169         this.containedPackageNamesAndModules = Collections.emptyMap();
170         this.requireModuleNameInPath = requireModuleNameInPath;
171     }
172 
173     static Map<String, String> getPackageListWithModules(final URI url, final Settings settings) throws IOException {
174         Map<String, String> containedPackageNamesAndModules = new HashMap<>();
175         try (BufferedReader reader = getReader(url.toURL(), settings)) {
176             String line;
177             String module = null;
178             while ((line = reader.readLine()) != null) {
179                 // each line starting with "module:" contains the module name afterwards
180                 if (line.startsWith(PREFIX_MODULE)) {
181                     module = line.substring(PREFIX_MODULE.length());
182                 } else {
183                     containedPackageNamesAndModules.put(line, module);
184                 }
185             }
186             return containedPackageNamesAndModules;
187         }
188     }
189 
190     static boolean findLineContaining(final URI url, final Settings settings, Pattern pattern) throws IOException {
191         try (BufferedReader reader = getReader(url.toURL(), settings)) {
192             return reader.lines().anyMatch(pattern.asPredicate());
193         }
194     }
195 
196     public URI getBaseUri() {
197         return baseUri;
198     }
199 
200     public boolean hasEntryFor(Optional<String> moduleName, Optional<String> packageName) {
201         if (containedPackageNamesAndModules.isEmpty()) {
202             throw new UnsupportedOperationException(
203                     "Operation hasEntryFor(...) is not supported for offline " + "javadoc sites");
204         }
205         if (packageName.isPresent()) {
206             if (moduleName.isPresent()) {
207                 String actualModuleName = containedPackageNamesAndModules.get(packageName.get());
208                 if (!moduleName.get().equals(actualModuleName)) {
209                     return false;
210                 }
211             } else {
212                 if (!containedPackageNamesAndModules.containsKey(packageName.get())) {
213                     return false;
214                 }
215             }
216         } else if (moduleName.isPresent()) {
217             if (!containedPackageNamesAndModules.containsValue(moduleName.get())) {
218                 return false;
219             }
220         } else {
221             throw new IllegalArgumentException("Either module name or package name must be set!");
222         }
223         return true;
224     }
225 
226     /**
227      * Generates a link to a javadoc html page below the javadoc site represented by this object.
228      * The link is not validated (i.e. might point to a non-existing page)
229      * @param
230      * @return the (deep-)link towards a javadoc page
231      * @throws IllegalArgumentException if no link can be created
232      */
233     public URI createLink(String packageName, String className) {
234         try {
235             if (className.endsWith("[]")) {
236                 // url must point to simple class
237                 className = className.substring(0, className.length() - 2);
238             }
239             return createLink(baseUri, Optional.empty(), Optional.of(packageName), Optional.of(className));
240         } catch (URISyntaxException e) {
241             throw new IllegalArgumentException("Could not create link for " + packageName + "." + className, e);
242         }
243     }
244 
245     /**
246      * Splits up a given binary class name into package name and simple class name part.
247      * @param binaryName a binary name according to
248      * <a href="https://docs.oracle.com/javase/specs/jls/se8/html/jls-13.html#jls-13.1">JLS 13.1</a>
249      * @return a key value pair where the key is the package name and the value the class name
250      * @throws IllegalArgumentException if no link can be created
251      */
252     static Map.Entry<String, String> getPackageAndClassName(String binaryName) {
253         // assume binary name according to https://docs.oracle.com/javase/specs/jls/se8/html/jls-13.html#jls-13.1
254         int indexOfDollar = binaryName.indexOf('$');
255         int indexOfDotBetweenPackageAndClass;
256         if (indexOfDollar >= 0) {
257             // check following character
258             if (Character.isDigit(binaryName.charAt(indexOfDollar + 1))) {
259                 // emit some warning, as non resolvable: unclear which type of member follows if it is non digit
260                 throw new IllegalArgumentException(
261                         "Can only resolve binary names of member classes, " + "but not local or anonymous classes");
262             }
263             // member is class, field or method....
264             indexOfDotBetweenPackageAndClass = binaryName.lastIndexOf('.', indexOfDollar);
265             // replace dollar by dot
266             binaryName = binaryName.replace('$', '.');
267         } else {
268             indexOfDotBetweenPackageAndClass = binaryName.lastIndexOf('.');
269         }
270         if (indexOfDotBetweenPackageAndClass < 0) {
271             throw new IllegalArgumentException("Resolving primitives is not supported. "
272                     + "Binary name must contain at least one dot: " + binaryName);
273         }
274         if (indexOfDotBetweenPackageAndClass == binaryName.length() - 1) {
275             throw new IllegalArgumentException("Invalid binary name ending with a dot: " + binaryName);
276         }
277         String packageName = binaryName.substring(0, indexOfDotBetweenPackageAndClass);
278         String className = binaryName.substring(indexOfDotBetweenPackageAndClass + 1, binaryName.length());
279         return new AbstractMap.SimpleEntry<>(packageName, className);
280     }
281 
282     /**
283      * Generates a link to a javadoc html page below the javadoc site represented by this object.
284      * The link is not validated (i.e. might point to a non-existing page)
285      * @param javadocReference a code reference from a javadoc tag
286      * @return  the (deep-)link towards a javadoc page
287      * @throws IllegalArgumentException if no link can be created
288      */
289     public URI createLink(FullyQualifiedJavadocReference javadocReference) throws IllegalArgumentException {
290         final Optional<String> moduleName;
291         if (!requireModuleNameInPath) {
292             moduleName = Optional.empty();
293         } else {
294             moduleName = Optional.ofNullable(javadocReference
295                     .getModuleName()
296                     .orElse(containedPackageNamesAndModules.get(
297                             javadocReference.getPackageName().orElse(null))));
298         }
299         return createLink(javadocReference, baseUri, this::appendMemberAsFragment, moduleName);
300     }
301 
302     static URI createLink(
303             FullyQualifiedJavadocReference javadocReference,
304             URI baseUri,
305             BiFunction<URI, FullyQualifiedJavadocReference, URI> fragmentAppender) {
306         return createLink(javadocReference, baseUri, fragmentAppender, Optional.empty());
307     }
308 
309     static URI createLink(
310             FullyQualifiedJavadocReference javadocReference,
311             URI baseUri,
312             BiFunction<URI, FullyQualifiedJavadocReference, URI> fragmentAppender,
313             Optional<String> pathPrefix)
314             throws IllegalArgumentException {
315         try {
316             URI uri = createLink(
317                     baseUri,
318                     javadocReference.getModuleName(),
319                     javadocReference.getPackageName(),
320                     javadocReference.getClassName());
321             return fragmentAppender.apply(uri, javadocReference);
322         } catch (URISyntaxException e) {
323             throw new IllegalArgumentException("Could not create link for " + javadocReference, e);
324         }
325     }
326 
327     static URI createLink(
328             URI baseUri, Optional<String> moduleName, Optional<String> packageName, Optional<String> className)
329             throws URISyntaxException {
330         StringBuilder link = new StringBuilder();
331         if (moduleName.isPresent()) {
332             link.append(moduleName.get() + "/");
333         }
334         if (packageName.isPresent()) {
335             link.append(packageName.get().replace('.', '/'));
336         }
337         if (!className.isPresent()) {
338             if (packageName.isPresent()) {
339                 link.append("/package-summary.html");
340             } else if (moduleName.isPresent()) {
341                 link.append("/module-summary.html");
342             }
343         } else {
344             link.append('/').append(className.get()).append(".html");
345         }
346         return baseUri.resolve(new URI(null, link.toString(), null));
347     }
348 
349     URI appendMemberAsFragment(URI url, FullyQualifiedJavadocReference reference) {
350         try {
351             return appendMemberAsFragment(url, reference.getMember(), reference.getMemberType());
352         } catch (URISyntaxException | IOException e) {
353             throw new IllegalArgumentException("Could not create link for " + reference, e);
354         }
355     }
356 
357     // CHECKSTYLE_OFF: LineLength
358     /**
359      * @param url
360      * @param optionalMember
361      * @param optionalMemberType
362      * @return
363      * @throws URISyntaxException
364      * @throws IOException
365      * @see <a href=
366      *      "https://github.com/openjdk/jdk8u-dev/blob/f0ac31998d8396d92b4ce99aa345c05e6fd0f02a/langtools/src/share/classes/com/sun/tools/doclets/formats/html/markup/HtmlDocWriter.java#L154">
367      *      Name generation in Javadoc8</a>
368      * @see <a href=
369      *      "https://github.com/openjdk/jdk/tree/master/src/jdk.javadoc/share/classes/jdk/javadoc/internal/doclets/formats/html">Javadoc
370      *      Tools Source since JDK10</a>
371      * @see <a href=
372      *      "https://github.com/openjdk/jdk/tree/jdk-9%2B181/langtools/src/jdk.javadoc/share/classes/jdk/javadoc/internal/doclets/formats/html">Javadoc
373      *      Tools Source JDK9<a/>
374      * @see <a href=
375      *      "https://github.com/openjdk/jdk/tree/jdk8-b93/langtools/src/share/classes/com/sun/tools/javadoc">Javadoc
376      *      Tools Source JDK8</a>
377      */
378     // CHECKSTYLE_ON: LineLength
379     URI appendMemberAsFragment(URI url, Optional<String> optionalMember, Optional<MemberType> optionalMemberType)
380             throws URISyntaxException, IOException {
381         if (!optionalMember.isPresent()) {
382             return url;
383         }
384         MemberType memberType = optionalMemberType.orElse(null);
385         final String member = optionalMember.get();
386         String fragment = member;
387         if (version != null) {
388             fragment = getFragmentForMember(version, member, memberType == MemberType.CONSTRUCTOR);
389         } else {
390             // try out all potential formats
391             for (JavadocLinkGenerator.JavadocToolVersionRange potentialVersion : VERSIONS_PER_TYPE.get(memberType)) {
392                 fragment = getFragmentForMember(potentialVersion, member, memberType == MemberType.CONSTRUCTOR);
393                 if (findAnchor(url, fragment)) {
394                     // only derive javadoc version if there is no ambiguity
395                     if (memberType == MemberType.CONSTRUCTOR || memberType == MemberType.METHOD) {
396                         version = potentialVersion;
397                     }
398                     break;
399                 }
400             }
401         }
402         return new URI(url.getScheme(), url.getSchemeSpecificPart(), fragment);
403     }
404 
405     /**
406      * canonical format given by member is using parentheses and comma.
407      *
408      * @param version
409      * @param member
410      * @param isConstructor
411      * @return the anchor
412      */
413     static String getFragmentForMember(
414             JavadocLinkGenerator.JavadocToolVersionRange version, String member, boolean isConstructor) {
415         String fragment = member;
416         switch (version) {
417             case JDK7_OR_LOWER:
418                 // separate argument by spaces
419                 fragment = fragment.replace(",", ", ");
420                 break;
421             case JDK8_OR_9:
422                 // replace [] by ":A"
423                 fragment = fragment.replace("[]", ":A");
424                 // separate arguments by "-", enclose all arguments in "-" for javadoc 8
425                 fragment = fragment.replace('(', '-').replace(')', '-').replace(',', '-');
426                 break;
427             case JDK10_OR_HIGHER:
428                 if (isConstructor) {
429                     int indexOfOpeningParenthesis = fragment.indexOf('(');
430                     if (indexOfOpeningParenthesis >= 0) {
431                         fragment = "&lt;init&gt;" + fragment.substring(indexOfOpeningParenthesis);
432                     } else {
433                         fragment = "&lt;init&gt;";
434                     }
435                 }
436                 break;
437             default:
438                 throw new IllegalArgumentException("No valid version range given");
439         }
440         return fragment;
441     }
442 
443     boolean findAnchor(URI uri, String anchorNameOrId) throws MalformedURLException, IOException {
444         return findLineContaining(uri, settings, getAnchorPattern(anchorNameOrId));
445     }
446 
447     static Pattern getAnchorPattern(String anchorNameOrId) {
448         // javadoc 17 uses"<section ... id=<anchor> >"
449         return Pattern.compile(".*(name|NAME|id)=\\\"" + Pattern.quote(anchorNameOrId) + "\\\"");
450     }
451 
452     // ---------------
453     // CHECKSTYLE_OFF: LineLength
454     // the following methods are copies from private methods contained in
455     // https://github.com/apache/maven-javadoc-plugin/blob/231316be785782b61d96783fad111325868cfa1f/src/main/java/org/apache/maven/plugins/javadoc/JavadocUtil.java
456     // CHECKSTYLE_ON: LineLength
457     // ---------------
458     /** The default timeout used when fetching url, i.e. 2000. */
459     public static final int DEFAULT_TIMEOUT = 2000;
460 
461     /**
462      * Creates a new {@code HttpClient} instance.
463      *
464      * @param settings The settings to use for setting up the client or {@code null}.
465      * @param url The {@code URL} to use for setting up the client or {@code null}.
466      * @return A new {@code HttpClient} instance.
467      * @see #DEFAULT_TIMEOUT
468      * @since 2.8
469      */
470     private static CloseableHttpClient createHttpClient(Settings settings, URL url) {
471         HttpClientBuilder builder = HttpClients.custom();
472 
473         Registry<ConnectionSocketFactory> csfRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
474                 .register("http", PlainConnectionSocketFactory.getSocketFactory())
475                 .register("https", SSLConnectionSocketFactory.getSystemSocketFactory())
476                 .build();
477 
478         builder.setConnectionManager(new PoolingHttpClientConnectionManager(csfRegistry));
479         builder.setDefaultRequestConfig(RequestConfig.custom()
480                 .setSocketTimeout(DEFAULT_TIMEOUT)
481                 .setConnectTimeout(DEFAULT_TIMEOUT)
482                 .setCircularRedirectsAllowed(true)
483                 .setCookieSpec(CookieSpecs.IGNORE_COOKIES)
484                 .build());
485 
486         // Some web servers don't allow the default user-agent sent by httpClient
487         builder.setUserAgent("Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0)");
488 
489         // Some server reject requests that do not have an Accept header
490         builder.setDefaultHeaders(Arrays.asList(new BasicHeader(HttpHeaders.ACCEPT, "*/*")));
491 
492         if (settings != null && settings.getActiveProxy() != null) {
493             Proxy activeProxy = settings.getActiveProxy();
494 
495             ProxyInfo proxyInfo = new ProxyInfo();
496             proxyInfo.setNonProxyHosts(activeProxy.getNonProxyHosts());
497 
498             if (StringUtils.isNotEmpty(activeProxy.getHost())
499                     && (url == null || !ProxyUtils.validateNonProxyHosts(proxyInfo, url.getHost()))) {
500                 HttpHost proxy = new HttpHost(activeProxy.getHost(), activeProxy.getPort());
501                 builder.setProxy(proxy);
502 
503                 if (StringUtils.isNotEmpty(activeProxy.getUsername()) && activeProxy.getPassword() != null) {
504                     Credentials credentials =
505                             new UsernamePasswordCredentials(activeProxy.getUsername(), activeProxy.getPassword());
506 
507                     CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
508                     credentialsProvider.setCredentials(AuthScope.ANY, credentials);
509                     builder.setDefaultCredentialsProvider(credentialsProvider);
510                 }
511             }
512         }
513         return builder.build();
514     }
515 
516     static BufferedReader getReader(URL url, Settings settings) throws IOException {
517         BufferedReader reader = null;
518 
519         if ("file".equals(url.getProtocol())) {
520             // Intentionally using the platform default encoding here since this is what Javadoc uses internally.
521             reader = new BufferedReader(new InputStreamReader(url.openStream()));
522         } else {
523             // http, https...
524             final CloseableHttpClient httpClient = createHttpClient(settings, url);
525 
526             final HttpGet httpMethod = new HttpGet(url.toString());
527 
528             HttpResponse response;
529             HttpClientContext httpContext = HttpClientContext.create();
530             try {
531                 response = httpClient.execute(httpMethod, httpContext);
532             } catch (SocketTimeoutException e) {
533                 // could be a sporadic failure, one more retry before we give up
534                 response = httpClient.execute(httpMethod, httpContext);
535             }
536 
537             int status = response.getStatusLine().getStatusCode();
538             if (status != HttpStatus.SC_OK) {
539                 throw new FileNotFoundException(
540                         "Unexpected HTTP status code " + status + " getting resource " + url.toExternalForm() + ".");
541             } else {
542                 int pos = url.getPath().lastIndexOf('/');
543                 List<URI> redirects = httpContext.getRedirectLocations();
544                 if (pos >= 0 && isNotEmpty(redirects)) {
545                     URI location = redirects.get(redirects.size() - 1);
546                     String suffix = url.getPath().substring(pos);
547                     // Redirections shall point to the same file, e.g. /package-list
548                     if (!location.getPath().endsWith(suffix)) {
549                         throw new FileNotFoundException(url.toExternalForm() + " redirects to "
550                                 + location.toURL().toExternalForm() + ".");
551                     }
552                 }
553             }
554 
555             // Intentionally using the platform default encoding here since this is what Javadoc uses internally.
556             reader = new BufferedReader(
557                     new InputStreamReader(response.getEntity().getContent())) {
558                 @Override
559                 public void close() throws IOException {
560                     super.close();
561 
562                     if (httpMethod != null) {
563                         httpMethod.releaseConnection();
564                     }
565                     if (httpClient != null) {
566                         httpClient.close();
567                     }
568                 }
569             };
570         }
571 
572         return reader;
573     }
574 
575     /**
576      * Convenience method to determine that a collection is not empty or null.
577      *
578      * @param collection the collection to verify
579      * @return {@code true} if not {@code null} and not empty, otherwise {@code false}
580      */
581     public static boolean isNotEmpty(final Collection<?> collection) {
582         return collection != null && !collection.isEmpty();
583     }
584 }