// // 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.workers; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.List; import java.util.Map; import org.apache.commons.lang.StringUtils; import org.bson.types.ObjectId; import org.chililog.server.common.ChiliLogException; import org.chililog.server.data.ListCriteria; import org.chililog.server.data.MongoConnection; import org.chililog.server.data.UserBO; import org.chililog.server.data.UserController; import org.chililog.server.workbench.Strings; 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.HttpResponseStatus; import org.jboss.netty.handler.codec.http.QueryStringDecoder; /** * <p> * Base API class. Contains the interface for <code>HttpRequestHandler</code> to use as well as common methods. * </p> * <p> * All services classes are designed to be single use (re-entry for more than one request is not supported) and single * threaded. * </p> * <p> * The request URL is assumed to follow the CRUD convention as specified in * http://archive.msdn.microsoft.com/cannonicalRESTEntity. * <ul> * <li><code>POST /api/{WorkerName}</code> - Create an object</li> * <li><code>GET /api/{WorkerName}</code> - Read a list of objects</li> * <li><code>GET /api/{WorkerName}/{id} - Read a specific object identified by the {id}</code></li> * <li><code>PUT /api/{WorkerName}/{id}</code> - Update a specific object identified by {id}</li> * <li><code>DELETE /api/{WorkerName}/{id}</code> - Delete a specific object identified by {id}</li> * </ul> * </p> */ public abstract class Worker { private HttpRequest _request = null; private Map<String, List<String>> _uriQueryStringParameters; private String[] _uriPathParameters = null; private ContentIOStyle _requestContentIOStyle = ContentIOStyle.ByteArray; private AuthenticationTokenAO _authenticationToken = null; private UserBO _authenticatedUser = null; public static final int ID_URI_PATH_PARAMETER_INDEX = 0; public static final String RECORDS_PER_PAGE_URI_QUERYSTRING_PARAMETER_NAME = "records_per_page"; public static final String START_PAGE_URI_QUERYSTRING_PARAMETER_NAME = "start_page"; public static final String DO_PAGE_COUNT_URI_QUERYSTRING_PARAMETER_NAME = "do_page_count"; public static final String RECORDS_PER_PAGE_HEADER_NAME = "X-Chililog-Records-Per-Page"; public static final String START_PAGE_HEADER_NAME = "X-Chililog-Start-Page"; public static final String DO_PAGE_COUNT_HEADER_NAME = "X-Chililog-Do-Page-Count"; public static final String AUTHENTICATION_TOKEN_HEADER = "X-Chililog-Authentication"; public static final String AUTHENTICATION_SERVER_VERSION = "X-Chililog-Version"; public static final String AUTHENTICATION_SERVER_BUILD_TIMESTAMP = "X-Chililog-Build-Timestamp"; public static final String PAGE_COUNT_HEADER = "X-Chililog-PageCount"; public static final String JSON_CONTENT_TYPE = "text/json; charset=UTF-8"; public static final String JSON_CHARSET = "UTF-8"; /** * Constructor * * @param request * HTTP request to process */ public Worker(HttpRequest request) { _request = request; } /** * Returns the HTTP request that is being processed */ public HttpRequest getRequest() { return _request; } /** * Determines how the HTTP request content is to be passed into <code>process()</code>. */ public ContentIOStyle getRequestContentIOStyle() { return _requestContentIOStyle; } protected void setRequestContentIOStyle(ContentIOStyle requestContentIOStyle) { _requestContentIOStyle = requestContentIOStyle; } /** * Returns an array of supported HTTP request methods. */ public HttpMethod[] getSupportedMethods() { return new HttpMethod[] { HttpMethod.GET, HttpMethod.PUT, HttpMethod.POST, HttpMethod.DELETE }; } /** * <p> * Returns array of path parameters. * </p> * <p> * The query string of URI <code>/api/WorkName/123456789?hello=world</code> can be accessed as follows: * </p> * * <pre> * assertEquals(this.getUriPathParameters()[0], "123456789"); * </pre> */ public String[] getUriPathParameters() { return _uriPathParameters; } /** * Returns the specified parameter from the uri path * * @param paramterName * Name of paramter to use in error message * @param parameterIndex * Index of the parameter. 0 is the index of the parameter after the worker name in the uri. * @return Value of specified parameter * @throws ChiliLogException */ public String getUriPathParameter(String paramterName, int parameterIndex) throws ChiliLogException { if (_uriPathParameters == null || parameterIndex < 0 || parameterIndex >= _uriPathParameters.length) { throw new ChiliLogException(Strings.URI_PATH_PARAMETER_ERROR, paramterName, _request.getUri()); } return _uriPathParameters[parameterIndex]; } /** * <p> * Returns the Query String parameters * </p> * <p> * The query string of URI <code>/api/WorkName?hello=world</code> can be accessed as follows: * </p> * * <pre> * assertEquals(this.getUriQueryStringParameters().get("hello").get(0), "world"); * </pre> */ public Map<String, List<String>> getUriQueryStringParameters() { return _uriQueryStringParameters; } /** * Returns the specified query string parameter * * @param parameterName * Name of query string parameter * @param isOptional * if True then exception will NOT be thrown if parameter does not exist * @return Query string parameter value * @throws ChiliLogException */ public String getUriQueryStringParameter(String parameterName, boolean isOptional) throws ChiliLogException { String value = null; if (_uriQueryStringParameters.containsKey(parameterName)) { List<String> l = _uriQueryStringParameters.get(parameterName); if (l != null && !l.isEmpty()) { value = l.get(0); } } if (StringUtils.isBlank(value)) { if (isOptional) { return null; } throw new ChiliLogException(Strings.URI_QUERY_STRING_PARAMETER_ERROR, parameterName, _request.getUri()); } return value; } /** * Load base list criteria values, "records per page", "start page" and "do page count", from the query string * parameters * * @param listCritiera * list criteria to load query string values into * @throws ChiliLogException */ protected void loadBaseListCriteriaParameters(ListCriteria listCritiera) throws ChiliLogException { String recordsPerPage = this.getQueryStringOrHeaderValue(RECORDS_PER_PAGE_URI_QUERYSTRING_PARAMETER_NAME, RECORDS_PER_PAGE_HEADER_NAME, true); if (!StringUtils.isBlank(recordsPerPage)) { listCritiera.setRecordsPerPage(Integer.parseInt(recordsPerPage)); } String startPage = this.getQueryStringOrHeaderValue(START_PAGE_URI_QUERYSTRING_PARAMETER_NAME, START_PAGE_HEADER_NAME, true); if (!StringUtils.isBlank(startPage)) { listCritiera.setStartPage(Integer.parseInt(startPage)); } String doPageCount = this.getQueryStringOrHeaderValue(DO_PAGE_COUNT_URI_QUERYSTRING_PARAMETER_NAME, DO_PAGE_COUNT_HEADER_NAME, true); if (!StringUtils.isBlank(doPageCount)) { listCritiera.setDoPageCount(doPageCount.equalsIgnoreCase("true")); } } /** * Looks for a value in the query string parameter or the header * * @param queryStringParameterName * Name of query string parameter * @param headerName * Name in the header * @param isOptional * If not optional, then error thrown if value is blank. * @return String value * @throws ChiliLogException */ protected String getQueryStringOrHeaderValue(String queryStringParameterName, String headerName, boolean isOptional) throws ChiliLogException { String s = this.getUriQueryStringParameter(queryStringParameterName, true); if (StringUtils.isBlank(s)) { s = _request.getHeader(headerName); if (StringUtils.isBlank(s) && !isOptional) { throw new ChiliLogException(Strings.URI_QUERY_STRING_PARAMETER_OR_HEADER_ERROR, queryStringParameterName, headerName, _request.getUri()); } } return StringUtils.isBlank(s) ? null : s; } /** * Returns the authentication token */ protected AuthenticationTokenAO getAuthenticationToken() { return _authenticationToken; } /** * Sets the authentication token * * @param authenticationToken */ protected void setAuthenticationToken(AuthenticationTokenAO authenticationToken) { _authenticationToken = authenticationToken; } /** * Returns the business object representing the authenticated user */ protected UserBO getAuthenticatedUser() { return _authenticatedUser; } protected void setAuthenticatedUser(UserBO authenticatedUser) { _authenticatedUser = authenticatedUser; } /** * Returns an array of repository names to which the authenticated user has workbench access * * @return Array of repository names */ protected String[] getAuthenticatedUserAllowedRepository() { ArrayList<String> l = new ArrayList<String>(); for (String role : _authenticatedUser.getRoles()) { if (role.endsWith(UserBO.REPOSITORY_ADMINISTRATOR_ROLE_SUFFIX) || role.endsWith(UserBO.REPOSITORY_WORKBENCH_ROLE_SUFFIX)) { String repoName = UserBO.extractRepositoryNameFromRole(role); if (!StringUtils.isBlank(repoName) && !l.contains(repoName)) { l.add(repoName); } } } return l.toArray(new String[l.size()]); } /** * Performs initial validation including authentication. * * @return True if successful and False if error. */ public ApiResult validate() { ApiResult result = validateSupportedMethod(); if (!result.isSuccess()) { return result; } result = parseURI(); if (!result.isSuccess()) { return result; } result = validateURI(); if (!result.isSuccess()) { return result; } result = validateAuthenticationToken(); if (!result.isSuccess()) { return result; } result = validateAuthenticatedUserRole(); if (!result.isSuccess()) { return result; } // Return success return new ApiResult(); } /** * <p> * Validates if the request method is supported. Returns a 405 Method Not Allowed response if there is an error. * </p> * <p> * According to the HTTP specs, a 405 Method Not Allowed response MUST include an Allow header containing a list of * valid methods for the requested resource. * </p> * * <pre> * Allow: GET, HEAD, PUT * </pre> * * @return ApiResult */ protected ApiResult validateSupportedMethod() { HttpMethod requestMethod = _request.getMethod(); HttpMethod[] supportedMethods = this.getSupportedMethods(); ApiResult result = new ApiResult(); boolean found = false; for (HttpMethod supportedMethod : supportedMethods) { if (supportedMethod == requestMethod) { found = true; break; } } if (!found) { result.setHttpResponseStatus(HttpResponseStatus.METHOD_NOT_ALLOWED); StringBuilder sb = new StringBuilder(); for (HttpMethod supportedMethod : supportedMethods) { sb.append(supportedMethod.toString()); sb.append(", "); } sb.setLength(sb.length() - 2); // remove last comma result.getHeaders().put(HttpHeaders.Names.ALLOW, sb.toString()); } return result; } /** * <p> * Validates if this request is authenticated. If not, a "401 Unauthorized" response is returned to the caller. * </p> * * @return ApiResult */ protected ApiResult validateAuthenticationToken() { try { _authenticationToken = AuthenticationTokenAO.fromString(_request.getHeader(AUTHENTICATION_TOKEN_HEADER)); // TODO some caching! _authenticatedUser = UserController.getInstance().get(MongoConnection.getInstance().getConnection(), new ObjectId(_authenticationToken.getUserID())); } catch (Exception ex) { return new ApiResult(HttpResponseStatus.UNAUTHORIZED, ex); } return new ApiResult(); } /** * Generic user role validate. Assumes the user can read but not write. * * @return ApiResult */ protected abstract ApiResult validateAuthenticatedUserRole(); /** * <p> * Validates if the URI has all the supplied parts. If not, a "400 Bad Request" response is returned to the caller. * </p> * * @return ApiResult */ protected ApiResult parseURI() { try { // Get query string QueryStringDecoder decoder = new QueryStringDecoder(_request.getUri()); _uriQueryStringParameters = decoder.getParameters(); // Get String[] pathElements = decoder.getPath().split("/"); if (pathElements.length > 3) { // Skip 1st blank element and the api and WorkerName elements. ArrayList<String> l = new ArrayList<String>(); for (int i = 3; i < pathElements.length; i++) { l.add(pathElements[i]); } _uriPathParameters = l.toArray(new String[] {}); } } catch (Exception ex) { return new ApiResult(HttpResponseStatus.BAD_REQUEST, ex); } // Success return new ApiResult(); } /** * <p> * Validates if the URI has all the supplied parts. If not, a 400 Bad Request response is returned to the caller. * </p> * * @return ApiResult */ protected ApiResult validateURI() { try { // PUT and DELETE must have a key HttpMethod requestMethod = _request.getMethod(); if (requestMethod == HttpMethod.PUT || requestMethod == HttpMethod.DELETE) { if (_uriPathParameters == null || StringUtils.isBlank(_uriPathParameters[ID_URI_PATH_PARAMETER_INDEX])) { throw new ChiliLogException(Strings.URI_PATH_PARAMETER_ERROR, "ID", _request.getUri()); } } } catch (Exception ex) { return new ApiResult(HttpResponseStatus.BAD_REQUEST, ex); } // Success return new ApiResult(); } /** * Process the incoming request. * * @param requestContent * If <code>ContentIOStyle</code> is Byte, then <code>byte[]</code> is passed in. If file, then a * <code>File</code> will be passed in. * @return ApiResult to indicate the success/false of processing */ public ApiResult processPost(Object requestContent) throws Exception { throw new UnsupportedOperationException("HTTP POST not supported for this API."); } /** * Process the incoming request. * * @return ApiResult to indicate the success/false of processing */ public ApiResult processDelete() throws Exception { throw new UnsupportedOperationException("HTTP DELETE not supported for this API."); } /** * Process the incoming request. * * @return ApiResult to indicate the success/false of processing */ public ApiResult processGet() throws Exception { throw new UnsupportedOperationException("HTTP GET not supported for this API."); } /** * Override this to implement worker specific processing * * @param requestContent * If <code>ContentIOStyle</code> is <code>Byte</code>, then <code>byte[]</code> is passed in. If file, * then a <code>File</code> will be passed in. * @return ApiResult to indicate the success/false of processing */ public ApiResult processPut(Object requestContent) throws Exception { throw new UnsupportedOperationException("HTTP PUT not supported for this API."); } /** * Converts request bytes into a string using the default UTF-8 character set. * * @param bytes * Bytes to convert * @return String form the bytes. If bytes is null, null is returned. * @throws UnsupportedEncodingException */ protected String bytesToString(byte[] bytes) throws UnsupportedEncodingException { if (bytes == null) { return null; } // TODO parse charset return new String(bytes, "UTF-8"); } /** * Specifies how request and response content is to be handled with respect to reading and writing. */ public static enum ContentIOStyle { /** * Keep content in memory as a byte array */ ByteArray, /** * - Flush content to file. */ File } }