diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/AbstractServlet.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/java/de/uapcore/lightpit/AbstractServlet.java Tue Jan 05 19:19:31 2021 +0100 @@ -0,0 +1,471 @@ +/* + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. + * + * Copyright 2018 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); + + private static final String SITE_JSP = jspPath("site"); + + /** + * Invocation mapping gathered from the {@link RequestMapping} annotations. + *

+ * Paths in this map must always start with a leading slash, although + * the specification in the annotation must not start with a leading slash. + *

+ * The reason for this is the different handling of empty paths in + * {@link HttpServletRequest#getPathInfo()}. + */ + private final Map> mappings = new HashMap<>(); + + /** + * Returns the name of the resource bundle associated with this servlet. + * + * @return the resource bundle base name + */ + protected abstract String getResourceBundleName(); + + + /** + * 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 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 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. + *

+ * 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. + *

+ * 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 Optional parseParameter(String paramValue, Class 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 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 the expected type + * @return the parameter value or an empty optional, if no parameter with the specified name was found + */ + protected Optional getParameter(HttpServletRequest req, Class 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 the type of the request parameter + * @param 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 Optional findByParameter(HttpServletRequest req, Class clazz, String name, Function 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> 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(SITE_JSP).forward(req, resp); + } + + protected Optional 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> availableLanguages = availableLanguages().map(Arrays::asList); + Optional 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); + req.setAttribute(Constants.REQ_ATTR_RESOURCE_BUNDLE, getResourceBundleName()); + + // 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); + } +}