source: josm/trunk/src/org/openstreetmap/josm/tools/HttpClient.java@ 9634

Last change on this file since 9634 was 9529, checked in by simon04, 8 years ago

HttpClient: test progress monitor handling

File size: 23.3 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.tools;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.io.BufferedOutputStream;
7import java.io.BufferedReader;
8import java.io.IOException;
9import java.io.InputStream;
10import java.io.OutputStream;
11import java.net.HttpRetryException;
12import java.net.HttpURLConnection;
13import java.net.URL;
14import java.util.List;
15import java.util.Locale;
16import java.util.Map;
17import java.util.Scanner;
18import java.util.TreeMap;
19import java.util.regex.Matcher;
20import java.util.regex.Pattern;
21import java.util.zip.GZIPInputStream;
22
23import org.openstreetmap.josm.Main;
24import org.openstreetmap.josm.data.Version;
25import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
26import org.openstreetmap.josm.gui.progress.ProgressMonitor;
27import org.openstreetmap.josm.io.Compression;
28import org.openstreetmap.josm.io.ProgressInputStream;
29import org.openstreetmap.josm.io.ProgressOutputStream;
30import org.openstreetmap.josm.io.UTFInputStreamReader;
31import org.openstreetmap.josm.io.auth.DefaultAuthenticator;
32
33/**
34 * Provides a uniform access for a HTTP/HTTPS server. This class should be used in favour of {@link HttpURLConnection}.
35 * @since 9168
36 */
37public final class HttpClient {
38
39 private URL url;
40 private final String requestMethod;
41 private int connectTimeout = Main.pref.getInteger("socket.timeout.connect", 15) * 1000;
42 private int readTimeout = Main.pref.getInteger("socket.timeout.read", 30) * 1000;
43 private byte[] requestBody;
44 private long ifModifiedSince;
45 private final Map<String, String> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
46 private int maxRedirects = Main.pref.getInteger("socket.maxredirects", 5);
47 private boolean useCache;
48 private String reasonForRequest;
49 private transient HttpURLConnection connection; // to allow disconnecting before `response` is set
50 private transient Response response;
51
52 private HttpClient(URL url, String requestMethod) {
53 this.url = url;
54 this.requestMethod = requestMethod;
55 this.headers.put("Accept-Encoding", "gzip");
56 }
57
58 /**
59 * Opens the HTTP connection.
60 * @return HTTP response
61 * @throws IOException if any I/O error occurs
62 */
63 public Response connect() throws IOException {
64 return connect(null);
65 }
66
67 /**
68 * Opens the HTTP connection.
69 * @param progressMonitor progress monitor
70 * @return HTTP response
71 * @throws IOException if any I/O error occurs
72 * @since 9179
73 */
74 public Response connect(ProgressMonitor progressMonitor) throws IOException {
75 if (progressMonitor == null) {
76 progressMonitor = NullProgressMonitor.INSTANCE;
77 }
78 final HttpURLConnection connection = (HttpURLConnection) url.openConnection();
79 this.connection = connection;
80 connection.setRequestMethod(requestMethod);
81 connection.setRequestProperty("User-Agent", Version.getInstance().getFullAgentString());
82 connection.setConnectTimeout(connectTimeout);
83 connection.setReadTimeout(readTimeout);
84 connection.setInstanceFollowRedirects(false); // we do that ourselves
85 if (ifModifiedSince > 0) {
86 connection.setIfModifiedSince(ifModifiedSince);
87 }
88 connection.setUseCaches(useCache);
89 if (!useCache) {
90 connection.setRequestProperty("Cache-Control", "no-cache");
91 }
92 for (Map.Entry<String, String> header : headers.entrySet()) {
93 if (header.getValue() != null) {
94 connection.setRequestProperty(header.getKey(), header.getValue());
95 }
96 }
97
98 progressMonitor.beginTask(tr("Contacting Server..."), 1);
99 progressMonitor.indeterminateSubTask(null);
100
101 if ("PUT".equals(requestMethod) || "POST".equals(requestMethod) || "DELETE".equals(requestMethod)) {
102 Main.info("{0} {1} ({2}) ...", requestMethod, url, Utils.getSizeString(requestBody.length, Locale.getDefault()));
103 connection.setFixedLengthStreamingMode(requestBody.length);
104 connection.setDoOutput(true);
105 final ProgressMonitor subTaskMonitor = progressMonitor.createSubTaskMonitor(1, false);
106 try (OutputStream out = new BufferedOutputStream(
107 new ProgressOutputStream(connection.getOutputStream(), requestBody.length, subTaskMonitor))) {
108 out.write(requestBody);
109 }
110 }
111
112 boolean successfulConnection = false;
113 try {
114 try {
115 connection.connect();
116 final boolean hasReason = reasonForRequest != null && !reasonForRequest.isEmpty();
117 Main.info("{0} {1}{2} -> {3}{4}",
118 requestMethod, url, hasReason ? " (" + reasonForRequest + ")" : "",
119 connection.getResponseCode(),
120 connection.getContentLengthLong() > 0
121 ? " (" + Utils.getSizeString(connection.getContentLengthLong(), Locale.getDefault()) + ")"
122 : ""
123 );
124 if (Main.isDebugEnabled()) {
125 Main.debug("RESPONSE: " + connection.getHeaderFields());
126 }
127 if (DefaultAuthenticator.getInstance().isEnabled() && connection.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED) {
128 DefaultAuthenticator.getInstance().addFailedCredentialHost(url.getHost());
129 }
130 } catch (IOException e) {
131 Main.info("{0} {1} -> !!!", requestMethod, url);
132 Main.warn(e);
133 //noinspection ThrowableResultOfMethodCallIgnored
134 Main.addNetworkError(url, Utils.getRootCause(e));
135 throw e;
136 }
137 if (isRedirect(connection.getResponseCode())) {
138 final String redirectLocation = connection.getHeaderField("Location");
139 if (redirectLocation == null) {
140 /* I18n: argument is HTTP response code */
141 String msg = tr("Unexpected response from HTTP server. Got {0} response without ''Location'' header." +
142 " Can''t redirect. Aborting.", connection.getResponseCode());
143 throw new IOException(msg);
144 } else if (maxRedirects > 0) {
145 url = new URL(url, redirectLocation);
146 maxRedirects--;
147 Main.info(tr("Download redirected to ''{0}''", redirectLocation));
148 return connect();
149 } else if (maxRedirects == 0) {
150 String msg = tr("Too many redirects to the download URL detected. Aborting.");
151 throw new IOException(msg);
152 }
153 }
154 response = new Response(connection, progressMonitor);
155 successfulConnection = true;
156 return response;
157 } finally {
158 if (!successfulConnection) {
159 connection.disconnect();
160 }
161 }
162 }
163
164 /**
165 * Returns the HTTP response which is set only after calling {@link #connect()}.
166 * Calling this method again, returns the identical object (unless another {@link #connect()} is performed).
167 *
168 * @return the HTTP response
169 * @since 9309
170 */
171 public Response getResponse() {
172 return response;
173 }
174
175 /**
176 * A wrapper for the HTTP response.
177 */
178 public static final class Response {
179 private final HttpURLConnection connection;
180 private final ProgressMonitor monitor;
181 private final int responseCode;
182 private final String responseMessage;
183 private boolean uncompress;
184 private boolean uncompressAccordingToContentDisposition;
185
186 private Response(HttpURLConnection connection, ProgressMonitor monitor) throws IOException {
187 CheckParameterUtil.ensureParameterNotNull(connection, "connection");
188 CheckParameterUtil.ensureParameterNotNull(monitor, "monitor");
189 this.connection = connection;
190 this.monitor = monitor;
191 this.responseCode = connection.getResponseCode();
192 this.responseMessage = connection.getResponseMessage();
193 }
194
195 /**
196 * Sets whether {@link #getContent()} should uncompress the input stream if necessary.
197 *
198 * @param uncompress whether the input stream should be uncompressed if necessary
199 * @return {@code this}
200 */
201 public Response uncompress(boolean uncompress) {
202 this.uncompress = uncompress;
203 return this;
204 }
205
206 /**
207 * Sets whether {@link #getContent()} should uncompress the input stream according to {@code Content-Disposition}
208 * HTTP header.
209 * @param uncompressAccordingToContentDisposition whether the input stream should be uncompressed according to
210 * {@code Content-Disposition}
211 * @return {@code this}
212 * @since 9172
213 */
214 public Response uncompressAccordingToContentDisposition(boolean uncompressAccordingToContentDisposition) {
215 this.uncompressAccordingToContentDisposition = uncompressAccordingToContentDisposition;
216 return this;
217 }
218
219 /**
220 * Returns the URL.
221 * @return the URL
222 * @see HttpURLConnection#getURL()
223 * @since 9172
224 */
225 public URL getURL() {
226 return connection.getURL();
227 }
228
229 /**
230 * Returns the request method.
231 * @return the HTTP request method
232 * @see HttpURLConnection#getRequestMethod()
233 * @since 9172
234 */
235 public String getRequestMethod() {
236 return connection.getRequestMethod();
237 }
238
239 /**
240 * Returns an input stream that reads from this HTTP connection, or,
241 * error stream if the connection failed but the server sent useful data.
242 * <p>
243 * Note: the return value can be null, if both the input and the error stream are null.
244 * Seems to be the case if the OSM server replies a 401 Unauthorized, see #3887
245 * @return input or error stream
246 * @throws IOException if any I/O error occurs
247 *
248 * @see HttpURLConnection#getInputStream()
249 * @see HttpURLConnection#getErrorStream()
250 */
251 @SuppressWarnings("resource")
252 public InputStream getContent() throws IOException {
253 InputStream in;
254 try {
255 in = connection.getInputStream();
256 } catch (IOException ioe) {
257 in = connection.getErrorStream();
258 }
259 monitor.subTask(tr("Fetching content..."));
260 if (in != null) {
261 in = new ProgressInputStream(in, getContentLength(), monitor.createSubTaskMonitor(1, false));
262 in = "gzip".equalsIgnoreCase(getContentEncoding()) ? new GZIPInputStream(in) : in;
263 Compression compression = Compression.NONE;
264 if (uncompress) {
265 final String contentType = getContentType();
266 Main.debug("Uncompressing input stream according to Content-Type header: {0}", contentType);
267 compression = Compression.forContentType(contentType);
268 }
269 if (uncompressAccordingToContentDisposition && Compression.NONE.equals(compression)) {
270 final String contentDisposition = getHeaderField("Content-Disposition");
271 final Matcher matcher = Pattern.compile("filename=\"([^\"]+)\"").matcher(contentDisposition);
272 if (matcher.find()) {
273 Main.debug("Uncompressing input stream according to Content-Disposition header: {0}", contentDisposition);
274 compression = Compression.byExtension(matcher.group(1));
275 }
276 }
277 in = compression.getUncompressedInputStream(in);
278 }
279 return in;
280 }
281
282 /**
283 * Returns {@link #getContent()} wrapped in a buffered reader.
284 *
285 * Detects Unicode charset in use utilizing {@link UTFInputStreamReader}.
286 * @return buffered reader
287 * @throws IOException if any I/O error occurs
288 */
289 public BufferedReader getContentReader() throws IOException {
290 return new BufferedReader(
291 UTFInputStreamReader.create(getContent())
292 );
293 }
294
295 /**
296 * Fetches the HTTP response as String.
297 * @return the response
298 * @throws IOException if any I/O error occurs
299 */
300 @SuppressWarnings("resource")
301 public String fetchContent() throws IOException {
302 try (Scanner scanner = new Scanner(getContentReader()).useDelimiter("\\A")) {
303 return scanner.hasNext() ? scanner.next() : "";
304 }
305 }
306
307 /**
308 * Gets the response code from this HTTP connection.
309 * @return HTTP response code
310 *
311 * @see HttpURLConnection#getResponseCode()
312 */
313 public int getResponseCode() {
314 return responseCode;
315 }
316
317 /**
318 * Gets the response message from this HTTP connection.
319 * @return HTTP response message
320 *
321 * @see HttpURLConnection#getResponseMessage()
322 * @since 9172
323 */
324 public String getResponseMessage() {
325 return responseMessage;
326 }
327
328 /**
329 * Returns the {@code Content-Encoding} header.
330 * @return {@code Content-Encoding} HTTP header
331 * @see HttpURLConnection#getContentEncoding()
332 */
333 public String getContentEncoding() {
334 return connection.getContentEncoding();
335 }
336
337 /**
338 * Returns the {@code Content-Type} header.
339 * @return {@code Content-Type} HTTP header
340 */
341 public String getContentType() {
342 return connection.getHeaderField("Content-Type");
343 }
344
345 /**
346 * Returns the {@code Expire} header.
347 * @return {@code Expire} HTTP header
348 * @see HttpURLConnection#getExpiration()
349 * @since 9232
350 */
351 public long getExpiration() {
352 return connection.getExpiration();
353 }
354
355 /**
356 * Returns the {@code Last-Modified} header.
357 * @return {@code Last-Modified} HTTP header
358 * @see HttpURLConnection#getLastModified()
359 * @since 9232
360 */
361 public long getLastModified() {
362 return connection.getLastModified();
363 }
364
365 /**
366 * Returns the {@code Content-Length} header.
367 * @return {@code Content-Length} HTTP header
368 * @see HttpURLConnection#getContentLengthLong()
369 */
370 public long getContentLength() {
371 return connection.getContentLengthLong();
372 }
373
374 /**
375 * Returns the value of the named header field.
376 * @param name the name of a header field
377 * @return the value of the named header field, or {@code null} if there is no such field in the header
378 * @see HttpURLConnection#getHeaderField(String)
379 * @since 9172
380 */
381 public String getHeaderField(String name) {
382 return connection.getHeaderField(name);
383 }
384
385 /**
386 * Returns an unmodifiable Map mapping header keys to a List of header values.
387 * @return unmodifiable Map mapping header keys to a List of header values
388 * @see HttpURLConnection#getHeaderFields()
389 * @since 9232
390 */
391 public Map<String, List<String>> getHeaderFields() {
392 return connection.getHeaderFields();
393 }
394
395 /**
396 * @see HttpURLConnection#disconnect()
397 */
398 public void disconnect() {
399 HttpClient.disconnect(connection);
400 }
401 }
402
403 /**
404 * Creates a new instance for the given URL and a {@code GET} request
405 *
406 * @param url the URL
407 * @return a new instance
408 */
409 public static HttpClient create(URL url) {
410 return create(url, "GET");
411 }
412
413 /**
414 * Creates a new instance for the given URL and a {@code GET} request
415 *
416 * @param url the URL
417 * @param requestMethod the HTTP request method to perform when calling
418 * @return a new instance
419 */
420 public static HttpClient create(URL url, String requestMethod) {
421 return new HttpClient(url, requestMethod);
422 }
423
424 /**
425 * Returns the URL set for this connection.
426 * @return the URL
427 * @see #create(URL)
428 * @see #create(URL, String)
429 * @since 9172
430 */
431 public URL getURL() {
432 return url;
433 }
434
435 /**
436 * Returns the request method set for this connection.
437 * @return the HTTP request method
438 * @see #create(URL, String)
439 * @since 9172
440 */
441 public String getRequestMethod() {
442 return requestMethod;
443 }
444
445 /**
446 * Returns the set value for the given {@code header}.
447 * @param header HTTP header name
448 * @return HTTP header value
449 * @since 9172
450 */
451 public String getRequestHeader(String header) {
452 return headers.get(header);
453 }
454
455 /**
456 * Sets whether not to set header {@code Cache-Control=no-cache}
457 *
458 * @param useCache whether not to set header {@code Cache-Control=no-cache}
459 * @return {@code this}
460 * @see HttpURLConnection#setUseCaches(boolean)
461 */
462 public HttpClient useCache(boolean useCache) {
463 this.useCache = useCache;
464 return this;
465 }
466
467 /**
468 * Sets whether not to set header {@code Connection=close}
469 * <p>
470 * This might fix #7640, see
471 * <a href='https://web.archive.org/web/20140118201501/http://www.tikalk.com/java/forums/httpurlconnection-disable-keep-alive'>here</a>.
472 *
473 * @param keepAlive whether not to set header {@code Connection=close}
474 * @return {@code this}
475 */
476 public HttpClient keepAlive(boolean keepAlive) {
477 return setHeader("Connection", keepAlive ? null : "close");
478 }
479
480 /**
481 * Sets a specified timeout value, in milliseconds, to be used when opening a communications link to the resource referenced
482 * by this URLConnection. If the timeout expires before the connection can be established, a
483 * {@link java.net.SocketTimeoutException} is raised. A timeout of zero is interpreted as an infinite timeout.
484 * @param connectTimeout an {@code int} that specifies the connect timeout value in milliseconds
485 * @return {@code this}
486 * @see HttpURLConnection#setConnectTimeout(int)
487 */
488 public HttpClient setConnectTimeout(int connectTimeout) {
489 this.connectTimeout = connectTimeout;
490 return this;
491 }
492
493 /**
494 * Sets the read timeout to a specified timeout, in milliseconds. A non-zero value specifies the timeout when reading from
495 * input stream when a connection is established to a resource. If the timeout expires before there is data available for
496 * read, a {@link java.net.SocketTimeoutException} is raised. A timeout of zero is interpreted as an infinite timeout.
497 * @param readTimeout an {@code int} that specifies the read timeout value in milliseconds
498 * @return {@code this}
499 * @see HttpURLConnection#setReadTimeout(int)
500 */
501 public HttpClient setReadTimeout(int readTimeout) {
502 this.readTimeout = readTimeout;
503 return this;
504 }
505
506 /**
507 * This method is used to enable streaming of a HTTP request body without internal buffering,
508 * when the content length is known in advance.
509 * <p>
510 * An exception will be thrown if the application attempts to write more data than the indicated content-length,
511 * or if the application closes the OutputStream before writing the indicated amount.
512 * <p>
513 * When output streaming is enabled, authentication and redirection cannot be handled automatically.
514 * A {@linkplain HttpRetryException} will be thrown when reading the response if authentication or redirection
515 * are required. This exception can be queried for the details of the error.
516 *
517 * @param contentLength The number of bytes which will be written to the OutputStream
518 * @return {@code this}
519 * @see HttpURLConnection#setFixedLengthStreamingMode(long)
520 * @since 9178
521 * @deprecated Submitting data via POST, PUT, DELETE automatically sets this property on the connection
522 */
523 @Deprecated
524 public HttpClient setFixedLengthStreamingMode(long contentLength) {
525 return this;
526 }
527
528 /**
529 * Sets the {@code Accept} header.
530 * @param accept header value
531 *
532 * @return {@code this}
533 */
534 public HttpClient setAccept(String accept) {
535 return setHeader("Accept", accept);
536 }
537
538 /**
539 * Sets the request body for {@code PUT}/{@code POST} requests.
540 * @param requestBody request body
541 *
542 * @return {@code this}
543 */
544 public HttpClient setRequestBody(byte[] requestBody) {
545 this.requestBody = requestBody;
546 return this;
547 }
548
549 /**
550 * Sets the {@code If-Modified-Since} header.
551 * @param ifModifiedSince header value
552 *
553 * @return {@code this}
554 */
555 public HttpClient setIfModifiedSince(long ifModifiedSince) {
556 this.ifModifiedSince = ifModifiedSince;
557 return this;
558 }
559
560 /**
561 * Sets the maximum number of redirections to follow.
562 *
563 * Set {@code maxRedirects} to {@code -1} in order to ignore redirects, i.e.,
564 * to not throw an {@link IOException} in {@link #connect()}.
565 * @param maxRedirects header value
566 *
567 * @return {@code this}
568 */
569 public HttpClient setMaxRedirects(int maxRedirects) {
570 this.maxRedirects = maxRedirects;
571 return this;
572 }
573
574 /**
575 * Sets an arbitrary HTTP header.
576 * @param key header name
577 * @param value header value
578 *
579 * @return {@code this}
580 */
581 public HttpClient setHeader(String key, String value) {
582 this.headers.put(key, value);
583 return this;
584 }
585
586 /**
587 * Sets arbitrary HTTP headers.
588 * @param headers HTTP headers
589 *
590 * @return {@code this}
591 */
592 public HttpClient setHeaders(Map<String, String> headers) {
593 this.headers.putAll(headers);
594 return this;
595 }
596
597 /**
598 * Sets a reason to show on console. Can be {@code null} if no reason is given.
599 * @param reasonForRequest Reason to show
600 * @return {@code this}
601 * @since 9172
602 */
603 public HttpClient setReasonForRequest(String reasonForRequest) {
604 this.reasonForRequest = reasonForRequest;
605 return this;
606 }
607
608 private static boolean isRedirect(final int statusCode) {
609 switch (statusCode) {
610 case HttpURLConnection.HTTP_MOVED_PERM: // 301
611 case HttpURLConnection.HTTP_MOVED_TEMP: // 302
612 case HttpURLConnection.HTTP_SEE_OTHER: // 303
613 case 307: // TEMPORARY_REDIRECT:
614 case 308: // PERMANENT_REDIRECT:
615 return true;
616 default:
617 return false;
618 }
619 }
620
621 /**
622 * @see HttpURLConnection#disconnect()
623 * @since 9309
624 */
625 public void disconnect() {
626 HttpClient.disconnect(connection);
627 }
628
629 private static void disconnect(final HttpURLConnection connection) {
630 // Fix upload aborts - see #263
631 connection.setConnectTimeout(100);
632 connection.setReadTimeout(100);
633 try {
634 Thread.sleep(100);
635 } catch (InterruptedException ex) {
636 Main.warn("InterruptedException in " + HttpClient.class + " during cancel");
637 }
638 connection.disconnect();
639 }
640}
Note: See TracBrowser for help on using the repository browser.