View Javadoc

1   package com.atlassian.sal.core.net;
2   
3   import java.io.ByteArrayInputStream;
4   import java.io.IOException;
5   import java.io.InputStream;
6   import java.io.UnsupportedEncodingException;
7   import java.net.MalformedURLException;
8   import java.net.SocketException;
9   import java.net.URL;
10  import java.util.ArrayList;
11  import java.util.Arrays;
12  import java.util.Collections;
13  import java.util.HashMap;
14  import java.util.List;
15  import java.util.Map;
16  
17  import javax.net.ssl.SSLException;
18  
19  import com.atlassian.sal.api.net.Request;
20  import com.atlassian.sal.api.net.RequestFilePart;
21  import com.atlassian.sal.api.net.ResponseConnectTimeoutException;
22  import com.atlassian.sal.api.net.ResponseException;
23  import com.atlassian.sal.api.net.ResponseHandler;
24  import com.atlassian.sal.api.net.ResponseProtocolException;
25  import com.atlassian.sal.api.net.ResponseReadTimeoutException;
26  import com.atlassian.sal.api.net.ResponseStatusException;
27  import com.atlassian.sal.api.net.ResponseTransportException;
28  import com.atlassian.sal.api.net.ReturningResponseHandler;
29  import com.atlassian.sal.api.net.auth.Authenticator;
30  import com.atlassian.sal.api.user.UserManager;
31  import com.atlassian.sal.core.net.auth.BaseAuthenticator;
32  import com.atlassian.sal.core.net.auth.HttpClientAuthenticator;
33  import com.atlassian.sal.core.net.auth.SeraphAuthenticator;
34  import com.atlassian.sal.core.net.auth.TrustedTokenAuthenticator;
35  import com.atlassian.sal.core.trusted.CertificateFactory;
36  
37  import com.google.common.annotations.VisibleForTesting;
38  import com.google.common.collect.ImmutableList;
39  
40  import org.apache.commons.httpclient.ConnectTimeoutException;
41  import org.apache.commons.httpclient.Header;
42  import org.apache.commons.httpclient.HostConfiguration;
43  import org.apache.commons.httpclient.HttpClient;
44  import org.apache.commons.httpclient.HttpConnectionManager;
45  import org.apache.commons.httpclient.HttpException;
46  import org.apache.commons.httpclient.HttpMethod;
47  import org.apache.commons.httpclient.NoHttpResponseException;
48  import org.apache.commons.httpclient.URI;
49  import org.apache.commons.httpclient.methods.DeleteMethod;
50  import org.apache.commons.httpclient.methods.EntityEnclosingMethod;
51  import org.apache.commons.httpclient.methods.GetMethod;
52  import org.apache.commons.httpclient.methods.HeadMethod;
53  import org.apache.commons.httpclient.methods.InputStreamRequestEntity;
54  import org.apache.commons.httpclient.methods.OptionsMethod;
55  import org.apache.commons.httpclient.methods.PostMethod;
56  import org.apache.commons.httpclient.methods.PutMethod;
57  import org.apache.commons.httpclient.methods.TraceMethod;
58  import org.apache.commons.httpclient.methods.multipart.FilePart;
59  import org.apache.commons.httpclient.methods.multipart.FilePartSource;
60  import org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity;
61  import org.apache.commons.httpclient.params.HttpClientParams;
62  import org.apache.commons.httpclient.params.HttpConnectionManagerParams;
63  import org.apache.commons.httpclient.params.HttpMethodParams;
64  import org.apache.commons.lang.StringUtils;
65  import org.slf4j.Logger;
66  import org.slf4j.LoggerFactory;
67  
68  /**
69   * HttpClient implementation of Request interface
70   */
71  public class HttpClientRequest implements Request<HttpClientRequest, HttpClientResponse>
72  {
73      private static final Logger log = LoggerFactory.getLogger(HttpClientRequest.class);
74  
75      /**
76       * The default number of redirects that will be followed.
77       * Can be overridden using the system property "http.protocol.max-redirects=10" etc.
78       * @see HttpClientParams.MAX_REDIRECTS
79       * 
80       * Raised the default in 2.10.1 to 20 as currently JIRA home page does 4 redirects.
81       * NB prior to v2.10.1 one "redirect" was used up handling a successful request.
82       */
83      @SuppressWarnings ("JavadocReference")
84      public static final int MAX_REDIRECTS = 20;
85  
86      /**
87       * The maximum number of redirects allowed within HttpClientRequest
88       */
89      private final int maxRedirects = Integer.getInteger(HttpClientParams.MAX_REDIRECTS, MAX_REDIRECTS);
90  
91      private final Request.MethodType methodType;
92      /**
93       * The absolute URL for the current request.
94       */
95      private String absoluteUrl;
96      /**
97       * The relative or absolute URL for the current request, depends on the state of HttpClient.
98       * see selectUrlFormat()
99       */
100     private String url;
101     private final Map<String, List<String>> parameters = new HashMap<String, List<String>>();
102     private final Map<String, List<String>> headers = new HashMap<String, List<String>>();
103     private final List<HttpClientAuthenticator> authenticators = new ArrayList<HttpClientAuthenticator>();
104     private final CertificateFactory certificateFactory;
105 
106     private final HttpClient httpClient;
107     private final UserManager userManager;
108     private String requestBody;
109     private String requestContentType;
110     private boolean followRedirects = true;
111     private List<RequestFilePart> files;
112 
113     public HttpClientRequest(final HttpClient httpClient, final MethodType methodType, final String url,
114                              final CertificateFactory certificateFactory, final UserManager userManager)
115     {
116         this.httpClient = httpClient;
117         this.methodType = methodType;
118         // an absolute url is expected. createRequest will fail otherwise
119         this.absoluteUrl = url;
120         this.url = selectUrlFormat(httpClient, this.absoluteUrl);
121         this.certificateFactory = certificateFactory;
122         this.userManager = userManager;
123         if (isEntityEnclosingMethod())
124         {
125             followRedirects = false;
126         }
127     }
128 
129     public HttpClientRequest setUrl(final String url)
130     {
131         this.url = selectUrlFormat(httpClient, url);
132 
133         // Reconfigure the protocol setting for the new URL
134         // as it may need a different protocol
135         configureProtocol(url);
136 
137         // Reconfigure the proxy setting for the new URL
138         // as it may or may not need to go through the system proxy
139         configureProxy();
140         return this;
141     }
142 
143     // ------------------------ authenticators -------------------------------------------
144 
145     public HttpClientRequest addAuthentication(final Authenticator authenticator)
146     {
147         if (authenticator instanceof HttpClientAuthenticator)
148         {
149             this.authenticators.add((HttpClientAuthenticator) authenticator);
150         }
151         else
152         {
153             log.warn("Authenticator '" + authenticator + "'is not instance of " + HttpClientAuthenticator.class.getName());
154         }
155         return this;
156     }
157 
158     public HttpClientRequest addTrustedTokenAuthentication()
159     {
160         return this.addTrustedTokenAuthentication(userManager.getRemoteUsername());
161     }
162 
163     public HttpClientRequest addTrustedTokenAuthentication(final String username)
164     {
165         final TrustedTokenAuthenticator trustedTokenAuthenticator = new TrustedTokenAuthenticator(username, this.absoluteUrl,
166                 certificateFactory);
167 
168         this.authenticators.add(trustedTokenAuthenticator);
169         return this;
170     }
171 
172     public HttpClientRequest addBasicAuthentication(final String username, final String password)
173     {
174         this.authenticators.add(new BaseAuthenticator(username, password));
175         return this;
176     }
177 
178     public HttpClientRequest addSeraphAuthentication(final String username, final String password)
179     {
180         this.authenticators.add(new SeraphAuthenticator(username, password));
181         return this;
182     }
183 
184     @VisibleForTesting
185     protected ImmutableList<HttpClientAuthenticator> getAuthenticators()
186     {
187         return ImmutableList.copyOf(authenticators);
188     }
189 
190     // ------------------------ various setters -------------------------------------------
191 
192     public HttpClientRequest setConnectionTimeout(final int connectionTimeout)
193     {
194         final HttpConnectionManagerParams params = httpClient.getHttpConnectionManager().getParams();
195         params.setConnectionTimeout(connectionTimeout);
196         return this;
197     }
198 
199     public HttpClientRequest setSoTimeout(final int soTimeout)
200     {
201         final HttpConnectionManagerParams params = httpClient.getHttpConnectionManager().getParams();
202         params.setSoTimeout(soTimeout);
203         return this;
204     }
205 
206     public HttpClientRequest setRequestBody(final String requestBody)
207     {
208         if (!isEntityEnclosingMethod())
209         {
210             throw new IllegalArgumentException("Only POST and PUT methods can have request body");
211         }
212         if (files != null)
213         {
214             throw new IllegalStateException("This request contains already file parts! The request body can only be set, if the request does not contain file parts.");
215         }
216         this.requestBody = requestBody;
217         return this;
218     }
219 
220     public HttpClientRequest setFiles(final List<RequestFilePart> files)
221     {
222         if (files == null)
223         {
224             throw new IllegalArgumentException("Files cannot be null");
225         }
226         if (!isEntityEnclosingMethod())
227         {
228             throw new IllegalArgumentException("Only POST and PUT methods can have a multi part body with file parts.");
229         }
230         if (requestBody != null)
231         {
232             throw new IllegalStateException("The request body is not empty! The request can only have file parts if the request is empty.");
233         }
234         this.files = files;
235         return this;
236     }
237 
238     private boolean isEntityEnclosingMethod()
239     {
240         return (methodType == MethodType.POST || methodType == MethodType.PUT);
241     }
242 
243     public HttpClientRequest setEntity(Object entity)
244     {
245         throw new UnsupportedOperationException("This SAL request does not support object marshaling. Use the RequestFactory component instead.");
246     }
247 
248     public HttpClientRequest setRequestContentType(final String requestContentType)
249     {
250         this.requestContentType = requestContentType;
251         return this;
252     }
253 
254     public HttpClientRequest addRequestParameters(final String... params)
255     {
256         if (methodType != MethodType.POST)
257         {
258             throw new UnsupportedOperationException("Only POST methods accept request parameters. For all other HTTP methods types http parameters have to be part of the URL string.");
259         }
260 
261         if (params.length % 2 != 0)
262         {
263             throw new IllegalArgumentException("You must enter an even number of arguments");
264         }
265 
266         for (int i = 0; i < params.length; i += 2)
267         {
268             final String name = params[i];
269             final String value = params[i + 1];
270             List<String> list = parameters.get(name);
271             if (list == null)
272             {
273                 list = new ArrayList<String>();
274                 parameters.put(name, list);
275             }
276             list.add(value);
277         }
278         return this;
279     }
280 
281     public HttpClientRequest addHeader(final String headerName, final String headerValue)
282     {
283         List<String> list = headers.get(headerName);
284         if (list == null)
285         {
286             list = new ArrayList<String>();
287             headers.put(headerName, list);
288         }
289         list.add(headerValue);
290         return this;
291     }
292 
293     public HttpClientRequest setHeader(final String headerName, final String headerValue)
294     {
295         headers.put(headerName, new ArrayList<String>(Arrays.asList(headerValue)));
296         return this;
297     }
298 
299     public HttpClientRequest setFollowRedirects(boolean follow)
300     {
301         if (isEntityEnclosingMethod() && follow)
302         {
303             throw new IllegalStateException("Entity enclosing requests cannot be redirected without user intervention!");
304         }
305         this.followRedirects = follow;
306         return this;
307     }
308 
309     public HttpClientRequest addHeaders(final String... params)
310     {
311         if (params.length % 2 != 0)
312         {
313             throw new IllegalArgumentException("You must enter even number of arguments");
314         }
315 
316         for (int i = 0; i < params.length; i += 2)
317         {
318             final String name = params[i];
319             final String value = params[i + 1];
320             List<String> list = headers.get(name);
321             if (list == null)
322             {
323                 list = new ArrayList<String>();
324                 headers.put(name, list);
325             }
326             list.add(value);
327         }
328         return this;
329     }
330 
331     public <E> E executeAndReturn(ReturningResponseHandler<HttpClientResponse, E> httpClientResponseResponseHandler)
332             throws ResponseException
333     {
334         final HttpMethod method = makeMethod();
335         method.setFollowRedirects(false); // we want to handle these ourselves
336         processHeaders(method);
337         processAuthenticator(method);
338         processParameters(method);
339         if (log.isDebugEnabled())
340         {
341             // Still use an isDebugEnabled here so we don't waste time getting the headers if logging off.
342             final Header[] requestHeaders = method.getRequestHeaders();
343             String headerStr = requestHeaders == null ? "none" : Arrays.asList(requestHeaders).toString();
344             log.debug("Calling {} {} with headers: {}", new Object[]{ method.getName(), this.url, headerStr });
345         }
346         method.setRequestHeader("Connection", "close");
347         ResponseException rethrown = null;
348         try
349         {
350             executeMethod(method, 0);
351             return httpClientResponseResponseHandler.handle(new HttpClientResponse(method));
352         }
353         catch (ConnectTimeoutException cte)
354         {
355             // We keep a copy of the exception solely to log it in a common 'finally' line.
356             rethrown = new ResponseConnectTimeoutException(cte.getMessage(), cte);
357             throw rethrown;
358         }
359         catch (SocketException se)
360         {
361             rethrown = new ResponseTransportException(se.getMessage(), se);
362             throw rethrown;
363         }
364         catch (NoHttpResponseException nhre)
365         {
366             rethrown = new ResponseReadTimeoutException(nhre.getMessage(), nhre);
367             throw rethrown;
368         }
369         catch (HttpException he)
370         {
371             rethrown = new ResponseProtocolException(he.getMessage(), he);
372             throw rethrown;
373         }
374         catch (IOException ioe)
375         {
376             rethrown = new ResponseException(ioe);
377             throw rethrown;
378         }
379         finally
380         {
381             if (rethrown != null)
382             {
383                 log.debug("Call to {} {} failed with {}, message: {}", new Object[]{ method.getName(), this.url, rethrown.getClass().getSimpleName(), rethrown.getMessage() });
384             }
385             else
386             {
387                 log.debug("Called {} {} with response status: {}", new Object[]{ method.getName(), this.url, method.getStatusLine() });
388             }
389 
390             exhaustResponseContents(method);
391             method.releaseConnection();
392             // see https://extranet.atlassian.com/display/~doflynn/2008/05/19/HttpClient+leaks+sockets+into+CLOSE_WAIT
393             final HttpConnectionManager httpConnectionManager = httpClient.getHttpConnectionManager();
394             if (httpConnectionManager != null)
395             {
396                 httpConnectionManager.closeIdleConnections(0);
397             }
398         }
399     }
400 
401     /* (non-Javadoc)
402      * @see com.atlassian.sal.api.net.Request#execute()
403      */
404     public void execute(final ResponseHandler<HttpClientResponse> responseHandler)
405             throws ResponseException
406     {
407         executeAndReturn(new ReturningResponseHandler<HttpClientResponse, Void>()
408         {
409             public Void handle(final HttpClientResponse response) throws ResponseException
410             {
411                 responseHandler.handle(response);
412                 return null;
413             }
414         });
415     }
416 
417     private static void exhaustResponseContents(final HttpMethod response)
418     {
419         InputStream body = null;
420         try
421         {
422             body = response.getResponseBodyAsStream();
423             if (body == null)
424             {
425                 return;
426             }
427             final byte[] buf = new byte[512];
428             @SuppressWarnings("unused")
429             int bytesRead = 0;
430             while ((bytesRead = body.read(buf)) != -1)
431             {
432                 // Read everything the server has to say before closing
433                 // the stream, or the server would get a unexpected
434                 // "connection closed" error.
435             }
436         }
437         catch (final IOException e)
438         {
439             // Ignore, we're already done with the response anyway.
440         }
441         finally
442         {
443             shutdownStream(body);
444         }
445     }
446 
447     /**
448      * Unconditionally close an <code>InputStream</code>.
449      * Equivalent to {@link InputStream#close()}, except any exceptions will be ignored.
450      *
451      * @param input A (possibly null) InputStream
452      */
453     public static void shutdownStream(final InputStream input)
454     {
455         if (null == input)
456         {
457             return;
458         }
459 
460         try
461         {
462             input.close();
463         }
464         catch (final IOException ioe)
465         {
466             // Do nothing
467         }
468     }
469 
470     public String execute() throws ResponseException
471     {
472         return executeAndReturn(new ReturningResponseHandler<HttpClientResponse, String>()
473         {
474             public String handle(final HttpClientResponse response) throws ResponseException
475             {
476                 if (!response.isSuccessful())
477                 {
478                     throw new ResponseStatusException("Unexpected response received. Status code: " + response.getStatusCode(),
479                                                       response);
480                 }
481                 return response.getResponseBodyAsString();
482             }
483         });
484     }
485     // ------------------------------------------------------------------------------------------------------------------------------------
486     // -------------------------------------------------- private methods ------------------------------------------------------------------
487     // ------------------------------------------------------------------------------------------------------------------------------------
488 
489     protected HttpMethod makeMethod()
490     {
491         final HttpMethod method;
492         switch (methodType)
493         {
494             case POST:
495                 method = new PostMethod(url);
496                 break;
497             case PUT:
498                 method = new PutMethod(url);
499                 break;
500             case DELETE:
501                 method = new DeleteMethod(url);
502                 break;
503             case OPTIONS:
504                 method = new OptionsMethod(url);
505                 break;
506             case HEAD:
507                 method = new HeadMethod(url);
508                 break;
509             case TRACE:
510                 method = new TraceMethod(url);
511                 break;
512             default:
513                 method = new GetMethod(url);
514                 break;
515         }
516         return method;
517     }
518 
519     /**
520      * Configures the proxy for the underlying HttpClient.
521      */
522     protected void configureProxy()
523     {
524         new HttpClientProxyConfig().configureProxy(this.httpClient, this.url);
525     }
526 
527     /**
528      * Configures the protocol for the underlying HttpClient.
529      */
530     protected void configureProtocol(final String url)
531     {
532         HttpClientProtocolConfig.configureProtocol(this.httpClient, url);
533     }
534 
535     private void executeMethod(final HttpMethod method, int redirectCounter) throws IOException, ResponseProtocolException
536     {
537         // only check against redirects. Do not include initial requests in the check.
538         if (redirectCounter > 0 && redirectCounter > this.getMaxRedirects())
539         {
540             // Putting the error message inside a wrapped IOException for backward compatibility
541             throw new ResponseProtocolException(new IOException("Maximum number of redirects (" + this.getMaxRedirects() + ") reached."));
542         }
543         else
544         {
545             // execute the method.
546             int statusCode = executeMethod(method);
547 
548             if(statusCode >= 300 && statusCode <= 399)
549             {
550                 // we have a redirect so count it.
551                 redirectCounter++;
552 
553                 if (followRedirects)
554                 {
555                     String redirectLocation;
556                     final Header locationHeader = method.getResponseHeader("location");
557                     if (locationHeader != null)
558                     {
559                         redirectLocation = locationHeader.getValue();
560                         HttpClientProtocolConfig.configureProtocol(httpClient, redirectLocation);
561                         method.setURI(new URI(selectUrlFormat(httpClient, redirectLocation), true));
562                         executeMethod(method, redirectCounter);
563                     }
564                     else
565                     {
566                         // The response is invalid and did not provide the new location for
567                         // the resource.  Report an error or possibly handle the response
568                         // like a 404 Not Found error.
569                         // (Putting the error message inside a wrapped IOException for backward compatibility)
570                         throw new ResponseProtocolException(new IOException("HTTP response returned redirect code " + statusCode + " but did not provide a location header"));
571                     }
572                 }
573             }
574         }
575     }
576 
577     /**
578      * Get the maximum number of redirects allowed by this instance.
579      */
580     public int getMaxRedirects()
581     {
582         return maxRedirects;
583     }
584 
585     private int executeMethod(HttpMethod method) throws IOException
586     {
587         return httpClient.executeMethod(method);
588     }
589 
590     private void processHeaders(final HttpMethod method)
591     {
592         for (final String headerName : this.headers.keySet())
593         {
594             for (final String headerValue : this.headers.get(headerName))
595             {
596                 method.addRequestHeader(headerName, headerValue);
597             }
598         }
599     }
600 
601     private void processParameters(final HttpMethod method)
602     {
603         if (!(method instanceof EntityEnclosingMethod))
604         {
605             return;    // only POST and PUT method can apply
606         }
607         EntityEnclosingMethod entityEnclosingMethod = (EntityEnclosingMethod) method;
608         // Add post parameters
609         if ((entityEnclosingMethod instanceof PostMethod) && !this.parameters.isEmpty())
610         {
611             final PostMethod postMethod = (PostMethod) method;
612             postMethod.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
613             for (final String parameterName : this.parameters.keySet())
614             {
615                 for (final String parameterValue : this.parameters.get(parameterName))
616                 {
617                     postMethod.addParameter(parameterName, parameterValue);
618                 }
619             }
620             return;
621         }
622 
623         // Set request body
624         if (this.requestBody != null)
625         {
626             setRequestBody(entityEnclosingMethod);
627         }
628 
629         if (files != null && !files.isEmpty())
630         {
631             setFileParts(entityEnclosingMethod);
632         }
633     }
634 
635     private void setFileParts(final EntityEnclosingMethod entityEnclosingMethod)
636     {
637         final List<FilePart> fileParts = new ArrayList<FilePart>();
638         for (RequestFilePart file : files)
639         {
640             try
641             {
642                 FilePartSource partSource = new FilePartSource(file.getFileName(), file.getFile());
643                 FilePart filePart = new FilePart(file.getParameterName(), partSource, file.getContentType(), null);
644                 fileParts.add(filePart);
645             }
646             catch (IOException e)
647             {
648                 throw new RuntimeException(e);
649             }
650         }
651         MultipartRequestEntity entity = new MultipartRequestEntity(fileParts.toArray(new FilePart[fileParts.size()]), new HttpMethodParams());
652         entityEnclosingMethod.setRequestEntity(entity);
653     }
654 
655     private void setRequestBody(final EntityEnclosingMethod method)
656     {
657         final String contentType = requestContentType + "; charset=UTF-8";
658         ByteArrayInputStream inputStream;
659         try
660         {
661             inputStream = new ByteArrayInputStream(requestBody.getBytes("UTF-8"));
662         }
663         catch (final UnsupportedEncodingException e)
664         {
665             throw new RuntimeException(e);
666         }
667         method.setRequestEntity(new InputStreamRequestEntity(inputStream, contentType));
668     }
669 
670     private void processAuthenticator(final HttpMethod method)
671     {
672         for (final HttpClientAuthenticator authenticator : authenticators)
673         {
674             authenticator.process(httpClient, method);
675         }
676     }
677 
678     /**
679      * Returns correct URL format based on the host configuration.
680      *
681      * Relative URL must be used if a host configuration is set up.
682      *
683      * @param client httpClient
684      * @param url absolute url
685      * @return url with correct format
686      */
687     private String selectUrlFormat(final HttpClient client, String url)
688     {
689         HostConfiguration hostConfiguration = client.getHostConfiguration();
690         if (hostConfiguration != null && hostConfiguration.getHost() != null)
691         {
692             // We must use a relative url if we want the host configuration to be used.
693             return getPathQueryAndFragment(url);
694         }
695 
696         return url;
697     }
698 
699     /**
700      * Given an absolute URL returns path, query and fragment (absolute URL without scheme and authority)
701      *
702      * @param url absolute URL (e.g https://ecosystem.atlassian.net/source/cru/CR-SAL-7#CFR-7821?param1=value1)
703      * @return relative URL (e.g  /source/cru/CR-SAL-7#CFR-7821?param1=value1)
704      */
705     private static String getPathQueryAndFragment(final String url)
706     {
707         try
708         {
709             return new URL(url).getFile();
710         }
711         catch (MalformedURLException e)
712         {
713             throw new IllegalArgumentException("The URL is not valid", e);
714         }
715     }
716 
717     public Map<String, List<String>> getHeaders()
718     {
719         return Collections.unmodifiableMap(headers);
720     }
721 
722     public MethodType getMethodType()
723     {
724         return methodType;
725     }
726 
727     @Override
728     public String toString()
729     {
730         return methodType + " " + url + ", Parameters: " + parameters +
731                 (StringUtils.isBlank(requestBody) ? "" : "\nRequest body:\n" + requestBody);
732     }
733 }