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 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> 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);
+ }
+}