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

Tue, 05 Jan 2021 19:19:31 +0100

author
Mike Becker <universe@uap-core.de>
date
Tue, 05 Jan 2021 19:19:31 +0100
changeset 179
623c340058f3
parent 168
src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java@1c3694ae224c
child 180
009700915269
permissions
-rw-r--r--

migrates the utility classes for the AbstractServlet

     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.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     private static final String SITE_JSP = jspPath("site");
    59     /**
    60      * Invocation mapping gathered from the {@link RequestMapping} annotations.
    61      * <p>
    62      * Paths in this map must always start with a leading slash, although
    63      * the specification in the annotation must not start with a leading slash.
    64      * <p>
    65      * The reason for this is the different handling of empty paths in
    66      * {@link HttpServletRequest#getPathInfo()}.
    67      */
    68     private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>();
    70     /**
    71      * Returns the name of the resource bundle associated with this servlet.
    72      *
    73      * @return the resource bundle base name
    74      */
    75     protected abstract String getResourceBundleName();
    78     /**
    79      * Creates a set of data access objects for the specified connection.
    80      *
    81      * @param connection the SQL connection
    82      * @return a set of data access objects
    83      */
    84     private DataAccessObject createDataAccessObjects(Connection connection) {
    85         final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME());
    86         if (df.getDialect() == DataSourceProvider.Dialect.Postgres) {
    87             return new PostgresDataAccessObject(connection);
    88         }
    89         throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug.");
    90     }
    92     private void invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException {
    93         final var pathPattern = mapping.getKey();
    94         final var method = mapping.getValue();
    95         try {
    96             LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName());
    97             final var paramTypes = method.getParameterTypes();
    98             final var paramValues = new Object[paramTypes.length];
    99             for (int i = 0; i < paramTypes.length; i++) {
   100                 if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) {
   101                     paramValues[i] = req;
   102                 } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) {
   103                     paramValues[i] = resp;
   104                 }
   105                 if (paramTypes[i].isAssignableFrom(DataAccessObject.class)) {
   106                     paramValues[i] = dao;
   107                 }
   108                 if (paramTypes[i].isAssignableFrom(PathParameters.class)) {
   109                     paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req));
   110                 }
   111             }
   112             method.invoke(this, paramValues);
   113         } catch (InvocationTargetException ex) {
   114             LOG.error("invocation of method {}::{} failed: {}",
   115                     method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage());
   116             LOG.debug("Details: ", ex.getTargetException());
   117             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage());
   118         } catch (ReflectiveOperationException | ClassCastException ex) {
   119             LOG.error("invocation of method {}::{} failed: {}",
   120                     method.getDeclaringClass().getName(), method.getName(), ex.getMessage());
   121             LOG.debug("Details: ", ex);
   122             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage());
   123         }
   124     }
   126     @Override
   127     public void init() throws ServletException {
   128         scanForRequestMappings();
   130         LOG.trace("{} initialized", getServletName());
   131     }
   133     private void scanForRequestMappings() {
   134         try {
   135             Method[] methods = getClass().getDeclaredMethods();
   136             for (Method method : methods) {
   137                 Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class));
   138                 if (mapping.isPresent()) {
   139                     if (mapping.get().requestPath().isBlank()) {
   140                         LOG.warn("{} is annotated with {} but request path is empty",
   141                                 method.getName(), RequestMapping.class.getSimpleName()
   142                         );
   143                         continue;
   144                     }
   146                     if (!Modifier.isPublic(method.getModifiers())) {
   147                         LOG.warn("{} is annotated with {} but is not public",
   148                                 method.getName(), RequestMapping.class.getSimpleName()
   149                         );
   150                         continue;
   151                     }
   152                     if (Modifier.isAbstract(method.getModifiers())) {
   153                         LOG.warn("{} is annotated with {} but is abstract",
   154                                 method.getName(), RequestMapping.class.getSimpleName()
   155                         );
   156                         continue;
   157                     }
   159                     boolean paramsInjectible = true;
   160                     for (var param : method.getParameterTypes()) {
   161                         paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param)
   162                                             || HttpServletResponse.class.isAssignableFrom(param)
   163                                             || PathParameters.class.isAssignableFrom(param)
   164                                             || DataAccessObject.class.isAssignableFrom(param);
   165                     }
   166                     if (paramsInjectible) {
   167                         try {
   168                             PathPattern pathPattern = new PathPattern(mapping.get().requestPath());
   170                             final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>());
   171                             final var currentMapping = methodMappings.putIfAbsent(pathPattern, method);
   172                             if (currentMapping != null) {
   173                                 LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}",
   174                                         mapping.get().method(),
   175                                         mapping.get().requestPath(),
   176                                         method.getName(),
   177                                         getClass().getSimpleName(),
   178                                         currentMapping.getName()
   179                                 );
   180                             }
   182                             LOG.debug("{} {} maps to {}::{}",
   183                                     mapping.get().method(),
   184                                     mapping.get().requestPath(),
   185                                     getClass().getSimpleName(),
   186                                     method.getName()
   187                             );
   188                         } catch (IllegalArgumentException ex) {
   189                             LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid",
   190                                     method.getName(), mapping.get().requestPath()
   191                             );
   192                         }
   193                     } else {
   194                         LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed",
   195                                 method.getName(), RequestMapping.class.getSimpleName()
   196                         );
   197                     }
   198                 }
   199             }
   200         } catch (SecurityException ex) {
   201             LOG.error("Scan for request mappings on declared methods failed.", ex);
   202         }
   203     }
   205     @Override
   206     public void destroy() {
   207         mappings.clear();
   208         LOG.trace("{} destroyed", getServletName());
   209     }
   211     /**
   212      * Sets the name of the content page.
   213      * <p>
   214      * It is sufficient to specify the name without any extension. The extension
   215      * is added automatically if not specified.
   216      *
   217      * @param req      the servlet request object
   218      * @param pageName the name of the content page
   219      * @see Constants#REQ_ATTR_CONTENT_PAGE
   220      */
   221     protected void setContentPage(HttpServletRequest req, String pageName) {
   222         req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName));
   223     }
   225     /**
   226      * Sets the navigation menu.
   227      *
   228      * @param req     the servlet request object
   229      * @param jspName the name of the menu's jsp file
   230      * @see Constants#REQ_ATTR_NAVIGATION
   231      */
   232     protected void setNavigationMenu(HttpServletRequest req, String jspName) {
   233         req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName));
   234     }
   236     /**
   237      * @param req      the servlet request object
   238      * @param location the location where to redirect
   239      * @see Constants#REQ_ATTR_REDIRECT_LOCATION
   240      */
   241     protected void setRedirectLocation(HttpServletRequest req, String location) {
   242         if (location.startsWith("./")) {
   243             location = location.replaceFirst("\\./", baseHref(req));
   244         }
   245         req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location);
   246     }
   248     /**
   249      * Specifies the names of additional stylesheets used by this Servlet.
   250      * <p>
   251      * It is sufficient to specify the name without any extension. The extension
   252      * is added automatically if not specified.
   253      *
   254      * @param req         the servlet request object
   255      * @param stylesheets the names of the stylesheets
   256      */
   257     public void setStylesheet(HttpServletRequest req, String ... stylesheets) {
   258         req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays
   259                 .stream(stylesheets)
   260                 .map(s -> enforceExt(s, ".css"))
   261                 .collect(Collectors.toUnmodifiableList()));
   262     }
   264     /**
   265      * Sets the view model object.
   266      * The type must match the expected type in the JSP file.
   267      *
   268      * @param req       the servlet request object
   269      * @param viewModel the view model object
   270      */
   271     public void setViewModel(HttpServletRequest req, Object viewModel) {
   272         req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel);
   273     }
   275     private <T> Optional<T> parseParameter(String paramValue, Class<T> clazz) {
   276         if (paramValue == null) return Optional.empty();
   277         if (clazz.equals(Boolean.class)) {
   278             if (paramValue.equalsIgnoreCase("false") || paramValue.equals("0")) {
   279                 return Optional.of((T) Boolean.FALSE);
   280             } else {
   281                 return Optional.of((T) Boolean.TRUE);
   282             }
   283         }
   284         if (clazz.equals(String.class)) return Optional.of((T) paramValue);
   285         if (java.sql.Date.class.isAssignableFrom(clazz)) {
   286             try {
   287                 return Optional.of((T) java.sql.Date.valueOf(paramValue));
   288             } catch (IllegalArgumentException ex) {
   289                 return Optional.empty();
   290             }
   291         }
   292         try {
   293             final Constructor<T> ctor = clazz.getConstructor(String.class);
   294             return Optional.of(ctor.newInstance(paramValue));
   295         } catch (ReflectiveOperationException e) {
   296             // does not type check and is not convertible - treat as if the parameter was never set
   297             return Optional.empty();
   298         }
   299     }
   301     /**
   302      * Obtains a request parameter of the specified type.
   303      * The specified type must have a single-argument constructor accepting a string to perform conversion.
   304      * The constructor of the specified type may throw an exception on conversion failures.
   305      *
   306      * @param req   the servlet request object
   307      * @param clazz the class object of the expected type
   308      * @param name  the name of the parameter
   309      * @param <T>   the expected type
   310      * @return the parameter value or an empty optional, if no parameter with the specified name was found
   311      */
   312     protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) {
   313         if (clazz.isArray()) {
   314             final String[] paramValues = req.getParameterValues(name);
   315             int len = paramValues == null ? 0 : paramValues.length;
   316             final var array = (T) Array.newInstance(clazz.getComponentType(), len);
   317             for (int i = 0; i < len; i++) {
   318                 try {
   319                     final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class);
   320                     Array.set(array, i, ctor.newInstance(paramValues[i]));
   321                 } catch (ReflectiveOperationException e) {
   322                     throw new RuntimeException(e);
   323                 }
   324             }
   325             return Optional.of(array);
   326         } else {
   327             return parseParameter(req.getParameter(name), clazz);
   328         }
   329     }
   331     /**
   332      * Tries to look up an entity with a key obtained from a request parameter.
   333      *
   334      * @param req   the servlet request object
   335      * @param clazz the class representing the type of the request parameter
   336      * @param name  the name of the request parameter
   337      * @param find  the find function (typically a DAO function)
   338      * @param <T>   the type of the request parameter
   339      * @param <R>   the type of the looked up entity
   340      * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing
   341      * @throws SQLException if the find function throws an exception
   342      */
   343     protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, Function<? super T, ? extends R> find) {
   344         final var param = getParameter(req, clazz, name);
   345         if (param.isPresent()) {
   346             return Optional.ofNullable(find.apply(param.get()));
   347         } else {
   348             return Optional.empty();
   349         }
   350     }
   352     protected void setAttributeFromParameter(HttpServletRequest req, String name) {
   353         final var parm = req.getParameter(name);
   354         if (parm != null) {
   355             req.setAttribute(name, parm);
   356         }
   357     }
   359     private String sanitizeRequestPath(HttpServletRequest req) {
   360         return Optional.ofNullable(req.getPathInfo()).orElse("/");
   361     }
   363     private Optional<Map.Entry<PathPattern, Method>> findMapping(HttpMethod method, HttpServletRequest req) {
   364         return Optional.ofNullable(mappings.get(method)).flatMap(rm ->
   365                 rm.entrySet().stream().filter(
   366                         kv -> kv.getKey().matches(sanitizeRequestPath(req))
   367                 ).findAny()
   368         );
   369     }
   371     protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
   372         req.getRequestDispatcher(SITE_JSP).forward(req, resp);
   373     }
   375     protected Optional<String[]> availableLanguages() {
   376         return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*"));
   377     }
   379     private static String baseHref(HttpServletRequest req) {
   380         return String.format("%s://%s:%d%s/",
   381                 req.getScheme(),
   382                 req.getServerName(),
   383                 req.getServerPort(),
   384                 req.getContextPath());
   385     }
   387     private static String enforceExt(String filename, String ext) {
   388         return filename.endsWith(ext) ? filename : filename + ext;
   389     }
   391     private static String jspPath(String filename) {
   392         return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp");
   393     }
   395     private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
   396         // the very first thing to do is to force UTF-8
   397         req.setCharacterEncoding("UTF-8");
   399         // choose the requested language as session language (if available) or fall back to english, otherwise
   400         HttpSession session = req.getSession();
   401         if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) {
   402             Optional<List<String>> availableLanguages = availableLanguages().map(Arrays::asList);
   403             Optional<Locale> reqLocale = Optional.of(req.getLocale());
   404             Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH);
   405             session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale);
   406             LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage());
   407         } else {
   408             Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE);
   409             resp.setLocale(sessionLocale);
   410             LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale);
   411         }
   413         // set some internal request attributes
   414         final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse("");
   415         req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req));
   416         req.setAttribute(Constants.REQ_ATTR_PATH, fullPath);
   417         req.setAttribute(Constants.REQ_ATTR_RESOURCE_BUNDLE, getResourceBundleName());
   419         // if this is an error path, bypass the normal flow
   420         if (fullPath.startsWith("/error/")) {
   421             final var mapping = findMapping(method, req);
   422             if (mapping.isPresent()) {
   423                 invokeMapping(mapping.get(), req, resp, null);
   424             }
   425             return;
   426         }
   428         // obtain a connection and create the data access objects
   429         final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME());
   430         final var ds = db.getDataSource();
   431         if (ds == null) {
   432             resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details.");
   433             return;
   434         }
   435         try (final var connection = ds.getConnection()) {
   436             final var dao = createDataAccessObjects(connection);
   437             try {
   438                 connection.setAutoCommit(false);
   439                 // call the handler, if available, or send an HTTP 404 error
   440                 final var mapping = findMapping(method, req);
   441                 if (mapping.isPresent()) {
   442                     invokeMapping(mapping.get(), req, resp, dao);
   443                 } else {
   444                     resp.sendError(HttpServletResponse.SC_NOT_FOUND);
   445                 }
   446                 connection.commit();
   447             } catch (SQLException ex) {
   448                 LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage());
   449                 LOG.debug("Details: ", ex);
   450                 resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode());
   451                 connection.rollback();
   452             }
   453         } catch (SQLException ex) {
   454             LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage());
   455             LOG.debug("Details: ", ex);
   456             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode());
   457         }
   458     }
   460     @Override
   461     protected final void doGet(HttpServletRequest req, HttpServletResponse resp)
   462             throws ServletException, IOException {
   463         doProcess(HttpMethod.GET, req, resp);
   464     }
   466     @Override
   467     protected final void doPost(HttpServletRequest req, HttpServletResponse resp)
   468             throws ServletException, IOException {
   469         doProcess(HttpMethod.POST, req, resp);
   470     }
   471 }

mercurial