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

Sat, 16 May 2020 15:45:06 +0200

author
Mike Becker <universe@uap-core.de>
date
Sat, 16 May 2020 15:45:06 +0200
changeset 53
6a8498291606
parent 47
57cfb94ab99f
child 54
77e01cda5a40
permissions
-rw-r--r--

fixes bug where displaying an error page for missing data source would also require that data source (error pages don't try to get database connections now)

also improves error pages in general

     1 /*
     2  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
     3  *
     4  * Copyright 2018 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.DataAccessObjects;
    32 import de.uapcore.lightpit.dao.postgres.PGDataAccessObjects;
    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.Constructor;
    43 import java.lang.reflect.Method;
    44 import java.lang.reflect.Modifier;
    45 import java.sql.Connection;
    46 import java.sql.SQLException;
    47 import java.util.*;
    49 /**
    50  * A special implementation of a HTTPServlet which is focused on implementing
    51  * the necessary functionality for {@link LightPITModule}s.
    52  */
    53 public abstract class AbstractLightPITServlet extends HttpServlet {
    55     private static final Logger LOG = LoggerFactory.getLogger(AbstractLightPITServlet.class);
    57     private static final String SITE_JSP = Functions.jspPath("site");
    59     /**
    60      * The EL proxy is necessary, because the EL resolver cannot handle annotation properties.
    61      */
    62     private LightPITModule.ELProxy moduleInfo = null;
    64     /**
    65      * Invocation mapping gathered from the {@link RequestMapping} annotations.
    66      * <p>
    67      * Paths in this map must always start with a leading slash, although
    68      * the specification in the annotation must not start with a leading slash.
    69      * <p>
    70      * The reason for this is the different handling of empty paths in
    71      * {@link HttpServletRequest#getPathInfo()}.
    72      */
    73     private final Map<HttpMethod, Map<String, Method>> mappings = new HashMap<>();
    75     private final List<MenuEntry> subMenu = new ArrayList<>();
    77     /**
    78      * Gives implementing modules access to the {@link ModuleManager}.
    79      *
    80      * @return the module manager
    81      */
    82     protected final ModuleManager getModuleManager() {
    83         return (ModuleManager) getServletContext().getAttribute(ModuleManager.SC_ATTR_NAME);
    84     }
    87     /**
    88      * Creates a set of data access objects for the specified connection.
    89      *
    90      * @param connection the SQL connection
    91      * @return a set of data access objects
    92      */
    93     private DataAccessObjects createDataAccessObjects(Connection connection) throws SQLException {
    94         final var df = (DatabaseFacade) getServletContext().getAttribute(DatabaseFacade.SC_ATTR_NAME);
    95         if (df.getSQLDialect() == DatabaseFacade.Dialect.Postgres) {
    96             return new PGDataAccessObjects(connection);
    97         }
    98         throw new AssertionError("Non-exhaustive if-else - this is a bug.");
    99     }
   101     private ResponseType invokeMapping(Method method, HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException {
   102         try {
   103             LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName());
   104             final var paramTypes = method.getParameterTypes();
   105             final var paramValues = new Object[paramTypes.length];
   106             for (int i = 0; i < paramTypes.length; i++) {
   107                 if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) {
   108                     paramValues[i] = req;
   109                 } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) {
   110                     paramValues[i] = resp;
   111                 }
   112                 if (paramTypes[i].isAssignableFrom(DataAccessObjects.class)) {
   113                     paramValues[i] = dao;
   114                 }
   115             }
   116             return (ResponseType) method.invoke(this, paramValues);
   117         } catch (ReflectiveOperationException | ClassCastException ex) {
   118             LOG.error("invocation of method {} failed: {}", method.getName(), ex.getMessage());
   119             LOG.debug("Details: ", ex);
   120             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
   121             return ResponseType.NONE;
   122         }
   123     }
   125     @Override
   126     public void init() throws ServletException {
   127         moduleInfo = Optional.ofNullable(this.getClass().getAnnotation(LightPITModule.class))
   128                 .map(LightPITModule.ELProxy::new).orElse(null);
   130         if (moduleInfo != null) {
   131             scanForRequestMappings();
   132         }
   134         LOG.trace("{} initialized", getServletName());
   135     }
   137     private void scanForRequestMappings() {
   138         try {
   139             Method[] methods = getClass().getDeclaredMethods();
   140             for (Method method : methods) {
   141                 Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class));
   142                 if (mapping.isPresent()) {
   143                     if (!Modifier.isPublic(method.getModifiers())) {
   144                         LOG.warn("{} is annotated with {} but is not public",
   145                                 method.getName(), RequestMapping.class.getSimpleName()
   146                         );
   147                         continue;
   148                     }
   149                     if (Modifier.isAbstract(method.getModifiers())) {
   150                         LOG.warn("{} is annotated with {} but is abstract",
   151                                 method.getName(), RequestMapping.class.getSimpleName()
   152                         );
   153                         continue;
   154                     }
   155                     if (!ResponseType.class.isAssignableFrom(method.getReturnType())) {
   156                         LOG.warn("{} is annotated with {} but has the wrong return type - 'ResponseType' required",
   157                                 method.getName(), RequestMapping.class.getSimpleName()
   158                         );
   159                         continue;
   160                     }
   162                     boolean paramsInjectible = true;
   163                     for (var param : method.getParameterTypes()) {
   164                         paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param)
   165                                 || HttpServletResponse.class.isAssignableFrom(param)
   166                                 || DataAccessObjects.class.isAssignableFrom(param);
   167                     }
   168                     if (paramsInjectible) {
   169                         final String requestPath = "/" + mapping.get().requestPath();
   171                         if (mappings
   172                                 .computeIfAbsent(mapping.get().method(), k -> new HashMap<>())
   173                                 .putIfAbsent(requestPath, method) != null) {
   174                             LOG.warn("{} {} has multiple mappings",
   175                                     mapping.get().method(),
   176                                     mapping.get().requestPath()
   177                             );
   178                         }
   180                         final var menuKey = mapping.get().menuKey();
   181                         if (!menuKey.isBlank()) {
   182                             subMenu.add(new MenuEntry(
   183                                     new ResourceKey(moduleInfo.getBundleBaseName(), menuKey),
   184                                     moduleInfo.getModulePath() + requestPath,
   185                                     mapping.get().menuSequence()));
   186                         }
   188                         LOG.debug("{} {} maps to {}::{}",
   189                                 mapping.get().method(),
   190                                 requestPath,
   191                                 getClass().getSimpleName(),
   192                                 method.getName()
   193                         );
   194                     } else {
   195                         LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest. HttpServletResponse, and DataAccessObjects are allowed",
   196                                 method.getName(), RequestMapping.class.getSimpleName()
   197                         );
   198                     }
   199                 }
   200             }
   201         } catch (SecurityException ex) {
   202             LOG.error("Scan for request mappings on declared methods failed.", ex);
   203         }
   204     }
   206     @Override
   207     public void destroy() {
   208         mappings.clear();
   209         LOG.trace("{} destroyed", getServletName());
   210     }
   212     /**
   213      * Sets the name of the dynamic fragment.
   214      * <p>
   215      * It is sufficient to specify the name without any extension. The extension
   216      * is added automatically if not specified.
   217      * <p>
   218      * The fragment must be located in the dynamic fragments folder.
   219      *
   220      * @param req          the servlet request object
   221      * @param fragmentName the name of the fragment
   222      * @see Constants#DYN_FRAGMENT_PATH_PREFIX
   223      */
   224     public void setDynamicFragment(HttpServletRequest req, String fragmentName) {
   225         req.setAttribute(Constants.REQ_ATTR_FRAGMENT, Functions.dynFragmentPath(fragmentName));
   226     }
   228     /**
   229      * @param req      the servlet request object
   230      * @param location the location where to redirect
   231      * @see Constants#REQ_ATTR_REDIRECT_LOCATION
   232      */
   233     public void setRedirectLocation(HttpServletRequest req, String location) {
   234         if (location.startsWith("./")) {
   235             location = location.replaceFirst("\\./", Functions.baseHref(req));
   236         }
   237         req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location);
   238     }
   240     /**
   241      * Specifies the name of an additional stylesheet used by the module.
   242      * <p>
   243      * Setting an additional stylesheet is optional, but quite common for HTML
   244      * output.
   245      * <p>
   246      * It is sufficient to specify the name without any extension. The extension
   247      * is added automatically if not specified.
   248      *
   249      * @param req        the servlet request object
   250      * @param stylesheet the name of the stylesheet
   251      */
   252     public void setStylesheet(HttpServletRequest req, String stylesheet) {
   253         req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Functions.enforceExt(stylesheet, ".css"));
   254     }
   256     /**
   257      * Obtains a request parameter of the specified type.
   258      * The specified type must have a single-argument constructor accepting a string to perform conversion.
   259      * The constructor of the specified type may throw an exception on conversion failures.
   260      *
   261      * @param req the servlet request object
   262      * @param clazz the class object of the expected type
   263      * @param name the name of the parameter
   264      * @param <T> the expected type
   265      * @return the parameter value or an empty optional, if no parameter with the specified name was found
   266      */
   267     public<T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) {
   268         final String paramValue = req.getParameter(name);
   269         if (paramValue == null) return Optional.empty();
   270         if (clazz.equals(String.class)) return Optional.of((T)paramValue);
   271         try {
   272             final Constructor<T> ctor = clazz.getConstructor(String.class);
   273             return Optional.of(ctor.newInstance(paramValue));
   274         } catch (ReflectiveOperationException e) {
   275             throw new RuntimeException(e);
   276         }
   278     }
   280     private void forwardToFullView(HttpServletRequest req, HttpServletResponse resp)
   281             throws IOException, ServletException {
   283         req.setAttribute(Constants.REQ_ATTR_MENU, getModuleManager().getMainMenu());
   284         req.setAttribute(Constants.REQ_ATTR_SUB_MENU, subMenu);
   285         req.getRequestDispatcher(SITE_JSP).forward(req, resp);
   286     }
   288     private String sanitizeRequestPath(HttpServletRequest req) {
   289         return Optional.ofNullable(req.getPathInfo()).orElse("/");
   290     }
   292     private Optional<Method> findMapping(HttpMethod method, HttpServletRequest req) {
   293         return Optional.ofNullable(mappings.get(method)).map(rm -> rm.get(sanitizeRequestPath(req)));
   294     }
   296     private void forwardAsSpecified(ResponseType type, HttpServletRequest req, HttpServletResponse resp)
   297             throws ServletException, IOException {
   298         switch (type) {
   299             case NONE:
   300                 return;
   301             case HTML:
   302                 forwardToFullView(req, resp);
   303                 return;
   304             // TODO: implement remaining response types
   305             default:
   306                 throw new AssertionError("ResponseType switch is not exhaustive - this is a bug!");
   307         }
   308     }
   310     private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
   312         // choose the requested language as session language (if available) or fall back to english, otherwise
   313         HttpSession session = req.getSession();
   314         if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) {
   315             Optional<List<String>> availableLanguages = Functions.availableLanguages(getServletContext()).map(Arrays::asList);
   316             Optional<Locale> reqLocale = Optional.of(req.getLocale());
   317             Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH);
   318             session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale);
   319             LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage());
   320         } else {
   321             Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE);
   322             resp.setLocale(sessionLocale);
   323             LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale);
   324         }
   326         // set some internal request attributes
   327         final String fullPath = Functions.fullPath(req);
   328         req.setAttribute(Constants.REQ_ATTR_BASE_HREF, Functions.baseHref(req));
   329         req.setAttribute(Constants.REQ_ATTR_PATH, fullPath);
   330         Optional.ofNullable(moduleInfo).ifPresent((proxy) -> req.setAttribute(Constants.REQ_ATTR_MODULE_INFO, proxy));
   332         // if this is an error path, bypass the normal flow
   333         if (fullPath.startsWith("/error/")) {
   334             final var mapping = findMapping(method, req);
   335             if (mapping.isPresent()) {
   336                 forwardAsSpecified(invokeMapping(mapping.get(), req, resp, null), req, resp);
   337             }
   338             return;
   339         }
   341         // obtain a connection and create the data access objects
   342         final var db = (DatabaseFacade) req.getServletContext().getAttribute(DatabaseFacade.SC_ATTR_NAME);
   343         final var ds = db.getDataSource();
   344         if (ds == null) {
   345             resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details.");
   346             return;
   347         }
   348         try (final var connection = ds.getConnection()) {
   349             final var dao = createDataAccessObjects(connection);
   350             try {
   351                 connection.setAutoCommit(false);
   352                 // call the handler, if available, or send an HTTP 404 error
   353                 final var mapping = findMapping(method, req);
   354                 if (mapping.isPresent()) {
   355                     forwardAsSpecified(invokeMapping(mapping.get(), req, resp, dao), req, resp);
   356                 } else {
   357                     resp.sendError(HttpServletResponse.SC_NOT_FOUND);
   358                 }
   359                 connection.commit();
   360             } catch (SQLException ex) {
   361                 LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage());
   362                 LOG.debug("Details: ", ex);
   363                 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code:" + ex.getErrorCode());
   364                 connection.rollback();
   365             }
   366         } catch (SQLException ex) {
   367             LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage());
   368             LOG.debug("Details: ", ex);
   369             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code:" + ex.getErrorCode());
   370         }
   371     }
   373     @Override
   374     protected final void doGet(HttpServletRequest req, HttpServletResponse resp)
   375             throws ServletException, IOException {
   376         doProcess(HttpMethod.GET, req, resp);
   377     }
   379     @Override
   380     protected final void doPost(HttpServletRequest req, HttpServletResponse resp)
   381             throws ServletException, IOException {
   382         doProcess(HttpMethod.POST, req, resp);
   383     }
   384 }

mercurial