/* * Copyright 2013- Yan Bonnel * * 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 fr.ybonnel.simpleweb4j.handlers; import fr.ybonnel.simpleweb4j.exception.FatalSimpleWeb4jException; import fr.ybonnel.simpleweb4j.exception.HttpErrorException; import fr.ybonnel.simpleweb4j.handlers.eventsource.EventSourceTask; import fr.ybonnel.simpleweb4j.handlers.eventsource.ReactiveEventSourceTask; import fr.ybonnel.simpleweb4j.handlers.eventsource.ReactiveStream; import fr.ybonnel.simpleweb4j.handlers.eventsource.Stream; import fr.ybonnel.simpleweb4j.handlers.filter.AbstractFilter; import fr.ybonnel.simpleweb4j.model.SimpleEntityManager; import org.eclipse.jetty.continuation.Continuation; import org.eclipse.jetty.continuation.ContinuationSupport; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.handler.AbstractHandler; import org.eclipse.jetty.util.MultiPartInputStreamParser; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.zip.GZIPOutputStream; /** * Class use to handler all json services (declared by Route or RestResource). */ public class SimpleWeb4jHandler extends AbstractHandler { /** * Size of thread pool for EventSource. */ private static final int EVENT_SOURCE_POOL_SIZE = 10; /** * Map of routes by HttpMethod. */ private Map<HttpMethod, List<Route>> routes = new HashMap<>(); /** * Map of routes for jsonp with callbacks. */ private Map<Route, String> jsonpRoutes = new HashMap<>(); /** * List of filters. */ private List<AbstractFilter> filters = new ArrayList<>(); /** * Thread pool for event-source. */ private ScheduledExecutorService executorServiceForEventSource = Executors.newScheduledThreadPool(EVENT_SOURCE_POOL_SIZE); /** * Add a route. * * @param httpMethod http method of the route. * @param route route to add. */ public void addRoute(HttpMethod httpMethod, Route route) { if (!routes.containsKey(httpMethod)) { routes.put(httpMethod, new ArrayList<>()); } routes.get(httpMethod).add(route); } /** * Add a route with jsonp support. * * @param callbackName name of query param of callback. * @param route route to add. */ public void addJsonpRoute(Route route, String callbackName) { jsonpRoutes.put(route, callbackName); } /** * Add a filter. * Filters are called in added order. * * @param filter filter to add. */ public void addFilter(AbstractFilter filter) { filters.add(filter); } /** * Reset filters to default (for test uses). */ public void resetFilters() { filters.clear(); } /** * Get parameters from query. * * @param request the request. * @return map of query parameters. */ private Map<String, String> getQueryParameters(HttpServletRequest request) { Map<String, String> queryParameters = new HashMap<>(); Enumeration<String> parametersName = request.getParameterNames(); while (parametersName.hasMoreElements()) { String name = parametersName.nextElement(); queryParameters.put(name, request.getParameter(name)); } return queryParameters; } /** * Handle a request. * * @param target The target of the request - either a URI or a name. * @param baseRequest The original unwrapped request object. * @param request The request either as the {@link Request} * object or a wrapper of that request. * @param response The response as the {@link org.eclipse.jetty.server.Response} * object or a wrapper of that request. * @throws IOException in case of IO error. */ @Override public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException { if (baseRequest.isHandled()) { return; } if (request.getContentType() != null && request.getContentType().startsWith("multipart/form-data")) { baseRequest.setAttribute(Request.__MULTIPART_CONFIG_ELEMENT, MultiPartInputStreamParser.__DEFAULT_MULTIPART_CONFIG); } Route<?, ?> route = findRoute(request.getMethod(), request.getPathInfo()); String callback = null; if (route == null && HttpMethod.fromValue(request.getMethod()) == HttpMethod.GET) { for (Map.Entry<Route, String> entry : jsonpRoutes.entrySet()) { if (entry.getKey().isThisPath(request.getPathInfo())) { route = entry.getKey(); callback = entry.getValue(); } } } if (route == null) { return; } processRoute(request, response, route, callback); baseRequest.setHandled(true); } /** * Process a route. * * @param request The request either as the {@link Request} * object or a wrapper of that request. * @param response The response as the {@link org.eclipse.jetty.server.Response} * object or a wrapper of that request. * @param route route to apply. * @param callback callback in case of jsonp. * @param <P> parameter type of route. * @param <R> return type of route. * @throws IOException in case of IO error. */ <P, R> void processRoute(HttpServletRequest request, HttpServletResponse response, Route<P, R> route, String callback) throws IOException { P param = route.getRouteParam(request); try { beginTransaction(); RouteParameters parameters = new RouteParameters( route.getRouteParams(request.getPathInfo(), getQueryParameters(request))); for (AbstractFilter filter : filters) { filter.handle(route, parameters); } Response<R> handlerResponse = route.handle(param, parameters); commitTransaction(); writeHttpResponse(request, response, handlerResponse, callback, parameters, route.getContentType()); } catch (HttpErrorException httpError) { commitTransaction(); writeHttpError(response, httpError, route.getContentType()); } catch (Exception exception) { rollBackTransaction(); writeInternalError(response, exception); } } /** * Write http response with exception details. * * @param response http response. * @param exception exception. * @throws IOException in case of IO error. */ private void writeInternalError(HttpServletResponse response, Exception exception) throws IOException { response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); StringWriter writer = new StringWriter(); PrintWriter printWriter = new PrintWriter(writer); exception.printStackTrace(printWriter); response.getWriter().print(writer.toString()); response.getWriter().close(); } /** * Write http response with HttpError details. * * @param response http response. * @param httpError http error. * @param contentType content type of response. * @throws IOException in case of IO error. */ private void writeHttpError(HttpServletResponse response, HttpErrorException httpError, ContentType contentType) throws IOException { response.setStatus(httpError.getStatus()); if (httpError.getAnswer() != null) { response.setContentType(contentType.getValue()); response.getWriter().print(contentType.convertObject(httpError.getAnswer())); response.getWriter().close(); } } /** * Write http response. * * @param request http request. * @param response http response. * @param handlerResponse response of route handler. * @param callback callback in case of jsonp. * @param parameters parameters in the routePath. * @param contentType content type of response. * @param <R> return type of route. * @throws IOException in case of IO error. */ private <R> void writeHttpResponse(HttpServletRequest request, HttpServletResponse response, Response<R> handlerResponse, String callback, RouteParameters parameters, ContentType contentType) throws IOException { if (handlerResponse.getStatus() != null) { response.setStatus(handlerResponse.getStatus()); } else if (handlerResponse.getAnswer() == null) { response.setStatus(HttpMethod.fromValue(request.getMethod()).getDefaultStatusWithNoContent()); } else { response.setStatus(HttpMethod.fromValue(request.getMethod()).getDefaultStatus()); } if (handlerResponse.getAnswer() != null) { if (handlerResponse.isStream()) { writeHttpResponseForEventSource(request, response, contentType, handlerResponse); } else { writeHttpResponse(response, handlerResponse, callback, parameters, contentType, request.getHeaders("Accept-Encoding")); } } } /** * Does the answer support gzip? * * @param acceptEncodings all Accept-Encoding headers received. * @return true is gzip is supported. */ private boolean supportGzip(Enumeration<String> acceptEncodings) { if (acceptEncodings != null) { for (String acceptEncoding : Collections.list(acceptEncodings)) { for (String encoding : acceptEncoding.split(",")) { if (encoding.equals("gzip")) { return true; } } } } return false; } /** * Write http response. * * @param response http response. * @param handlerResponse response of route handler. * @param callback callback in case of jsonp. * @param parameters parameters in the routePath. * @param contentType content type of response. * @param acceptEncodings value of header accept-encoding. * @param <R> return type of route. * @throws IOException in case of IO error. */ private <R> void writeHttpResponse(HttpServletResponse response, Response<R> handlerResponse, String callback, RouteParameters parameters, ContentType contentType, Enumeration<String> acceptEncodings) throws IOException { response.setContentType(contentType.getValue()); PrintWriter writer; if (supportGzip(acceptEncodings)) { response.addHeader("Content-Encoding", "gzip"); writer = new PrintWriter(new OutputStreamWriter( new GZIPOutputStream(response.getOutputStream()), ContentType.CURRENT_CHARSET)); } else { writer = response.getWriter(); } if (callback != null) { writer.print(parameters.getParam(callback)); writer.print('('); } writer.print(contentType.convertObject(handlerResponse.getAnswer())); if (callback != null) { writer.print(");"); } writer.close(); } /** * Content type of event-stream. */ private static final String EVENT_STREAM_CONTENT_TYPE = "text/event-stream;charset=" + Charset.defaultCharset().displayName(); /** * Write http response for EventSource case. * * @param request http request. * @param response http response. * @param contentType content type of response. * @param handlerResponse response of route handler. * @throws IOException in case of IO error. */ @SuppressWarnings("unchecked") protected void writeHttpResponseForEventSource(HttpServletRequest request, HttpServletResponse response, ContentType contentType, final Response<?> handlerResponse) throws IOException { response.setContentType(EVENT_STREAM_CONTENT_TYPE); response.addHeader("Connection", "close"); response.flushBuffer(); final Continuation continuation = ContinuationSupport.getContinuation(request); // Infinite timeout because the continuation is never resumed, // but only completed on close continuation.setTimeout(0L); continuation.suspend(response); if (handlerResponse.getAnswer() instanceof Stream) { Response<Stream> streamResponse = (Response<Stream>) handlerResponse; executorServiceForEventSource.scheduleAtFixedRate(new EventSourceTask(contentType, streamResponse, continuation), 0, streamResponse.getAnswer().timeBeforeNextEvent(), TimeUnit.MILLISECONDS); } else if (handlerResponse.getAnswer() instanceof ReactiveStream) { ((Response<ReactiveStream>) handlerResponse).getAnswer().setReactiveHandler( new ReactiveEventSourceTask(contentType, continuation)); } else { throw new FatalSimpleWeb4jException("Your answer is an unknown stream"); } } /** * Rollback current transaction if exists. */ private void rollBackTransaction() { closeTransaction(true); } /** * Close current transaction if exists. * * @param rollback true if you want a rollback, false if you want a commit. */ private void closeTransaction(boolean rollback) { if (SimpleEntityManager.getCurrentSession() != null) { if (rollback) { SimpleEntityManager.getCurrentSession().getTransaction().rollback(); } else { SimpleEntityManager.getCurrentSession().getTransaction().commit(); } SimpleEntityManager.closeSession(); } } /** * Commit current transaction if exists. */ private void commitTransaction() { closeTransaction(false); } /** * Open a new transaction if there is entities. */ private void beginTransaction() { if (SimpleEntityManager.hasEntities()) { SimpleEntityManager.openSession().beginTransaction(); } } /** * Find a route for method and path. * * @param httpMethod http method. * @param pathInfo path. * @return the route found (null if no route found). */ protected Route<?, ?> findRoute(String httpMethod, String pathInfo) { if (!routes.containsKey(HttpMethod.fromValue(httpMethod))) { return null; } for (Route route : routes.get(HttpMethod.fromValue(httpMethod))) { if (route.isThisPath(pathInfo)) { return route; } } return null; } }