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

/*
 * 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.DataAccessObjects;
import de.uapcore.lightpit.dao.postgres.PGDataAccessObjects;
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.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.*;

/**
 * A special implementation of a HTTPServlet which is focused on implementing
 * the necessary functionality for {@link LightPITModule}s.
 */
public abstract class AbstractLightPITServlet extends HttpServlet {

    private static final Logger LOG = LoggerFactory.getLogger(AbstractLightPITServlet.class);

    private static final String SITE_JSP = Functions.jspPath("site");

    /**
     * The EL proxy is necessary, because the EL resolver cannot handle annotation properties.
     */
    private LightPITModule.ELProxy moduleInfo = null;

    /**
     * Invocation mapping gathered from the {@link RequestMapping} annotations.
     * <p>
     * Paths in this map must always start with a leading slash, although
     * the specification in the annotation must not start with a leading slash.
     * <p>
     * The reason for this is the different handling of empty paths in
     * {@link HttpServletRequest#getPathInfo()}.
     */
    private final Map<HttpMethod, Map<String, Method>> mappings = new HashMap<>();

    private final List<MenuEntry> subMenu = new ArrayList<>();

    /**
     * Gives implementing modules access to the {@link ModuleManager}.
     *
     * @return the module manager
     */
    protected final ModuleManager getModuleManager() {
        return (ModuleManager) getServletContext().getAttribute(ModuleManager.SC_ATTR_NAME);
    }


    /**
     * Creates a set of data access objects for the specified connection.
     *
     * @param connection the SQL connection
     * @return a set of data access objects
     */
    private DataAccessObjects createDataAccessObjects(Connection connection) throws SQLException {
        final var df = (DatabaseFacade) getServletContext().getAttribute(DatabaseFacade.SC_ATTR_NAME);
        if (df.getSQLDialect() == DatabaseFacade.Dialect.Postgres) {
            return new PGDataAccessObjects(connection);
        }
        throw new AssertionError("Non-exhaustive if-else - this is a bug.");
    }

    private ResponseType invokeMapping(Method method, HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException {
        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(DataAccessObjects.class)) {
                    paramValues[i] = dao;
                }
            }
            return (ResponseType) method.invoke(this, paramValues);
        } catch (ReflectiveOperationException | ClassCastException ex) {
            LOG.error("invocation of method {} failed: {}", method.getName(), ex.getMessage());
            LOG.debug("Details: ", ex);
            resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            return ResponseType.NONE;
        }
    }

    @Override
    public void init() throws ServletException {
        moduleInfo = Optional.ofNullable(this.getClass().getAnnotation(LightPITModule.class))
                .map(LightPITModule.ELProxy::new).orElse(null);

        if (moduleInfo != null) {
            scanForRequestMappings();
        }

        LOG.trace("{} initialized", getServletName());
    }

    private void scanForRequestMappings() {
        try {
            Method[] methods = getClass().getDeclaredMethods();
            for (Method method : methods) {
                Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class));
                if (mapping.isPresent()) {
                    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;
                    }
                    if (!ResponseType.class.isAssignableFrom(method.getReturnType())) {
                        LOG.warn("{} is annotated with {} but has the wrong return type - 'ResponseType' required",
                                method.getName(), RequestMapping.class.getSimpleName()
                        );
                        continue;
                    }

                    boolean paramsInjectible = true;
                    for (var param : method.getParameterTypes()) {
                        paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param)
                                || HttpServletResponse.class.isAssignableFrom(param)
                                || DataAccessObjects.class.isAssignableFrom(param);
                    }
                    if (paramsInjectible) {
                        final String requestPath = "/" + mapping.get().requestPath();

                        if (mappings
                                .computeIfAbsent(mapping.get().method(), k -> new HashMap<>())
                                .putIfAbsent(requestPath, method) != null) {
                            LOG.warn("{} {} has multiple mappings",
                                    mapping.get().method(),
                                    mapping.get().requestPath()
                            );
                        }

                        final var menuKey = mapping.get().menuKey();
                        if (!menuKey.isBlank()) {
                            subMenu.add(new MenuEntry(
                                    new ResourceKey(moduleInfo.getBundleBaseName(), menuKey),
                                    moduleInfo.getModulePath() + requestPath,
                                    mapping.get().menuSequence()));
                        }

                        LOG.debug("{} {} maps to {}::{}",
                                mapping.get().method(),
                                requestPath,
                                getClass().getSimpleName(),
                                method.getName()
                        );
                    } else {
                        LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest. HttpServletResponse, 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 dynamic fragment.
     * <p>
     * It is sufficient to specify the name without any extension. The extension
     * is added automatically if not specified.
     * <p>
     * The fragment must be located in the dynamic fragments folder.
     *
     * @param req          the servlet request object
     * @param fragmentName the name of the fragment
     * @see Constants#DYN_FRAGMENT_PATH_PREFIX
     */
    public void setDynamicFragment(HttpServletRequest req, String fragmentName) {
        req.setAttribute(Constants.REQ_ATTR_FRAGMENT, Functions.dynFragmentPath(fragmentName));
    }

    /**
     * @param req      the servlet request object
     * @param location the location where to redirect
     * @see Constants#REQ_ATTR_REDIRECT_LOCATION
     */
    public void setRedirectLocation(HttpServletRequest req, String location) {
        if (location.startsWith("./")) {
            location = location.replaceFirst("\\./", Functions.baseHref(req));
        }
        req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location);
    }

    /**
     * Specifies the name of an additional stylesheet used by the module.
     * <p>
     * Setting an additional stylesheet is optional, but quite common for HTML
     * output.
     * <p>
     * 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 stylesheet the name of the stylesheet
     */
    public void setStylesheet(HttpServletRequest req, String stylesheet) {
        req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Functions.enforceExt(stylesheet, ".css"));
    }

    /**
     * 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 <T> the expected type
     * @return the parameter value or an empty optional, if no parameter with the specified name was found
     */
    public<T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) {
        final String paramValue = req.getParameter(name);
        if (paramValue == null) return Optional.empty();
        if (clazz.equals(String.class)) return Optional.of((T)paramValue);
        try {
            final Constructor<T> ctor = clazz.getConstructor(String.class);
            return Optional.of(ctor.newInstance(paramValue));
        } catch (ReflectiveOperationException e) {
            throw new RuntimeException(e);
        }

    }

    private void forwardToFullView(HttpServletRequest req, HttpServletResponse resp)
            throws IOException, ServletException {

        req.setAttribute(Constants.REQ_ATTR_MENU, getModuleManager().getMainMenu());
        req.setAttribute(Constants.REQ_ATTR_SUB_MENU, subMenu);
        req.getRequestDispatcher(SITE_JSP).forward(req, resp);
    }

    private String sanitizeRequestPath(HttpServletRequest req) {
        return Optional.ofNullable(req.getPathInfo()).orElse("/");
    }

    private Optional<Method> findMapping(HttpMethod method, HttpServletRequest req) {
        return Optional.ofNullable(mappings.get(method)).map(rm -> rm.get(sanitizeRequestPath(req)));
    }

    private void forwardAsSpecified(ResponseType type, HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        switch (type) {
            case NONE:
                return;
            case HTML:
                forwardToFullView(req, resp);
                return;
            // TODO: implement remaining response types
            default:
                throw new AssertionError("ResponseType switch is not exhaustive - this is a bug!");
        }
    }

    private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        // 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<List<String>> availableLanguages = Functions.availableLanguages(getServletContext()).map(Arrays::asList);
            Optional<Locale> 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 = Functions.fullPath(req);
        req.setAttribute(Constants.REQ_ATTR_BASE_HREF, Functions.baseHref(req));
        req.setAttribute(Constants.REQ_ATTR_PATH, fullPath);
        Optional.ofNullable(moduleInfo).ifPresent((proxy) -> req.setAttribute(Constants.REQ_ATTR_MODULE_INFO, proxy));

        // if this is an error path, bypass the normal flow
        if (fullPath.startsWith("/error/")) {
            final var mapping = findMapping(method, req);
            if (mapping.isPresent()) {
                forwardAsSpecified(invokeMapping(mapping.get(), req, resp, null), req, resp);
            }
            return;
        }

        // obtain a connection and create the data access objects
        final var db = (DatabaseFacade) req.getServletContext().getAttribute(DatabaseFacade.SC_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()) {
                    forwardAsSpecified(invokeMapping(mapping.get(), req, resp, dao), req, resp);
                } 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);
    }
}

mercurial