package org.deftserver.web.http.client; import java.io.IOException; import java.nio.channels.SocketChannel; import java.util.concurrent.TimeoutException; import org.deftserver.io.AsynchronousSocket; import org.deftserver.io.IOLoop; import org.deftserver.io.timeout.Timeout; import org.deftserver.util.NopAsyncResult; import org.deftserver.util.UrlUtil; import org.deftserver.web.AsyncCallback; import org.deftserver.web.AsyncResult; import org.deftserver.web.HttpVerb; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * This class implements a simple HTTP 1.1 client on top of Deft's {@code AsynchronousSocket}. * It does not currently implement all applicable parts of the HTTP * specification. * <pre> * E.g the following is not supported. * - POST and PUT * * </pre> * This class has not been tested extensively in production and * should be considered experimental as of the release of * Deft 0.3. * * This http client is inspired by https://github.com/facebook/tornado/blob/master/tornado/simple_httpclient.py * and part of the documentation is simply copy pasted. */ public class AsynchronousHttpClient { private static final Logger logger = LoggerFactory.getLogger(AsynchronousHttpClient.class); private static final long TIMEOUT = 15 * 1000; // 15s private static final AsyncResult<Response> nopAsyncResult = NopAsyncResult.of(Response.class).nopAsyncResult; private AsynchronousSocket socket; private Request request; private long requestStarted; private Response response; private AsyncResult<Response> responseCallback; private Timeout timeout; private final IOLoop ioLoop; private static final String HTTP_VERSION = "HTTP/1.1\r\n"; private static final String USER_AGENT_HEADER = "User-Agent: Deft AsynchronousHttpClient/0.2-SNAPSHOT\r\n"; private static final String NEWLINE = "\r\n"; public AsynchronousHttpClient() { this(IOLoop.INSTANCE); } public AsynchronousHttpClient(IOLoop ioLoop) { this.ioLoop = ioLoop; } /** * Makes an asynchronous HTTP GET request against the specified url and invokes the given * callback when the response is fetched. * * @param url e.g "http://tt.se:80/start/" * @param cb callback that will be executed when the response is received. */ public void fetch(String url, AsyncResult<Response> cb) { request = new Request(url, HttpVerb.GET); doFetch(cb, System.currentTimeMillis()); } public void fetch(Request request, AsyncResult<Response> cb) { this.request = request; doFetch(cb, System.currentTimeMillis()); } private void doFetch(AsyncResult<Response> cb, long requestStarted) { this.requestStarted = requestStarted; try { socket = new AsynchronousSocket(SocketChannel.open().configureBlocking(false)); } catch (IOException e) { logger.error("Error opening SocketChannel: {}" + e.getMessage()); } responseCallback = cb; int port = request.getURL().getPort(); port = port == -1 ? 80 : port; startTimeout(); socket.connect( request.getURL().getHost(), port, new AsyncResult<Boolean>() { public void onFailure(Throwable t) { onConnectFailure(t); } public void onSuccess(Boolean result) { onConnect(); } } ); } /** * Close the underlaying {@code AsynchronousSocket}. */ public void close() { logger.debug("Closing http client connection..."); socket.close(); } private void startTimeout() { logger.debug("start timeout..."); timeout = new Timeout( System.currentTimeMillis() + TIMEOUT, new AsyncCallback() { public void onCallback() { onTimeout(); } } ); ioLoop.addTimeout(timeout); } private void cancelTimeout() { logger.debug("cancel timeout..."); timeout.cancel(); timeout = null; } private void onTimeout() { logger.debug("Pending operation (connect, read or write) timed out..."); AsyncResult<Response> cb = responseCallback; responseCallback = nopAsyncResult; cb.onFailure(new TimeoutException("Connection timed out")); close(); } private void onConnect() { logger.debug("Connected..."); cancelTimeout(); startTimeout(); socket.write( makeRequestLineAndHeaders(), new AsyncCallback() { public void onCallback() { onWriteComplete(); }} ); } private void onConnectFailure(Throwable t) { logger.debug("Connect failed..."); cancelTimeout(); AsyncResult<Response> cb = responseCallback; responseCallback = nopAsyncResult; cb.onFailure(t); close(); } /** * * @return Eg. * GET /path/to/file/index.html HTTP/1.0 * From: a@b.com * User-Agent: HTTPTool/1.0 * */ private String makeRequestLineAndHeaders() { return request.getVerb() + " " + request.getURL().getPath() + " " + HTTP_VERSION + "Host: " + request.getURL().getHost() + "\r\n" + USER_AGENT_HEADER + NEWLINE; } private void onWriteComplete() { logger.debug("onWriteComplete..."); cancelTimeout(); startTimeout(); socket.readUntil( "\r\n\r\n", /* header delimiter */ new NaiveAsyncResult() { public void onSuccess(String headers) { onHeaders(headers); } }); } private void onHeaders(String result) { logger.debug("headers: {}", result); cancelTimeout(); response = new Response(requestStarted); String[] headers = result.split("\r\n"); response.setStatuLine(headers[0]); // first entry contains status line (e.g. HTTP/1.1 200 OK) for (int i = 1; i < headers.length; i++) { String[] header = headers[i].split(": "); response.setHeader(header[0], header[1]); } String contentLength = response.getHeader("Content-Length"); startTimeout(); if (contentLength != null) { socket.readBytes( Integer.parseInt(contentLength), new NaiveAsyncResult() { public void onSuccess(String body) { onBody(body); } } ); } else { // Transfer-Encoding: chunked socket.readUntil( NEWLINE, /* chunk delimiter*/ new NaiveAsyncResult() { public void onSuccess(String octet) { onChunkOctet(octet); } } ); } } private void onBody(String body) { logger.debug("body size: {}", body.length()); cancelTimeout(); response.setBody(body); if ((response.getStatusLine().contains("301") || response.getStatusLine().contains("302")) && request.isFollowingRedirects() && request.getMaxRedirects() > 0) { String newUrl = UrlUtil.urlJoin(request.getURL(), response.getHeader("Location")); request = new Request(newUrl, HttpVerb.valueOf(request.getVerb()), true, request.getMaxRedirects() - 1); logger.debug("Following redirect, new url: {}, redirects left: {}", newUrl, request.getMaxRedirects()); doFetch(responseCallback, requestStarted); } else { close(); invokeResponseCallback(); } } private void onChunk(String chunk) { logger.debug("chunk size: {}", chunk.length()); cancelTimeout(); response.addChunk(chunk.substring(0, chunk.length() - NEWLINE.length())); startTimeout(); socket.readUntil( NEWLINE, /* chunk delimiter*/ new NaiveAsyncResult() { public void onSuccess(String octet) { onChunkOctet(octet); } } ); } private void onChunkOctet(String octet) { int readBytes = Integer.parseInt(octet, 16); logger.debug("chunk octet: {} (decimal: {})", octet, readBytes); cancelTimeout(); startTimeout(); if (readBytes != 0) { socket.readBytes( readBytes + NEWLINE.length(), // chunk delimiter is \r\n new NaiveAsyncResult() { public void onSuccess(String chunk) { onChunk(chunk); } } ); } else { onBody(response.getBody()); } } private void invokeResponseCallback() { AsyncResult<Response> cb = responseCallback; responseCallback = nopAsyncResult; cb.onSuccess(response); } /** * Naive because all it does when an exception is thrown is log the exception. */ private abstract class NaiveAsyncResult implements AsyncResult<String> { @Override public void onFailure(Throwable caught) { logger.debug("onFailure: {}", caught); } } }