blob: b52c2e77cc73489acb6fd46396f04027c02a280e [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to you 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.
*/
package org.apache.pivot.web;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.Proxy;
import java.net.URL;
import java.net.URLEncoder;
import java.util.concurrent.ExecutorService;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import org.apache.pivot.io.IOTask;
import org.apache.pivot.json.JSONSerializer;
import org.apache.pivot.serialization.SerializationException;
import org.apache.pivot.serialization.Serializer;
import org.apache.pivot.util.ListenerList;
/**
* Abstract base class for web queries. A web query is an asynchronous operation
* that executes one of the following HTTP methods: <ul> <li>GET</li>
* <li>POST</li> <li>PUT</li> <li>DELETE</li> </ul>
*
* @param <V> The type of the value retrieved or sent via the query. For GET
* operations, it is {@link Object}; for POST operations, the type is
* {@link URL}. For PUT and DELETE, it is {@link Void}.
*/
public abstract class Query<V> extends IOTask<V> {
/**
* Supported HTTP methods.
*/
public enum Method {
GET, POST, PUT, DELETE;
}
/**
* Query status codes.
*/
public static class Status {
public static final int OK = 200;
public static final int CREATED = 201;
public static final int NO_CONTENT = 204;
public static final int BAD_REQUEST = 400;
public static final int UNAUTHORIZED = 401;
public static final int FORBIDDEN = 403;
public static final int NOT_FOUND = 404;
public static final int METHOD_NOT_ALLOWED = 405;
public static final int REQUEST_TIMEOUT = 408;
public static final int CONFLICT = 409;
public static final int LENGTH_REQUIRED = 411;
public static final int PRECONDITION_FAILED = 412;
public static final int REQUEST_ENTITY_TOO_LARGE = 413;
public static final int REQUEST_URI_TOO_LONG = 414;
public static final int UNSUPPORTED_MEDIA_TYPE = 415;
public static final int INTERNAL_SERVER_ERROR = 500;
public static final int NOT_IMPLEMENTED = 501;
public static final int SERVICE_UNAVAILABLE = 503;
public static final int HTTP_VERSION_NOT_SUPPORTED = 505;
}
/**
* Query listener list.
*/
private static class QueryListenerList<V> extends ListenerList<QueryListener<V>> implements
QueryListener<V> {
@Override
public synchronized void add(QueryListener<V> listener) {
super.add(listener);
}
@Override
public synchronized void remove(QueryListener<V> listener) {
super.remove(listener);
}
@Override
public synchronized void connected(Query<V> query) {
for (QueryListener<V> listener : this) {
listener.connected(query);
}
}
@Override
public synchronized void requestSent(Query<V> query) {
for (QueryListener<V> listener : this) {
listener.requestSent(query);
}
}
@Override
public synchronized void responseReceived(Query<V> query) {
for (QueryListener<V> listener : this) {
listener.responseReceived(query);
}
}
@Override
public synchronized void failed(Query<V> query) {
for (QueryListener<V> listener : this) {
listener.failed(query);
}
}
}
private URL locationContext = null;
private HostnameVerifier hostnameVerifier = null;
private Proxy proxy = null;
private QueryDictionary parameters = new QueryDictionary(true);
private QueryDictionary requestHeaders = new QueryDictionary(false);
private QueryDictionary responseHeaders = new QueryDictionary(false);
private int status = 0;
private volatile long bytesExpected = -1;
private Serializer<?> serializer = new JSONSerializer();
private QueryListenerList<V> queryListeners = new QueryListenerList<>();
public static final int DEFAULT_PORT = -1;
private static final String HTTP_PROTOCOL = "http";
private static final String HTTPS_PROTOCOL = "https";
private static final String URL_ENCODING = "UTF-8";
static {
try {
// See http://java.sun.com/javase/6/docs/technotes/guides/net/proxies.html
// for more info on this system property.
System.setProperty("java.net.useSystemProxies", "true");
} catch (SecurityException exception) {
// No-op
}
}
/**
* Creates a new web query.
*
* @param hostname Name of the host to contact for this web query.
* @param port Port number on that host.
* @param path The resource path on the host that is the target of this query.
* @param secure A flag to say whether to use {@code http} or {@code https} as the protocol.
* @param executorService The executor to use for running the query (in the background).
* @throws IllegalArgumentException if the {@code URL} cannot be constructed.
*/
public Query(String hostname, int port, String path, boolean secure, ExecutorService executorService) {
super(executorService);
try {
locationContext = new URL(secure ? HTTPS_PROTOCOL : HTTP_PROTOCOL, hostname, port, path);
} catch (MalformedURLException exception) {
throw new IllegalArgumentException("Unable to construct context URL.", exception);
}
}
public abstract Method getMethod();
public String getHostname() {
return locationContext.getHost();
}
public String getPath() {
return locationContext.getFile();
}
public int getPort() {
return locationContext.getPort();
}
public boolean isSecure() {
String protocol = locationContext.getProtocol();
return protocol.equalsIgnoreCase(HTTPS_PROTOCOL);
}
public HostnameVerifier getHostnameVerifier() {
return hostnameVerifier;
}
public void setHostnameVerifier(HostnameVerifier hostnameVerifier) {
this.hostnameVerifier = hostnameVerifier;
}
/**
* Gets the proxy associated with this query.
*
* @return This query's proxy, or <tt>null</tt> if the query is using the
* default JVM proxy settings
*/
public Proxy getProxy() {
return proxy;
}
/**
* Sets the proxy associated with this query.
*
* @param proxy This query's proxy, or <tt>null</tt> to use the default JVM
* proxy settings
*/
public void setProxy(Proxy proxy) {
this.proxy = proxy;
}
public URL getLocation() {
StringBuilder queryStringBuilder = new StringBuilder();
for (String key : parameters) {
for (int index = 0; index < parameters.getLength(key); index++) {
try {
if (queryStringBuilder.length() > 0) {
queryStringBuilder.append("&");
}
queryStringBuilder.append(URLEncoder.encode(key, URL_ENCODING) + "="
+ URLEncoder.encode(parameters.get(key, index), URL_ENCODING));
} catch (UnsupportedEncodingException exception) {
throw new IllegalStateException("Unable to construct query string.", exception);
}
}
}
URL location = null;
try {
String queryString = queryStringBuilder.length() > 0 ? "?"
+ queryStringBuilder.toString() : "";
location = new URL(locationContext.getProtocol(), locationContext.getHost(),
locationContext.getPort(), locationContext.getPath() + queryString);
} catch (MalformedURLException exception) {
throw new IllegalStateException("Unable to construct query URL.", exception);
}
return location;
}
/**
* Returns the web query's parameter dictionary. Parameters are passed via
* the query string of the web query's URL.
* @return The current set of query parameters.
*/
public QueryDictionary getParameters() {
return parameters;
}
/**
* Returns the web query's request header dictionary. Request headers are
* passed via HTTP headers when the query is executed.
* @return The current set of request headers.
*/
public QueryDictionary getRequestHeaders() {
return requestHeaders;
}
/**
* Returns the web query's response header dictionary. Response headers are
* returned via HTTP headers when the query is executed.
* @return The current set of response headers.
*/
public QueryDictionary getResponseHeaders() {
return responseHeaders;
}
/**
* Returns the status of the most recent execution.
*
* @return An HTTP code representing the most recent execution status.
*/
public int getStatus() {
return status;
}
/**
* Returns the serializer used to stream the value passed to or from the web
* query. By default, an instance of {@link JSONSerializer} is used.
* @return The current serializer.
*/
public Serializer<?> getSerializer() {
return serializer;
}
/**
* Sets the serializer used to stream the value passed to or from the web
* query.
*
* @param serializer The serializer (must be non-null).
* @throws IllegalArgumentException if the input is {@code null}.
*/
public void setSerializer(Serializer<?> serializer) {
if (serializer == null) {
throw new IllegalArgumentException("Serializer is null.");
}
this.serializer = serializer;
}
/**
* Gets the number of bytes that have been sent in the body of this query's
* HTTP request. This will only be non-zero for POST and PUT requests, as
* GET and DELETE requests send no content to the server. <p> For POST and
* PUT requests, this number will increment in between the
* {@link QueryListener#connected(Query) connected} and
* {@link QueryListener#requestSent(Query) requestSent} phases of the
* <tt>QueryListener</tt> lifecycle methods. Interested listeners can poll
* for this value during that phase.
* @return Number of bytes sent by POST or PUT requests, or 0 for GET and
* DELETE.
*/
public long getBytesSent() {
return bytesSent;
}
/**
* Gets the number of bytes that have been received from the server in the
* body of the server's HTTP response. This will generally only be non-zero
* for GET requests, as POST, PUT, and DELETE requests generally don't
* solicit response content from the server. <p> This number will increment
* in between the {@link QueryListener#requestSent(Query) requestSent} and
* {@link QueryListener#responseReceived(Query) responseReceived} phases of
* the <tt>QueryListener</tt> lifecycle methods. Interested listeners can
* poll for this value during that phase.
* @return The number of bytes received.
*/
public long getBytesReceived() {
return bytesReceived;
}
/**
* Gets the number of bytes that are expected to be received from the server
* in the body of the server's HTTP response. This value reflects the
* <tt>Content-Length</tt> HTTP response header and is thus merely an
* expectation. The actual total number of bytes that will be received is
* not known for certain until the full response has been received. <p> If
* the server did not specify a <tt>Content-Length</tt> HTTP response
* header, a value of <tt>-1</tt> will be returned to indicate that this
* value is unknown.
* @return The expected number of bytes to received based on the content length.
*/
public long getBytesExpected() {
return bytesExpected;
}
@SuppressWarnings("unchecked")
protected Object execute(final Method method, final Object value) throws QueryException {
Object result = value;
URL location = getLocation();
HttpURLConnection connection = null;
Serializer<Object> serializerLocal = (Serializer<Object>) this.serializer;
bytesSent = 0;
bytesReceived = 0;
bytesExpected = -1;
status = 0;
String message = null;
try {
// Clear any properties from a previous response
responseHeaders.clear();
// Open a connection
if (proxy == null) {
connection = (HttpURLConnection) location.openConnection();
} else {
connection = (HttpURLConnection) location.openConnection(proxy);
}
connection.setRequestMethod(method.toString());
connection.setAllowUserInteraction(false);
connection.setInstanceFollowRedirects(false);
connection.setUseCaches(false);
if (connection instanceof HttpsURLConnection && hostnameVerifier != null) {
HttpsURLConnection httpsConnection = (HttpsURLConnection) connection;
httpsConnection.setHostnameVerifier(hostnameVerifier);
}
// Set the request headers
if (result != null) {
connection.setRequestProperty("Content-Type", serializerLocal.getMIMEType(result));
}
for (String key : requestHeaders) {
for (int i = 0, n = requestHeaders.getLength(key); i < n; i++) {
if (i == 0) {
connection.setRequestProperty(key, requestHeaders.get(key, i));
} else {
connection.addRequestProperty(key, requestHeaders.get(key, i));
}
}
}
// Set the input/output state
connection.setDoInput(true);
connection.setDoOutput(result != null);
// Connect to the server
connection.connect();
queryListeners.connected(this);
// Write the request body
if (result != null) {
try (OutputStream outputStream = connection.getOutputStream()) {
serializerLocal.writeObject(result, new MonitoredOutputStream(outputStream));
}
}
// Notify listeners that the request has been sent
queryListeners.requestSent(this);
// Set the response info
status = connection.getResponseCode();
message = connection.getResponseMessage();
// Record the content length
bytesExpected = connection.getContentLengthLong();
// NOTE Header indexes start at 1, not 0
int i = 1;
for (String key = connection.getHeaderFieldKey(i); key != null; key = connection.getHeaderFieldKey(++i)) {
responseHeaders.add(key, connection.getHeaderField(i));
}
// If the response was anything other than 2xx, throw an exception
int statusPrefix = status / 100;
if (statusPrefix != 2) {
queryListeners.failed(this);
throw new QueryException(status, message);
}
// Read the response body
if (method == Method.GET && status == Query.Status.OK) {
try (InputStream inputStream = connection.getInputStream()) {
result = serializerLocal.readObject(new MonitoredInputStream(inputStream));
}
}
// Notify listeners that the response has been received
queryListeners.responseReceived(this);
} catch (IOException exception) {
queryListeners.failed(this);
throw new QueryException(exception);
} catch (SerializationException exception) {
queryListeners.failed(this);
throw new QueryException(exception);
} catch (RuntimeException exception) {
queryListeners.failed(this);
throw exception;
}
return result;
}
/**
* @return The query listener list.
*/
public ListenerList<QueryListener<V>> getQueryListeners() {
return queryListeners;
}
}