// // Copyright 2010 Cinch Logic Pty Ltd. // // http://www.chililog.com // // 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. // package org.chililog.server.workbench; import static org.jboss.netty.handler.codec.http.HttpHeaders.*; import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*; import static org.jboss.netty.handler.codec.http.HttpResponseStatus.*; import static org.jboss.netty.handler.codec.http.HttpVersion.*; import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.Map; import java.util.Map.Entry; import java.util.TimeZone; import org.apache.commons.lang.ClassUtils; import org.apache.commons.lang.NotImplementedException; import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.WordUtils; import org.apache.commons.lang.reflect.ConstructorUtils; import org.chililog.server.common.ChiliLogException; import org.chililog.server.common.JsonTranslator; import org.chililog.server.common.Log4JLogger; import org.chililog.server.workbench.workers.ApiResult; import org.chililog.server.workbench.workers.AuthenticationWorker; import org.chililog.server.workbench.workers.ErrorAO; import org.chililog.server.workbench.workers.Worker; import org.chililog.server.workbench.workers.Worker.ContentIOStyle; import org.jboss.netty.buffer.ChannelBuffer; import org.jboss.netty.buffer.ChannelBufferOutputStream; import org.jboss.netty.buffer.ChannelBuffers; import org.jboss.netty.channel.ChannelFuture; import org.jboss.netty.channel.ChannelFutureListener; import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.channel.MessageEvent; import org.jboss.netty.handler.codec.http.DefaultHttpResponse; import org.jboss.netty.handler.codec.http.HttpChunk; import org.jboss.netty.handler.codec.http.HttpHeaders; import org.jboss.netty.handler.codec.http.HttpMethod; import org.jboss.netty.handler.codec.http.HttpRequest; import org.jboss.netty.handler.codec.http.HttpResponse; import org.jboss.netty.handler.codec.http.HttpResponseStatus; /** * <p> * Routes the request to an API worker for processing * </p> * <p> * The expected format of the URI is <code>/api/{WorkerName}</code> where <code>{WorkerName}</code> is the name of the * API worker class to invoke. * </p> * <p> * For example, <code>/api/Authentication</code> will invoke the {@link AuthenticationWorker} worker. * </p> * <p> * If processing is successful, a response status of <code>200 OK</code> is returned. * </p> * <p> * Common errors include: * <ul> * <li><code>404 Not Found</code> - if the requested authentication work is not found</li> * <li><code>405 Method Not Allowed</code> - if the requested HTTP method is not supported</li> * <li><code>500 Internal Server Error</code> - if there is an unexpected exception caught during processing</li> * </p> * <p> * The following is an example of the error description in JSON format. * </p> * * <pre> * { * "Message": "Cannot find API class 'com.chililog.server.ui.api.Notfound' for URI: '/api/notfound.'", * "StackTrace": "com.chililog.server.common.ChiliLogException: Cannot find ..." * } * </pre> */ public class ApiRequestHandler extends WorkbenchRequestHandler { private static Log4JLogger _logger = Log4JLogger.getLogger(ApiRequestHandler.class); /** * Http request */ private HttpRequest _request; /** * API work to call to process request */ private Worker _apiWorker; /** * Flag to indicate if we ware processing incoming HTTP chunks */ private boolean _readingChunks = false; /** * File used for storing chunked input */ private File _requestContentFile = null; /** * Buffer that stores chunked response content * */ private OutputStream _requestContentStream = null; /** * Process the message */ @Override public void processMessage(ChannelHandlerContext ctx, MessageEvent e) throws Exception { try { ApiResult result; // Collect HTTP information - whether chunk or not if (!_readingChunks) { // Initialize _request = (HttpRequest) e.getMessage(); result = instanceApiWorker(); if (!result.isSuccess()) { writeResponse(ctx, e, result); return; } // Send 100 continue to tell browser that there is no validation errors, send content now. if (is100ContinueExpected(_request)) { send100Continue(e); } // Get request content if (_request.isChunked()) { // Read chunks _readingChunks = true; // Setup buffer to store chunk if (_apiWorker.getRequestContentIOStyle() == ContentIOStyle.ByteArray) { // Store in memory _requestContentStream = new ByteArrayOutputStream(); } else if (_apiWorker.getRequestContentIOStyle() == ContentIOStyle.File) { // Store as file on the file system File _requestContentFile = File.createTempFile("ApiService_", ".dat"); _requestContentStream = new BufferedOutputStream(new FileOutputStream(_requestContentFile)); } else { throw new UnsupportedOperationException("ContentIOStyle " + _apiWorker.getRequestContentIOStyle().toString()); } return; } else { // No chunks. Process it. writeResponse(ctx, e, invokeApiWorker(false)); return; } } else { HttpChunk chunk = (HttpChunk) e.getMessage(); if (chunk.isLast()) { // No more chunks. Process it. _readingChunks = false; writeResponse(ctx, e, invokeApiWorker(true)); return; } else { _requestContentStream.write(chunk.getContent().array()); return; } } // Don't code here. Unreachable code } catch (Exception ex) { writeResponse(ctx, e, ex); } finally { cleanup(); } } /** * <p> * Instance our API worker class using the name passed in on the URI. * </p> * <p> * If <code>/api/Authentication</code> is passed in, the class * <code>com.chililog.server.intefaces.management.workers.AuthenticationWorker</code> will be instanced. * </p> */ private ApiResult instanceApiWorker() throws Exception { // TODO - Invoke in another thread because we are mostly reading and writing to mongodb String className = null; try { String uri = _request.getUri(); String[] segments = uri.split("/"); String apiName = segments[2]; // Get rid of query string int qs = apiName.indexOf("?"); if (qs > 0) { apiName = apiName.substring(0, qs); } // Merge _ to camel case apiName = WordUtils.capitalizeFully(apiName, new char[] { '_' }); apiName = apiName.replace("_", ""); className = "org.chililog.server.workbench.workers." + apiName + "Worker"; _logger.debug("Instancing ApiWorker: %s", className); Class<?> apiClass = ClassUtils.getClass(className); _apiWorker = (Worker) ConstructorUtils.invokeConstructor(apiClass, _request); return _apiWorker.validate(); } catch (ClassNotFoundException ex) { return new ApiResult(HttpResponseStatus.NOT_FOUND, new ChiliLogException(ex, Strings.API_NOT_FOUND_ERROR, className, _request.getUri())); } } /** * Invoke an API worker object to process the request * * @param isChunked * Flag indicating if this HTTP request is chunked or not * @return {@link ApiResult} indicating the success or failure of the operation * @throws IOException */ private ApiResult invokeApiWorker(boolean isChunked) throws Exception { Object requestContent = null; ContentIOStyle requestContentIOStyle = _apiWorker.getRequestContentIOStyle(); if (isChunked) { // Chunked so request data is stored in streams if (requestContentIOStyle == ContentIOStyle.ByteArray) { // byte[] requestContent = ((ByteArrayOutputStream) _requestContentStream).toByteArray(); } else if (requestContentIOStyle == ContentIOStyle.File) { // File _requestContentStream.close(); requestContent = _requestContentFile; } else { throw new UnsupportedOperationException("ContentIOStyle " + requestContentIOStyle.toString()); } } else { // Not chunked so our request data is stored in the request content ChannelBuffer content = _request.getContent(); if (content.readable()) { if (requestContentIOStyle == ContentIOStyle.ByteArray) { // byte[] requestContent = content.array(); } else if (requestContentIOStyle == ContentIOStyle.File) { // File _requestContentFile = File.createTempFile("ApiService_", ".dat"); _requestContentStream = new FileOutputStream(_requestContentFile); _requestContentStream.write(content.array()); _requestContentStream.close(); requestContent = _requestContentFile; } else { throw new UnsupportedOperationException("ContentIOStyle " + requestContentIOStyle.toString()); } } } // If debugging, we want to output our request if (_logger.isDebugEnabled()) { logHttpRequest(requestContent); } // Dispatch HttpMethod requestMethod = _request.getMethod(); if (requestMethod == HttpMethod.GET) { return _apiWorker.processGet(); } else if (requestMethod == HttpMethod.DELETE) { return _apiWorker.processDelete(); } else if (requestMethod == HttpMethod.POST) { return _apiWorker.processPost(requestContent); } else if (requestMethod == HttpMethod.PUT) { return _apiWorker.processPut(requestContent); } else { throw new UnsupportedOperationException("HTTP method " + requestMethod.toString() + " not supproted."); } } /** * Write the HTTP response * * @param e * Message event * @param result * {@link ApiResult} API processing result */ private void writeResponse(ChannelHandlerContext ctx, MessageEvent e, ApiResult result) { // Log it _logger.info("%s %s REMOTE_IP=%s STATUS=%s", _request.getMethod(), _request.getUri(), e.getRemoteAddress() .toString(), result.getResponseStatus()); // Decide whether to close the connection or not. boolean keepAlive = isKeepAlive(_request); // Build the response object. HttpResponse response = new DefaultHttpResponse(HTTP_1_1, result.getResponseStatus()); // Headers setDateHeader(response); for (Entry<String, String> header : result.getHeaders().entrySet()) { response.setHeader(header.getKey(), header.getValue()); } // Content if (result.getResponseContent() != null) { response.setHeader(CONTENT_TYPE, result.getResponseContentType()); if (result.getResponseContentIOStyle() == ContentIOStyle.ByteArray) { byte[] content = (byte[]) result.getResponseContent(); response.setContent(ChannelBuffers.copiedBuffer(content)); } else { throw new NotImplementedException("ContentIOStyle " + result.getResponseContentIOStyle().toString()); } } // If debugging, we want to output our response if (_logger.isDebugEnabled()) { logHttpResponse(response, result.getResponseContent()); } // Add 'Content-Length' header only for a keep-alive connection. if (keepAlive) { response.setHeader(CONTENT_LENGTH, response.getContent().readableBytes()); } // Write the response. ChannelFuture future = e.getChannel().write(response); // Close the non-keep-alive connection after the write operation is done. if (!keepAlive) { future.addListener(ChannelFutureListener.CLOSE); } } /** * Write response for when there is an exception * * @param e * Message event * @param ex * Exception that was thrown */ private void writeResponse(ChannelHandlerContext ctx, MessageEvent e, Exception ex) throws Exception { // Decide whether to close the connection or not. boolean keepAlive = isKeepAlive(_request); // Build the response object. HttpResponse response = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.INTERNAL_SERVER_ERROR); setDateHeader(response); response.setHeader(CONTENT_TYPE, Worker.JSON_CONTENT_TYPE); ErrorAO errorAO = new ErrorAO(ex); ChannelBuffer buffer = ChannelBuffers.dynamicBuffer(4096); ChannelBufferOutputStream os = new ChannelBufferOutputStream(buffer); PrintStream ps = new PrintStream(os, true, Worker.JSON_CHARSET); JsonTranslator.getInstance().toJson(errorAO, ps); ps.close(); os.close(); response.setContent(buffer); // If debugging, we want to output our response if (_logger.isDebugEnabled()) { logHttpResponse(response, response.getContent().array()); } // Add 'Content-Length' header only for a keep-alive connection. if (keepAlive) { response.setHeader(CONTENT_LENGTH, response.getContent().readableBytes()); } // Write the response. ChannelFuture future = e.getChannel().write(response); // Close the non-keep-alive connection after the write operation is done. if (!keepAlive) { future.addListener(ChannelFutureListener.CLOSE); } } /** * Cleanup temp files, etc. */ private void cleanup() { try { if (_requestContentFile != null && _requestContentFile.exists()) { _requestContentFile.delete(); } } catch (Exception ex) { _logger.error(ex, "Error cleaning up."); } } /** * 100 Continue tells the browser to start sending the content * * @param e */ private void send100Continue(MessageEvent e) { HttpResponse response = new DefaultHttpResponse(HTTP_1_1, CONTINUE); e.getChannel().write(response); } /** * Sets the Date header for the HTTP response * * @param response * HTTP response * @param file * file to extract content type */ private void setDateHeader(HttpResponse response) { SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT); dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE)); Calendar time = new GregorianCalendar(); response.setHeader(HttpHeaders.Names.DATE, dateFormatter.format(time.getTime())); } /** * Write a log entry about the request * * @param requestContent * HTTP request body */ private void logHttpRequest(Object requestContent) { try { StringBuilder sb = new StringBuilder(); sb.append(String.format("HTTP Request %s '%s'", _request.getMethod().toString(), _request.getUri())); for (Map.Entry<String, String> h : _request.getHeaders()) { sb.append("\r\nHEADER: " + h.getKey() + " = " + h.getValue()); } if (requestContent != null) { if (requestContent instanceof byte[]) { sb.append("\r\nCONTENT: " + new String((byte[]) requestContent, "UTF-8")); } else if (requestContent instanceof File) { sb.append("\r\nCONTENT: stored in file"); } } _logger.debug(sb.toString()); } catch (Throwable ex) { // Ignore ex.toString(); } } /** * Log the HTTP response * * @param response * HTTP response * @param responseContent * HTTP response content */ private void logHttpResponse(HttpResponse response, Object responseContent) { try { StringBuilder sb = new StringBuilder(); sb.append(String.format("HTTP Response to %s '%s'. %s", _request.getMethod().toString(), _request.getUri(), response.getStatus().toString())); String contentType = StringUtils.EMPTY; for (Map.Entry<String, String> h : response.getHeaders()) { sb.append("\r\nHEADER: " + h.getKey() + " = " + h.getValue()); if (h.getKey().equals(HttpHeaders.Names.CONTENT_TYPE)) { contentType = h.getValue(); } } if (responseContent != null) { if (responseContent instanceof byte[] && contentType.contains("UTF-8")) { sb.append("\r\nCONTENT: " + new String((byte[]) responseContent, "UTF-8")); } else if (responseContent instanceof File) { sb.append("\r\nCONTENT: stored in file"); } } _logger.debug(sb.toString()); } catch (Throwable ex) { // Ignore ex.toString(); } } }