--- a/src/main/java/de/uapcore/lightpit/AbstractServlet.java Sat Jan 23 14:47:59 2021 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,460 +0,0 @@ -/* - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. - * - * Copyright 2021 Mike Becker. All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - * - */ -package de.uapcore.lightpit; - -import de.uapcore.lightpit.dao.DataAccessObject; -import de.uapcore.lightpit.dao.PostgresDataAccessObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; -import java.io.IOException; -import java.lang.reflect.*; -import java.sql.Connection; -import java.sql.SQLException; -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; - -/** - * A special implementation of a HTTPServlet which is focused on implementing - * the necessary functionality for LightPIT pages. - */ -public abstract class AbstractServlet extends HttpServlet { - - private static final Logger LOG = LoggerFactory.getLogger(AbstractServlet.class); - - /** - * Invocation mapping gathered from the {@link RequestMapping} annotations. - * <p> - * Paths in this map must always start with a leading slash, although - * the specification in the annotation must not start with a leading slash. - * <p> - * The reason for this is the different handling of empty paths in - * {@link HttpServletRequest#getPathInfo()}. - */ - private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>(); - - /** - * Creates a set of data access objects for the specified connection. - * - * @param connection the SQL connection - * @return a set of data access objects - */ - private DataAccessObject createDataAccessObjects(Connection connection) { - final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); - if (df.getDialect() == DataSourceProvider.Dialect.Postgres) { - return new PostgresDataAccessObject(connection); - } - throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug."); - } - - private void invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException { - final var pathPattern = mapping.getKey(); - final var method = mapping.getValue(); - try { - LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName()); - final var paramTypes = method.getParameterTypes(); - final var paramValues = new Object[paramTypes.length]; - for (int i = 0; i < paramTypes.length; i++) { - if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) { - paramValues[i] = req; - } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) { - paramValues[i] = resp; - } - if (paramTypes[i].isAssignableFrom(DataAccessObject.class)) { - paramValues[i] = dao; - } - if (paramTypes[i].isAssignableFrom(PathParameters.class)) { - paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req)); - } - } - method.invoke(this, paramValues); - } catch (InvocationTargetException ex) { - LOG.error("invocation of method {}::{} failed: {}", - method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage()); - LOG.debug("Details: ", ex.getTargetException()); - resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage()); - } catch (ReflectiveOperationException | ClassCastException ex) { - LOG.error("invocation of method {}::{} failed: {}", - method.getDeclaringClass().getName(), method.getName(), ex.getMessage()); - LOG.debug("Details: ", ex); - resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage()); - } - } - - @Override - public void init() throws ServletException { - scanForRequestMappings(); - - LOG.trace("{} initialized", getServletName()); - } - - private void scanForRequestMappings() { - try { - Method[] methods = getClass().getDeclaredMethods(); - for (Method method : methods) { - Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class)); - if (mapping.isPresent()) { - if (mapping.get().requestPath().isBlank()) { - LOG.warn("{} is annotated with {} but request path is empty", - method.getName(), RequestMapping.class.getSimpleName() - ); - continue; - } - - if (!Modifier.isPublic(method.getModifiers())) { - LOG.warn("{} is annotated with {} but is not public", - method.getName(), RequestMapping.class.getSimpleName() - ); - continue; - } - if (Modifier.isAbstract(method.getModifiers())) { - LOG.warn("{} is annotated with {} but is abstract", - method.getName(), RequestMapping.class.getSimpleName() - ); - continue; - } - - boolean paramsInjectible = true; - for (var param : method.getParameterTypes()) { - paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param) - || HttpServletResponse.class.isAssignableFrom(param) - || PathParameters.class.isAssignableFrom(param) - || DataAccessObject.class.isAssignableFrom(param); - } - if (paramsInjectible) { - try { - PathPattern pathPattern = new PathPattern(mapping.get().requestPath()); - - final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>()); - final var currentMapping = methodMappings.putIfAbsent(pathPattern, method); - if (currentMapping != null) { - LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}", - mapping.get().method(), - mapping.get().requestPath(), - method.getName(), - getClass().getSimpleName(), - currentMapping.getName() - ); - } - - LOG.debug("{} {} maps to {}::{}", - mapping.get().method(), - mapping.get().requestPath(), - getClass().getSimpleName(), - method.getName() - ); - } catch (IllegalArgumentException ex) { - LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid", - method.getName(), mapping.get().requestPath() - ); - } - } else { - LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed", - method.getName(), RequestMapping.class.getSimpleName() - ); - } - } - } - } catch (SecurityException ex) { - LOG.error("Scan for request mappings on declared methods failed.", ex); - } - } - - @Override - public void destroy() { - mappings.clear(); - LOG.trace("{} destroyed", getServletName()); - } - - /** - * Sets the name of the content page. - * <p> - * It is sufficient to specify the name without any extension. The extension - * is added automatically if not specified. - * - * @param req the servlet request object - * @param pageName the name of the content page - * @see Constants#REQ_ATTR_CONTENT_PAGE - */ - protected void setContentPage(HttpServletRequest req, String pageName) { - req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName)); - } - - /** - * Sets the navigation menu. - * - * @param req the servlet request object - * @param jspName the name of the menu's jsp file - * @see Constants#REQ_ATTR_NAVIGATION - */ - protected void setNavigationMenu(HttpServletRequest req, String jspName) { - req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName)); - } - - /** - * @param req the servlet request object - * @param location the location where to redirect - * @see Constants#REQ_ATTR_REDIRECT_LOCATION - */ - protected void setRedirectLocation(HttpServletRequest req, String location) { - if (location.startsWith("./")) { - location = location.replaceFirst("\\./", baseHref(req)); - } - req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location); - } - - /** - * Specifies the names of additional stylesheets used by this Servlet. - * <p> - * It is sufficient to specify the name without any extension. The extension - * is added automatically if not specified. - * - * @param req the servlet request object - * @param stylesheets the names of the stylesheets - */ - public void setStylesheet(HttpServletRequest req, String ... stylesheets) { - req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays - .stream(stylesheets) - .map(s -> enforceExt(s, ".css")) - .collect(Collectors.toUnmodifiableList())); - } - - /** - * Sets the view model object. - * The type must match the expected type in the JSP file. - * - * @param req the servlet request object - * @param viewModel the view model object - */ - public void setViewModel(HttpServletRequest req, Object viewModel) { - req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel); - } - - private <T> Optional<T> parseParameter(String paramValue, Class<T> clazz) { - if (paramValue == null) return Optional.empty(); - if (clazz.equals(Boolean.class)) { - if (paramValue.equalsIgnoreCase("false") || paramValue.equals("0")) { - return Optional.of((T) Boolean.FALSE); - } else { - return Optional.of((T) Boolean.TRUE); - } - } - if (clazz.equals(String.class)) return Optional.of((T) paramValue); - if (java.sql.Date.class.isAssignableFrom(clazz)) { - try { - return Optional.of((T) java.sql.Date.valueOf(paramValue)); - } catch (IllegalArgumentException ex) { - return Optional.empty(); - } - } - try { - final Constructor<T> ctor = clazz.getConstructor(String.class); - return Optional.of(ctor.newInstance(paramValue)); - } catch (ReflectiveOperationException e) { - // does not type check and is not convertible - treat as if the parameter was never set - return Optional.empty(); - } - } - - /** - * Obtains a request parameter of the specified type. - * The specified type must have a single-argument constructor accepting a string to perform conversion. - * The constructor of the specified type may throw an exception on conversion failures. - * - * @param req the servlet request object - * @param clazz the class object of the expected type - * @param name the name of the parameter - * @param <T> the expected type - * @return the parameter value or an empty optional, if no parameter with the specified name was found - */ - protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) { - if (clazz.isArray()) { - final String[] paramValues = req.getParameterValues(name); - int len = paramValues == null ? 0 : paramValues.length; - final var array = (T) Array.newInstance(clazz.getComponentType(), len); - for (int i = 0; i < len; i++) { - try { - final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class); - Array.set(array, i, ctor.newInstance(paramValues[i])); - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } - } - return Optional.of(array); - } else { - return parseParameter(req.getParameter(name), clazz); - } - } - - /** - * Tries to look up an entity with a key obtained from a request parameter. - * - * @param req the servlet request object - * @param clazz the class representing the type of the request parameter - * @param name the name of the request parameter - * @param find the find function (typically a DAO function) - * @param <T> the type of the request parameter - * @param <R> the type of the looked up entity - * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing - * @throws SQLException if the find function throws an exception - */ - protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, Function<? super T, ? extends R> find) { - final var param = getParameter(req, clazz, name); - if (param.isPresent()) { - return Optional.ofNullable(find.apply(param.get())); - } else { - return Optional.empty(); - } - } - - protected void setAttributeFromParameter(HttpServletRequest req, String name) { - final var parm = req.getParameter(name); - if (parm != null) { - req.setAttribute(name, parm); - } - } - - private String sanitizeRequestPath(HttpServletRequest req) { - return Optional.ofNullable(req.getPathInfo()).orElse("/"); - } - - private Optional<Map.Entry<PathPattern, Method>> findMapping(HttpMethod method, HttpServletRequest req) { - return Optional.ofNullable(mappings.get(method)).flatMap(rm -> - rm.entrySet().stream().filter( - kv -> kv.getKey().matches(sanitizeRequestPath(req)) - ).findAny() - ); - } - - protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - req.getRequestDispatcher(jspPath("site")).forward(req, resp); - } - - protected Optional<String[]> availableLanguages() { - return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*")); - } - - private static String baseHref(HttpServletRequest req) { - return String.format("%s://%s:%d%s/", - req.getScheme(), - req.getServerName(), - req.getServerPort(), - req.getContextPath()); - } - - private static String enforceExt(String filename, String ext) { - return filename.endsWith(ext) ? filename : filename + ext; - } - - private static String jspPath(String filename) { - return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp"); - } - - private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - // the very first thing to do is to force UTF-8 - req.setCharacterEncoding("UTF-8"); - - // choose the requested language as session language (if available) or fall back to english, otherwise - HttpSession session = req.getSession(); - if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) { - Optional<List<String>> availableLanguages = availableLanguages().map(Arrays::asList); - Optional<Locale> reqLocale = Optional.of(req.getLocale()); - Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH); - session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale); - LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage()); - } else { - Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE); - resp.setLocale(sessionLocale); - LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale); - } - - // set some internal request attributes - final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse(""); - req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req)); - req.setAttribute(Constants.REQ_ATTR_PATH, fullPath); - - // if this is an error path, bypass the normal flow - if (fullPath.startsWith("/error/")) { - final var mapping = findMapping(method, req); - if (mapping.isPresent()) { - invokeMapping(mapping.get(), req, resp, null); - } - return; - } - - // obtain a connection and create the data access objects - final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME()); - final var ds = db.getDataSource(); - if (ds == null) { - resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details."); - return; - } - try (final var connection = ds.getConnection()) { - final var dao = createDataAccessObjects(connection); - try { - connection.setAutoCommit(false); - // call the handler, if available, or send an HTTP 404 error - final var mapping = findMapping(method, req); - if (mapping.isPresent()) { - invokeMapping(mapping.get(), req, resp, dao); - } else { - resp.sendError(HttpServletResponse.SC_NOT_FOUND); - } - connection.commit(); - } catch (SQLException ex) { - LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage()); - LOG.debug("Details: ", ex); - resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode()); - connection.rollback(); - } - } catch (SQLException ex) { - LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage()); - LOG.debug("Details: ", ex); - resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode()); - } - } - - @Override - protected final void doGet(HttpServletRequest req, HttpServletResponse resp) - throws ServletException, IOException { - doProcess(HttpMethod.GET, req, resp); - } - - @Override - protected final void doPost(HttpServletRequest req, HttpServletResponse resp) - throws ServletException, IOException { - doProcess(HttpMethod.POST, req, resp); - } -}