Java tutorial
/* * Copyright 2008-2011 Thomas Nichols. http://blog.thomnichols.org * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * You are receiving this code free of charge, which represents many hours of * effort from other individuals and corporations. As a responsible member * of the community, you are encouraged (but not required) to donate any * enhancements or improvements back to the community under a similar open * source license. Thank you. -TMN */ package groovyx.net.http; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Reader; import java.io.StringReader; import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URLConnection; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import oauth.signpost.OAuthConsumer; import oauth.signpost.basic.DefaultOAuthConsumer; import oauth.signpost.basic.HttpURLConnectionRequestAdapter; import oauth.signpost.exception.OAuthException; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.http.Header; import org.apache.http.HeaderIterator; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.ProtocolVersion; import org.apache.http.StatusLine; import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicHeaderIterator; import org.apache.http.message.BasicStatusLine; import org.apache.http.params.HttpParams; import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.codehaus.groovy.runtime.EncodingGroovyMethods; /** * <p>This class provides a simplified API similar to {@link HTTPBuilder}, but * uses {@link java.net.HttpURLConnection} for I/O so that it is compatible * with Google App Engine. Features: * <ul> * <li>Parser and Encoder support</li> * <li>Easy request and response header manipulation</li> * <li>Basic authentication</li> * </ul> * Notably absent are status-code based response handling and the more complex * authentication mechanisms.</p> * * TODO request encoding support (if anyone asks for it) * * @see <a href='http://code.google.com/appengine/docs/java/urlfetch/overview.html'>GAE URLFetch</a> * @author <a href='mailto:tomstrummer+httpbuilder@gmail.com'>Tom Nichols</a> * @since 0.5.0 */ public class HttpURLClient { private Map<String, String> defaultHeaders = new HashMap<String, String>(); private EncoderRegistry encoderRegistry = new EncoderRegistry(); private ParserRegistry parserRegistry = new ParserRegistry(); private Object contentType = ContentType.ANY; private Object requestContentType = null; private URIBuilder defaultURL = null; private boolean followRedirects = true; protected OAuthWrapper oauth; /** Logger instance defined for use by sub-classes */ protected Log log = LogFactory.getLog(getClass()); /** * Perform a request. Parameters are: * <dl> * <dt>url</dt><dd>the entire request URL</dd> * <dt>path</dt><dd>the path portion of the request URL, if a default * URL is set on this instance.</dd> * <dt>query</dt><dd>URL query parameters for this request.</dd> * <dt>timeout</dt><dd>see {@link HttpURLConnection#setReadTimeout(int)}</dd> * <dt>method</dt><dd>This defaults to GET, or POST if a <code>body</code> * parameter is also specified.</dd> * <dt>contentType</dt><dd>Explicitly specify how to parse the response. * If this value is ContentType.ANY, the response <code>Content-Type</code> * header is used to determine how to parse the response.</dd> * <dt>requestContentType</dt><dd>used in a PUT or POST request to * transform the request body and set the proper * <code>Content-Type</code> header. This defaults to the * <code>contentType</code> if unset.</dd> * <dt>auth</dt><dd>Basic authorization; pass the value as a list in the * form [user, pass]</dd> * <dt>headers</dt><dd>additional request headers, as a map</dd> * <dt>body</dt><dd>request content body, for a PUT or POST request. * This will be encoded using the requestContentType</dd> * </dl> * @param args named parameters * @return the parsed response * @throws URISyntaxException * @throws MalformedURLException * @throws IOException */ public HttpResponseDecorator request(Map<String, ?> args) throws URISyntaxException, MalformedURLException, IOException { // copy so we don't modify the original collection when removing items: args = new HashMap<String, Object>(args); Object arg = args.remove("url"); if (arg == null && this.defaultURL == null) throw new IllegalStateException("Either the 'defaultURL' property" + " must be set or a 'url' parameter must be passed to the " + "request method."); URIBuilder url = arg != null ? new URIBuilder(arg.toString()) : defaultURL.clone(); arg = null; arg = args.remove("path"); if (arg != null) url.setPath(arg.toString()); arg = null; arg = args.remove("query"); if (arg != null) { if (!(arg instanceof Map<?, ?>)) throw new IllegalArgumentException("'query' must be a map"); url.setQuery((Map<?, ?>) arg); } HttpURLConnection conn = (HttpURLConnection) url.toURL().openConnection(); conn.setInstanceFollowRedirects(this.followRedirects); arg = null; arg = args.remove("timeout"); if (arg != null) conn.setConnectTimeout(Integer.parseInt(arg.toString())); arg = null; arg = args.remove("method"); if (arg != null) conn.setRequestMethod(arg.toString()); arg = null; arg = args.remove("contentType"); Object contentType = arg != null ? arg : this.contentType; if (contentType instanceof ContentType) conn.addRequestProperty("Accept", ((ContentType) contentType).getAcceptHeader()); arg = null; arg = args.remove("requestContentType"); String requestContentType = arg != null ? arg.toString() : this.requestContentType != null ? this.requestContentType.toString() : contentType != null ? contentType.toString() : null; // must add default headers before setting auth: for (String key : defaultHeaders.keySet()) conn.addRequestProperty(key, defaultHeaders.get(key)); arg = null; arg = args.remove("auth"); if (arg != null) { if (oauth != null) log.warn("You are trying to use both OAuth and basic authentication!"); try { List<?> vals = (List<?>) arg; conn.addRequestProperty("Authorization", getBasicAuthHeader(vals.get(0).toString(), vals.get(1).toString())); } catch (Exception ex) { throw new IllegalArgumentException("Auth argument must be a list in the form [user,pass]"); } } arg = null; arg = args.remove("headers"); if (arg != null) { if (!(arg instanceof Map<?, ?>)) throw new IllegalArgumentException("'headers' must be a map"); Map<?, ?> headers = (Map<?, ?>) arg; for (Object key : headers.keySet()) conn.addRequestProperty(key.toString(), headers.get(key).toString()); } arg = null; arg = args.remove("body"); if (arg != null) { // if there is a request POST or PUT body conn.setDoOutput(true); final HttpEntity body = (HttpEntity) encoderRegistry.getAt(requestContentType).call(arg); // TODO configurable request charset //TODO don't override if there is a 'content-type' in the headers list conn.addRequestProperty("Content-Type", requestContentType); try { // OAuth Sign if necessary. if (oauth != null) conn = oauth.sign(conn, body); // send request data DefaultGroovyMethods.leftShift(conn.getOutputStream(), body.getContent()); } finally { conn.getOutputStream().close(); } } // sign the request if we're using OAuth else if (oauth != null) conn = oauth.sign(conn, null); if (args.size() > 0) { String illegalArgs = ""; for (String k : args.keySet()) illegalArgs += k + ","; throw new IllegalArgumentException("Unknown named parameters: " + illegalArgs); } String method = conn.getRequestMethod(); log.debug(method + " " + url); HttpResponse response = new HttpURLResponseAdapter(conn); if (ContentType.ANY.equals(contentType)) contentType = conn.getContentType(); Object result = this.getparsedResult(method, contentType, response); log.debug(response.getStatusLine()); HttpResponseDecorator decoratedResponse = new HttpResponseDecorator(response, result); if (log.isTraceEnabled()) { for (Header h : decoratedResponse.getHeaders()) log.trace(" << " + h.getName() + " : " + h.getValue()); } if (conn.getResponseCode() > 399) throw new HttpResponseException(decoratedResponse); return decoratedResponse; } private Object getparsedResult(String method, Object contentType, HttpResponse response) throws ResponseParseException { Object parsedData = method.equals("HEAD") || method.equals("OPTIONS") ? null : parserRegistry.getAt(contentType).call(response); try { //If response is streaming, buffer it in a byte array: if (parsedData instanceof InputStream) { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); DefaultGroovyMethods.leftShift(buffer, (InputStream) parsedData); parsedData = new ByteArrayInputStream(buffer.toByteArray()); } else if (parsedData instanceof Reader) { StringWriter buffer = new StringWriter(); DefaultGroovyMethods.leftShift(buffer, (Reader) parsedData); parsedData = new StringReader(buffer.toString()); } else if (parsedData instanceof Closeable) log.warn("Parsed data is streaming, but cannot be buffered: " + parsedData.getClass()); return parsedData; } catch (IOException ex) { throw new ResponseParseException(new HttpResponseDecorator(response, null), ex); } } private String getBasicAuthHeader(String user, String pass) throws UnsupportedEncodingException { return "Basic " + EncodingGroovyMethods.encodeBase64((user + ":" + pass).getBytes("ISO-8859-1")).toString(); } /** * Set basic user and password authorization to be used for every request. * Pass <code>null</code> to un-set authorization for this instance. * @param user * @param pass * @throws UnsupportedEncodingException */ public void setBasicAuth(Object user, Object pass) throws UnsupportedEncodingException { if (user == null) this.defaultHeaders.remove("Authorization"); else this.defaultHeaders.put("Authorization", getBasicAuthHeader(user.toString(), pass.toString())); } /** * Sign all outbound requests with the given OAuth keys and tokens. It * is assumed you have already generated a consumer keypair and retrieved * a proper access token pair from your target service (see * <a href='http://code.google.com/p/oauth-signpost/wiki/TwitterAndSignpost'>Signpost documentation</a> * for more details.) Once this has been done all requests will be signed. * @param consumerKey null if you want to _stop_ signing requests. * @param consumerSecret * @param accessToken * @param accessSecret */ public void setOAuth(Object consumerKey, Object consumerSecret, Object accessToken, Object accessSecret) { if (consumerKey == null) { oauth = null; return; } this.oauth = new OAuthWrapper(consumerKey, consumerSecret, accessToken, accessSecret); } /** * This class basically wraps Signpost classes so they are not loaded * until {@link HttpURLClient#setOAuth(Object, Object, Object, Object)} * is called. This allows Signpost to act as an optional * dependency. If you are not using Signpost, you don't need the JAR * on your classpath. * @since 0.5.1 */ private static class OAuthWrapper { protected OAuthConsumer oauth; OAuthWrapper(Object consumerKey, Object consumerSecret, Object accessToken, Object accessSecret) { oauth = new DefaultOAuthConsumer(consumerKey.toString(), consumerSecret.toString()); oauth.setTokenWithSecret(accessToken.toString(), accessSecret.toString()); } HttpURLConnection sign(HttpURLConnection request, final HttpEntity body) throws IOException { try { // OAuth Sign. // Note that the request body must be repeatable even though it is an input stream. if (body == null) return (HttpURLConnection) oauth.sign(request).unwrap(); else return (HttpURLConnection) oauth.sign(new HttpURLConnectionRequestAdapter(request) { /* @Override */ public InputStream getMessagePayload() throws IOException { return body.getContent(); } }).unwrap(); } catch (final OAuthException ex) { // throw new IOException( "OAuth signing error", ex ); // 1.6 only! throw new IOException("OAuth signing error: " + ex.getMessage()) { private static final long serialVersionUID = -13848840190384656L; /* @Override */ public Throwable getCause() { return ex; } }; } } } /** * Control whether this instance should automatically follow redirect * responses. See {@link HttpURLConnection#setInstanceFollowRedirects(boolean)} * @param follow true if the connection should automatically follow * redirect responses from the server. */ public void setFollowRedirects(boolean follow) { this.followRedirects = follow; } /** * See {@link #setFollowRedirects(boolean)} * @return */ public boolean isFollowRedirects() { return this.followRedirects; } /** * The default URL for this request. This is a {@link URIBuilder} which can * be used to easily manipulate portions of the request URL. * @return */ public Object getUrl() { return this.defaultURL; } /** * Set the default request URL. * @see URIBuilder#convertToURI(Object) * @param url any object whose <code>toString()</code> produces a valid URI. * @throws URISyntaxException */ public void setUrl(Object url) throws URISyntaxException { this.defaultURL = new URIBuilder(URIBuilder.convertToURI(url)); } /** * This class makes a HttpURLConnection look like an HttpResponse for use * by {@link ParserRegistry} and {@link HttpResponseDecorator}. */ private final class HttpURLResponseAdapter implements HttpResponse { HttpURLConnection conn; Header[] headers; HttpURLResponseAdapter(HttpURLConnection conn) { this.conn = conn; } public HttpEntity getEntity() { return new HttpEntity() { public void consumeContent() throws IOException { conn.getInputStream().close(); } public InputStream getContent() throws IOException, IllegalStateException { if (Status.find(conn.getResponseCode()) == Status.FAILURE) return conn.getErrorStream(); return conn.getInputStream(); } public Header getContentEncoding() { return new BasicHeader("Content-Encoding", conn.getContentEncoding()); } public long getContentLength() { return conn.getContentLength(); } public Header getContentType() { return new BasicHeader("Content-Type", conn.getContentType()); } public boolean isChunked() { String enc = conn.getHeaderField("Transfer-Encoding"); return enc != null && enc.contains("chunked"); } public boolean isRepeatable() { return false; } public boolean isStreaming() { return true; } public void writeTo(OutputStream out) throws IOException { DefaultGroovyMethods.leftShift(out, conn.getInputStream()); } }; } public Locale getLocale() { //TODO test me String val = conn.getHeaderField("Locale"); return val != null ? new Locale(val) : Locale.getDefault(); } public StatusLine getStatusLine() { try { return new BasicStatusLine(this.getProtocolVersion(), conn.getResponseCode(), conn.getResponseMessage()); } catch (IOException ex) { throw new RuntimeException("Error reading status line", ex); } } public boolean containsHeader(String key) { return conn.getHeaderField(key) != null; } public Header[] getAllHeaders() { if (this.headers != null) return this.headers; List<Header> headers = new ArrayList<Header>(); // see http://java.sun.com/j2se/1.5.0/docs/api/java/net/HttpURLConnection.html#getHeaderFieldKey(int) int i = conn.getHeaderFieldKey(0) != null ? 0 : 1; String key; while ((key = conn.getHeaderFieldKey(i)) != null) { headers.add(new BasicHeader(key, conn.getHeaderField(i++))); } this.headers = headers.toArray(new Header[headers.size()]); return this.headers; } public Header getFirstHeader(String key) { for (Header h : getAllHeaders()) if (h.getName().equals(key)) return h; return null; } /** * Note that HttpURLConnection does not support multiple headers of * the same name. */ public Header[] getHeaders(String key) { List<Header> headers = new ArrayList<Header>(); for (Header h : getAllHeaders()) if (h.getName().equals(key)) headers.add(h); return headers.toArray(new Header[headers.size()]); } /** * @see URLConnection#getHeaderField(String) */ public Header getLastHeader(String key) { String val = conn.getHeaderField(key); return val != null ? new BasicHeader(key, val) : null; } public HttpParams getParams() { return null; } public ProtocolVersion getProtocolVersion() { /* TODO this could potentially cause problems if the server is using HTTP 1.0 */ return new ProtocolVersion("HTTP", 1, 1); } public HeaderIterator headerIterator() { return new BasicHeaderIterator(this.getAllHeaders(), null); } public HeaderIterator headerIterator(String key) { return new BasicHeaderIterator(this.getHeaders(key), key); } /* Setters are part of the interface, but aren't applicable for this * adapter */ public void setEntity(HttpEntity entity) { } public void setLocale(Locale l) { } public void setReasonPhrase(String phrase) { } public void setStatusCode(int code) { } public void setStatusLine(StatusLine line) { } public void setStatusLine(ProtocolVersion v, int code) { } public void setStatusLine(ProtocolVersion arg0, int arg1, String arg2) { } public void addHeader(Header arg0) { } public void addHeader(String arg0, String arg1) { } public void removeHeader(Header arg0) { } public void removeHeaders(String arg0) { } public void setHeader(Header arg0) { } public void setHeader(String arg0, String arg1) { } public void setHeaders(Header[] arg0) { } public void setParams(HttpParams arg0) { } } /** * Retrieve the default headers that will be sent in each request. Note * that this is a 'live' map that can be directly manipulated to add or * remove the default request headers. * @return */ public Map<String, String> getHeaders() { return defaultHeaders; } /** * Set default headers to be sent with every request. * @param headers */ public void setHeaders(Map<?, ?> headers) { this.defaultHeaders.clear(); for (Object key : headers.keySet()) { Object val = headers.get(key); if (val != null) this.defaultHeaders.put(key.toString(), val.toString()); } } /** * Get the encoder registry used by this instance, which can be used * to directly modify the request serialization behavior. * i.e. <code>client.encoders.'application/xml' = {....}</code>. * @return */ public EncoderRegistry getEncoders() { return encoderRegistry; } public void setEncoders(EncoderRegistry encoderRegistry) { this.encoderRegistry = encoderRegistry; } /** * Retrieve the parser registry used by this instance, which can be used to * directly modify the parsing behavior. * @return */ public ParserRegistry getParsers() { return parserRegistry; } public void setParsers(ParserRegistry parserRegistry) { this.parserRegistry = parserRegistry; } /** * Get the default content-type used for parsing response data. * @return a String or {@link ContentType} object. Defaults to * {@link ContentType#ANY} */ public Object getContentType() { return contentType; } /** * Set the default content-type used to control response parsing and request * serialization behavior. If <code>null</code> is passed, * {@link ContentType#ANY} will be used. If this value is * {@link ContentType#ANY}, the response <code>Content-Type</code> header is * used to parse the response. * @param ct a String or {@link ContentType} value. */ public void setContentType(Object ct) { this.contentType = (ct == null) ? ContentType.ANY : ct; } /** * Get the default content-type used to serialize the request data. * @return */ public Object getRequestContentType() { return requestContentType; } /** * Set the default content-type used to control request body serialization. * If null, the {@link #getContentType() contentType property} is used. * Additionally, if the <code>contentType</code> is {@link ContentType#ANY}, * a <code>requestContentType</code> <i>must</i> be specified when * performing a POST or PUT request that sends request data. * @param requestContentType String or {@link ContentType} value. */ public void setRequestContentType(Object requestContentType) { this.requestContentType = requestContentType; } }