src/main/java/de/uapcore/lightpit/AbstractServlet.java

Wed, 06 Jan 2021 16:41:09 +0100

author
Mike Becker <universe@uap-core.de>
date
Wed, 06 Jan 2021 16:41:09 +0100
changeset 182
53f0e2685ad5
parent 180
009700915269
permissions
-rw-r--r--

single-use constant field can be inlined

     1 /*
     2  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
     3  *
     4  * Copyright 2021 Mike Becker. All rights reserved.
     5  *
     6  * Redistribution and use in source and binary forms, with or without
     7  * modification, are permitted provided that the following conditions are met:
     8  *
     9  *   1. Redistributions of source code must retain the above copyright
    10  *      notice, this list of conditions and the following disclaimer.
    11  *
    12  *   2. Redistributions in binary form must reproduce the above copyright
    13  *      notice, this list of conditions and the following disclaimer in the
    14  *      documentation and/or other materials provided with the distribution.
    15  *
    16  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    17  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    18  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    19  * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    20  * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    21  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    22  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    23  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    24  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    25  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    26  * POSSIBILITY OF SUCH DAMAGE.
    27  *
    28  */
    29 package de.uapcore.lightpit;
    31 import de.uapcore.lightpit.dao.DataAccessObject;
    32 import de.uapcore.lightpit.dao.PostgresDataAccessObject;
    33 import org.slf4j.Logger;
    34 import org.slf4j.LoggerFactory;
    36 import javax.servlet.ServletException;
    37 import javax.servlet.http.HttpServlet;
    38 import javax.servlet.http.HttpServletRequest;
    39 import javax.servlet.http.HttpServletResponse;
    40 import javax.servlet.http.HttpSession;
    41 import java.io.IOException;
    42 import java.lang.reflect.*;
    43 import java.sql.Connection;
    44 import java.sql.SQLException;
    45 import java.util.*;
    46 import java.util.function.Function;
    47 import java.util.stream.Collectors;
    49 /**
    50  * A special implementation of a HTTPServlet which is focused on implementing
    51  * the necessary functionality for LightPIT pages.
    52  */
    53 public abstract class AbstractServlet extends HttpServlet {
    55     private static final Logger LOG = LoggerFactory.getLogger(AbstractServlet.class);
    57     /**
    58      * Invocation mapping gathered from the {@link RequestMapping} annotations.
    59      * <p>
    60      * Paths in this map must always start with a leading slash, although
    61      * the specification in the annotation must not start with a leading slash.
    62      * <p>
    63      * The reason for this is the different handling of empty paths in
    64      * {@link HttpServletRequest#getPathInfo()}.
    65      */
    66     private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>();
    68     /**
    69      * Creates a set of data access objects for the specified connection.
    70      *
    71      * @param connection the SQL connection
    72      * @return a set of data access objects
    73      */
    74     private DataAccessObject createDataAccessObjects(Connection connection) {
    75         final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME());
    76         if (df.getDialect() == DataSourceProvider.Dialect.Postgres) {
    77             return new PostgresDataAccessObject(connection);
    78         }
    79         throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug.");
    80     }
    82     private void invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException {
    83         final var pathPattern = mapping.getKey();
    84         final var method = mapping.getValue();
    85         try {
    86             LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName());
    87             final var paramTypes = method.getParameterTypes();
    88             final var paramValues = new Object[paramTypes.length];
    89             for (int i = 0; i < paramTypes.length; i++) {
    90                 if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) {
    91                     paramValues[i] = req;
    92                 } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) {
    93                     paramValues[i] = resp;
    94                 }
    95                 if (paramTypes[i].isAssignableFrom(DataAccessObject.class)) {
    96                     paramValues[i] = dao;
    97                 }
    98                 if (paramTypes[i].isAssignableFrom(PathParameters.class)) {
    99                     paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req));
   100                 }
   101             }
   102             method.invoke(this, paramValues);
   103         } catch (InvocationTargetException ex) {
   104             LOG.error("invocation of method {}::{} failed: {}",
   105                     method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage());
   106             LOG.debug("Details: ", ex.getTargetException());
   107             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage());
   108         } catch (ReflectiveOperationException | ClassCastException ex) {
   109             LOG.error("invocation of method {}::{} failed: {}",
   110                     method.getDeclaringClass().getName(), method.getName(), ex.getMessage());
   111             LOG.debug("Details: ", ex);
   112             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage());
   113         }
   114     }
   116     @Override
   117     public void init() throws ServletException {
   118         scanForRequestMappings();
   120         LOG.trace("{} initialized", getServletName());
   121     }
   123     private void scanForRequestMappings() {
   124         try {
   125             Method[] methods = getClass().getDeclaredMethods();
   126             for (Method method : methods) {
   127                 Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class));
   128                 if (mapping.isPresent()) {
   129                     if (mapping.get().requestPath().isBlank()) {
   130                         LOG.warn("{} is annotated with {} but request path is empty",
   131                                 method.getName(), RequestMapping.class.getSimpleName()
   132                         );
   133                         continue;
   134                     }
   136                     if (!Modifier.isPublic(method.getModifiers())) {
   137                         LOG.warn("{} is annotated with {} but is not public",
   138                                 method.getName(), RequestMapping.class.getSimpleName()
   139                         );
   140                         continue;
   141                     }
   142                     if (Modifier.isAbstract(method.getModifiers())) {
   143                         LOG.warn("{} is annotated with {} but is abstract",
   144                                 method.getName(), RequestMapping.class.getSimpleName()
   145                         );
   146                         continue;
   147                     }
   149                     boolean paramsInjectible = true;
   150                     for (var param : method.getParameterTypes()) {
   151                         paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param)
   152                                             || HttpServletResponse.class.isAssignableFrom(param)
   153                                             || PathParameters.class.isAssignableFrom(param)
   154                                             || DataAccessObject.class.isAssignableFrom(param);
   155                     }
   156                     if (paramsInjectible) {
   157                         try {
   158                             PathPattern pathPattern = new PathPattern(mapping.get().requestPath());
   160                             final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>());
   161                             final var currentMapping = methodMappings.putIfAbsent(pathPattern, method);
   162                             if (currentMapping != null) {
   163                                 LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}",
   164                                         mapping.get().method(),
   165                                         mapping.get().requestPath(),
   166                                         method.getName(),
   167                                         getClass().getSimpleName(),
   168                                         currentMapping.getName()
   169                                 );
   170                             }
   172                             LOG.debug("{} {} maps to {}::{}",
   173                                     mapping.get().method(),
   174                                     mapping.get().requestPath(),
   175                                     getClass().getSimpleName(),
   176                                     method.getName()
   177                             );
   178                         } catch (IllegalArgumentException ex) {
   179                             LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid",
   180                                     method.getName(), mapping.get().requestPath()
   181                             );
   182                         }
   183                     } else {
   184                         LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed",
   185                                 method.getName(), RequestMapping.class.getSimpleName()
   186                         );
   187                     }
   188                 }
   189             }
   190         } catch (SecurityException ex) {
   191             LOG.error("Scan for request mappings on declared methods failed.", ex);
   192         }
   193     }
   195     @Override
   196     public void destroy() {
   197         mappings.clear();
   198         LOG.trace("{} destroyed", getServletName());
   199     }
   201     /**
   202      * Sets the name of the content page.
   203      * <p>
   204      * It is sufficient to specify the name without any extension. The extension
   205      * is added automatically if not specified.
   206      *
   207      * @param req      the servlet request object
   208      * @param pageName the name of the content page
   209      * @see Constants#REQ_ATTR_CONTENT_PAGE
   210      */
   211     protected void setContentPage(HttpServletRequest req, String pageName) {
   212         req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName));
   213     }
   215     /**
   216      * Sets the navigation menu.
   217      *
   218      * @param req     the servlet request object
   219      * @param jspName the name of the menu's jsp file
   220      * @see Constants#REQ_ATTR_NAVIGATION
   221      */
   222     protected void setNavigationMenu(HttpServletRequest req, String jspName) {
   223         req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName));
   224     }
   226     /**
   227      * @param req      the servlet request object
   228      * @param location the location where to redirect
   229      * @see Constants#REQ_ATTR_REDIRECT_LOCATION
   230      */
   231     protected void setRedirectLocation(HttpServletRequest req, String location) {
   232         if (location.startsWith("./")) {
   233             location = location.replaceFirst("\\./", baseHref(req));
   234         }
   235         req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location);
   236     }
   238     /**
   239      * Specifies the names of additional stylesheets used by this Servlet.
   240      * <p>
   241      * It is sufficient to specify the name without any extension. The extension
   242      * is added automatically if not specified.
   243      *
   244      * @param req         the servlet request object
   245      * @param stylesheets the names of the stylesheets
   246      */
   247     public void setStylesheet(HttpServletRequest req, String ... stylesheets) {
   248         req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays
   249                 .stream(stylesheets)
   250                 .map(s -> enforceExt(s, ".css"))
   251                 .collect(Collectors.toUnmodifiableList()));
   252     }
   254     /**
   255      * Sets the view model object.
   256      * The type must match the expected type in the JSP file.
   257      *
   258      * @param req       the servlet request object
   259      * @param viewModel the view model object
   260      */
   261     public void setViewModel(HttpServletRequest req, Object viewModel) {
   262         req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel);
   263     }
   265     private <T> Optional<T> parseParameter(String paramValue, Class<T> clazz) {
   266         if (paramValue == null) return Optional.empty();
   267         if (clazz.equals(Boolean.class)) {
   268             if (paramValue.equalsIgnoreCase("false") || paramValue.equals("0")) {
   269                 return Optional.of((T) Boolean.FALSE);
   270             } else {
   271                 return Optional.of((T) Boolean.TRUE);
   272             }
   273         }
   274         if (clazz.equals(String.class)) return Optional.of((T) paramValue);
   275         if (java.sql.Date.class.isAssignableFrom(clazz)) {
   276             try {
   277                 return Optional.of((T) java.sql.Date.valueOf(paramValue));
   278             } catch (IllegalArgumentException ex) {
   279                 return Optional.empty();
   280             }
   281         }
   282         try {
   283             final Constructor<T> ctor = clazz.getConstructor(String.class);
   284             return Optional.of(ctor.newInstance(paramValue));
   285         } catch (ReflectiveOperationException e) {
   286             // does not type check and is not convertible - treat as if the parameter was never set
   287             return Optional.empty();
   288         }
   289     }
   291     /**
   292      * Obtains a request parameter of the specified type.
   293      * The specified type must have a single-argument constructor accepting a string to perform conversion.
   294      * The constructor of the specified type may throw an exception on conversion failures.
   295      *
   296      * @param req   the servlet request object
   297      * @param clazz the class object of the expected type
   298      * @param name  the name of the parameter
   299      * @param <T>   the expected type
   300      * @return the parameter value or an empty optional, if no parameter with the specified name was found
   301      */
   302     protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) {
   303         if (clazz.isArray()) {
   304             final String[] paramValues = req.getParameterValues(name);
   305             int len = paramValues == null ? 0 : paramValues.length;
   306             final var array = (T) Array.newInstance(clazz.getComponentType(), len);
   307             for (int i = 0; i < len; i++) {
   308                 try {
   309                     final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class);
   310                     Array.set(array, i, ctor.newInstance(paramValues[i]));
   311                 } catch (ReflectiveOperationException e) {
   312                     throw new RuntimeException(e);
   313                 }
   314             }
   315             return Optional.of(array);
   316         } else {
   317             return parseParameter(req.getParameter(name), clazz);
   318         }
   319     }
   321     /**
   322      * Tries to look up an entity with a key obtained from a request parameter.
   323      *
   324      * @param req   the servlet request object
   325      * @param clazz the class representing the type of the request parameter
   326      * @param name  the name of the request parameter
   327      * @param find  the find function (typically a DAO function)
   328      * @param <T>   the type of the request parameter
   329      * @param <R>   the type of the looked up entity
   330      * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing
   331      * @throws SQLException if the find function throws an exception
   332      */
   333     protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, Function<? super T, ? extends R> find) {
   334         final var param = getParameter(req, clazz, name);
   335         if (param.isPresent()) {
   336             return Optional.ofNullable(find.apply(param.get()));
   337         } else {
   338             return Optional.empty();
   339         }
   340     }
   342     protected void setAttributeFromParameter(HttpServletRequest req, String name) {
   343         final var parm = req.getParameter(name);
   344         if (parm != null) {
   345             req.setAttribute(name, parm);
   346         }
   347     }
   349     private String sanitizeRequestPath(HttpServletRequest req) {
   350         return Optional.ofNullable(req.getPathInfo()).orElse("/");
   351     }
   353     private Optional<Map.Entry<PathPattern, Method>> findMapping(HttpMethod method, HttpServletRequest req) {
   354         return Optional.ofNullable(mappings.get(method)).flatMap(rm ->
   355                 rm.entrySet().stream().filter(
   356                         kv -> kv.getKey().matches(sanitizeRequestPath(req))
   357                 ).findAny()
   358         );
   359     }
   361     protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
   362         req.getRequestDispatcher(jspPath("site")).forward(req, resp);
   363     }
   365     protected Optional<String[]> availableLanguages() {
   366         return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*"));
   367     }
   369     private static String baseHref(HttpServletRequest req) {
   370         return String.format("%s://%s:%d%s/",
   371                 req.getScheme(),
   372                 req.getServerName(),
   373                 req.getServerPort(),
   374                 req.getContextPath());
   375     }
   377     private static String enforceExt(String filename, String ext) {
   378         return filename.endsWith(ext) ? filename : filename + ext;
   379     }
   381     private static String jspPath(String filename) {
   382         return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp");
   383     }
   385     private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
   386         // the very first thing to do is to force UTF-8
   387         req.setCharacterEncoding("UTF-8");
   389         // choose the requested language as session language (if available) or fall back to english, otherwise
   390         HttpSession session = req.getSession();
   391         if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) {
   392             Optional<List<String>> availableLanguages = availableLanguages().map(Arrays::asList);
   393             Optional<Locale> reqLocale = Optional.of(req.getLocale());
   394             Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH);
   395             session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale);
   396             LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage());
   397         } else {
   398             Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE);
   399             resp.setLocale(sessionLocale);
   400             LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale);
   401         }
   403         // set some internal request attributes
   404         final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse("");
   405         req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req));
   406         req.setAttribute(Constants.REQ_ATTR_PATH, fullPath);
   408         // if this is an error path, bypass the normal flow
   409         if (fullPath.startsWith("/error/")) {
   410             final var mapping = findMapping(method, req);
   411             if (mapping.isPresent()) {
   412                 invokeMapping(mapping.get(), req, resp, null);
   413             }
   414             return;
   415         }
   417         // obtain a connection and create the data access objects
   418         final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME());
   419         final var ds = db.getDataSource();
   420         if (ds == null) {
   421             resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details.");
   422             return;
   423         }
   424         try (final var connection = ds.getConnection()) {
   425             final var dao = createDataAccessObjects(connection);
   426             try {
   427                 connection.setAutoCommit(false);
   428                 // call the handler, if available, or send an HTTP 404 error
   429                 final var mapping = findMapping(method, req);
   430                 if (mapping.isPresent()) {
   431                     invokeMapping(mapping.get(), req, resp, dao);
   432                 } else {
   433                     resp.sendError(HttpServletResponse.SC_NOT_FOUND);
   434                 }
   435                 connection.commit();
   436             } catch (SQLException ex) {
   437                 LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage());
   438                 LOG.debug("Details: ", ex);
   439                 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode());
   440                 connection.rollback();
   441             }
   442         } catch (SQLException ex) {
   443             LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage());
   444             LOG.debug("Details: ", ex);
   445             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode());
   446         }
   447     }
   449     @Override
   450     protected final void doGet(HttpServletRequest req, HttpServletResponse resp)
   451             throws ServletException, IOException {
   452         doProcess(HttpMethod.GET, req, resp);
   453     }
   455     @Override
   456     protected final void doPost(HttpServletRequest req, HttpServletResponse resp)
   457             throws ServletException, IOException {
   458         doProcess(HttpMethod.POST, req, resp);
   459     }
   460 }

mercurial