migrates the utility classes for the AbstractServlet

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 178
88207b860cba
child 180
009700915269

migrates the utility classes for the AbstractServlet

src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/AbstractServlet.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/HttpMethod.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/PathParameters.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/PathPattern.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/RequestMapping.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/ResourceKey.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/ErrorModule.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/LanguageModule.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/UsersModule.java file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/HttpMethod.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt file | annotate | diff | comparison | revisions
--- a/src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java	Mon Jan 04 17:30:10 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,471 +0,0 @@
-/*
- * 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 AbstractLightPITServlet extends HttpServlet {
-
-    private static final Logger LOG = LoggerFactory.getLogger(AbstractLightPITServlet.class);
-
-    private static final String SITE_JSP = jspPath("site");
-
-    /**
-     * 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<PathPattern, Method>> 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<PathPattern, Method> 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<RequestMapping> 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.
-     * <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 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.
-     * <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 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 <T> Optional<T> parseParameter(String paramValue, Class<T> 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<T> 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 <T>   the expected type
-     * @return the parameter value or an empty optional, if no parameter with the specified name was found
-     */
-    protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> 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 <T>   the type of the request parameter
-     * @param <R>   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 <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> 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<Map.Entry<PathPattern, Method>> 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<String[]> 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<List<String>> availableLanguages = availableLanguages().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 = 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);
-    }
-}
--- /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.
+     * <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<PathPattern, Method>> 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<PathPattern, Method> 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<RequestMapping> 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.
+     * <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 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.
+     * <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 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 <T> Optional<T> parseParameter(String paramValue, Class<T> 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<T> 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 <T>   the expected type
+     * @return the parameter value or an empty optional, if no parameter with the specified name was found
+     */
+    protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> 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 <T>   the type of the request parameter
+     * @param <R>   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 <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> 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<Map.Entry<PathPattern, Method>> 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<String[]> 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<List<String>> availableLanguages = availableLanguages().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 = 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);
+    }
+}
--- a/src/main/java/de/uapcore/lightpit/HttpMethod.java	Mon Jan 04 17:30:10 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,34 +0,0 @@
-/*
- * 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;
-
-
-public enum HttpMethod {
-    GET, POST, PUT, DELETE, TRACE, HEAD, OPTIONS
-}
--- a/src/main/java/de/uapcore/lightpit/PathParameters.java	Mon Jan 04 17:30:10 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-package de.uapcore.lightpit;
-
-import java.util.HashMap;
-
-public class PathParameters extends HashMap<String, String> {
-}
--- a/src/main/java/de/uapcore/lightpit/PathPattern.java	Mon Jan 04 17:30:10 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,125 +0,0 @@
-package de.uapcore.lightpit;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public final class PathPattern {
-
-    private final List<String> nodePatterns;
-    private final boolean collection;
-
-    /**
-     * Constructs a new path pattern.
-     * The special directories . and .. are disallowed in the pattern.
-     *
-     * @param pattern
-     */
-    public PathPattern(String pattern) {
-        nodePatterns = parse(pattern);
-        collection = pattern.endsWith("/");
-    }
-
-    private List<String> parse(String pattern) {
-
-        var nodes = new ArrayList<String>();
-        var parts = pattern.split("/");
-
-        for (var part : parts) {
-            if (part.isBlank()) continue;
-            if (part.equals(".") || part.equals(".."))
-                throw new IllegalArgumentException("Path must not contain '.' or '..' nodes.");
-            nodes.add(part);
-        }
-
-        return nodes;
-    }
-
-    /**
-     * Matches a path against this pattern.
-     * The path must be canonical in the sense that no . or .. parts occur.
-     *
-     * @param path the path to match
-     * @return true if the path matches the pattern, false otherwise
-     */
-    public boolean matches(String path) {
-        if (collection ^ path.endsWith("/"))
-            return false;
-
-        var nodes = parse(path);
-        if (nodePatterns.size() != nodes.size())
-            return false;
-
-        for (int i = 0 ; i < nodePatterns.size() ; i++) {
-            var pattern = nodePatterns.get(i);
-            var node = nodes.get(i);
-            if (pattern.startsWith("$"))
-                continue;
-            if (!pattern.equals(node))
-                return false;
-        }
-
-        return true;
-    }
-
-    /**
-     * Returns the path parameters found in the specified path using this pattern.
-     * The return value of this method is undefined, if the patter does not match.
-     *
-     * @param path the path
-     * @return the path parameters, if any, or an empty map
-     * @see #matches(String)
-     */
-    public PathParameters obtainPathParameters(String path) {
-        var params = new PathParameters();
-
-        var nodes = parse(path);
-
-        for (int i = 0 ; i < Math.min(nodes.size(), nodePatterns.size()) ; i++) {
-            var pattern = nodePatterns.get(i);
-            var node = nodes.get(i);
-            if (pattern.startsWith("$")) {
-                params.put(pattern.substring(1), node);
-            }
-        }
-
-        return params;
-    }
-
-    @Override
-    public int hashCode() {
-        var str = new StringBuilder();
-        for (var node : nodePatterns) {
-            if (node.startsWith("$")) {
-                str.append("/$");
-            } else {
-                str.append('/');
-                str.append(node);
-            }
-        }
-        if (collection)
-            str.append('/');
-
-        return str.toString().hashCode();
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-        if (!obj.getClass().equals(PathPattern.class))
-            return false;
-
-        var other = (PathPattern) obj;
-        if (collection ^ other.collection || nodePatterns.size() != other.nodePatterns.size())
-            return false;
-
-        for (int i = 0 ; i < nodePatterns.size() ; i++) {
-            var left = nodePatterns.get(i);
-            var right = other.nodePatterns.get(i);
-            if (left.startsWith("$") && right.startsWith("$"))
-                continue;
-            if (!left.equals(right))
-                return false;
-        }
-
-        return true;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/RequestMapping.java	Mon Jan 04 17:30:10 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-/*
- * 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 java.lang.annotation.*;
-
-
-/**
- * Maps requests to methods.
- * <p>
- * This annotation is used to annotate methods within classes which
- * override {@link AbstractLightPITServlet}.
- */
-@Documented
-@Retention(RetentionPolicy.RUNTIME)
-@Target(ElementType.METHOD)
-public @interface RequestMapping {
-
-    /**
-     * Specifies the HTTP method.
-     *
-     * @return the HTTP method handled by the annotated Java method
-     */
-    HttpMethod method();
-
-    /**
-     * Specifies the request path relative to the module path.
-     * The trailing slash is important.
-     * A node may start with a dollar ($) sign.
-     * This part of the path is then treated as an path parameter.
-     * Path parameters can be obtained by including the {@link PathParameters} interface in the signature.
-     *
-     * @return the request path the annotated method should handle
-     */
-    String requestPath() default "/";
-}
--- a/src/main/java/de/uapcore/lightpit/ResourceKey.java	Mon Jan 04 17:30:10 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,74 +0,0 @@
-/*
- * 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 java.util.Objects;
-
-/**
- * Fully specifies a resource key by the bundle and the key name.
- */
-public final class ResourceKey {
-    private String bundle;
-    private String key;
-
-    public ResourceKey(String bundle, String key) {
-        this.bundle = bundle;
-        this.key = key;
-    }
-
-    public void setBundle(String bundle) {
-        this.bundle = bundle;
-    }
-
-    public String getBundle() {
-        return bundle;
-    }
-
-    public void setKey(String key) {
-        this.key = key;
-    }
-
-    public String getKey() {
-        return key;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-        if (this == o) return true;
-        if (o == null || getClass() != o.getClass()) return false;
-        ResourceKey that = (ResourceKey) o;
-        return bundle.equals(that.bundle) &&
-                key.equals(that.key);
-    }
-
-    @Override
-    public int hashCode() {
-        return Objects.hash(bundle, key);
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/modules/ErrorModule.java	Mon Jan 04 17:30:10 2021 +0100
+++ b/src/main/java/de/uapcore/lightpit/modules/ErrorModule.java	Tue Jan 05 19:19:31 2021 +0100
@@ -28,7 +28,7 @@
  */
 package de.uapcore.lightpit.modules;
 
-import de.uapcore.lightpit.AbstractLightPITServlet;
+import de.uapcore.lightpit.AbstractServlet;
 import de.uapcore.lightpit.HttpMethod;
 import de.uapcore.lightpit.RequestMapping;
 
@@ -43,7 +43,7 @@
         name = "ErrorModule",
         urlPatterns = "/error/*"
 )
-public final class ErrorModule extends AbstractLightPITServlet {
+public final class ErrorModule extends AbstractServlet {
 
     public static final String REQ_ATTR_RETURN_LINK = "returnLink";
 
--- a/src/main/java/de/uapcore/lightpit/modules/LanguageModule.java	Mon Jan 04 17:30:10 2021 +0100
+++ b/src/main/java/de/uapcore/lightpit/modules/LanguageModule.java	Tue Jan 05 19:19:31 2021 +0100
@@ -28,7 +28,7 @@
  */
 package de.uapcore.lightpit.modules;
 
-import de.uapcore.lightpit.AbstractLightPITServlet;
+import de.uapcore.lightpit.AbstractServlet;
 import de.uapcore.lightpit.Constants;
 import de.uapcore.lightpit.HttpMethod;
 import de.uapcore.lightpit.RequestMapping;
@@ -47,7 +47,7 @@
         name = "LanguageModule",
         urlPatterns = "/language/*"
 )
-public final class LanguageModule extends AbstractLightPITServlet {
+public final class LanguageModule extends AbstractServlet {
 
     private static final Logger LOG = LoggerFactory.getLogger(LanguageModule.class);
 
--- a/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Mon Jan 04 17:30:10 2021 +0100
+++ b/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Tue Jan 05 19:19:31 2021 +0100
@@ -61,7 +61,7 @@
         name = "ProjectsModule",
         urlPatterns = "/projects/*"
 )
-public final class ProjectsModule extends AbstractLightPITServlet {
+public final class ProjectsModule extends AbstractServlet {
 
     private static final Logger LOG = LoggerFactory.getLogger(ProjectsModule.class);
 
--- a/src/main/java/de/uapcore/lightpit/modules/UsersModule.java	Mon Jan 04 17:30:10 2021 +0100
+++ b/src/main/java/de/uapcore/lightpit/modules/UsersModule.java	Tue Jan 05 19:19:31 2021 +0100
@@ -28,7 +28,7 @@
  */
 package de.uapcore.lightpit.modules;
 
-import de.uapcore.lightpit.AbstractLightPITServlet;
+import de.uapcore.lightpit.AbstractServlet;
 import de.uapcore.lightpit.Constants;
 import de.uapcore.lightpit.HttpMethod;
 import de.uapcore.lightpit.RequestMapping;
@@ -51,7 +51,7 @@
         name = "UsersModule",
         urlPatterns = "/teams/*"
 )
-public final class UsersModule extends AbstractLightPITServlet {
+public final class UsersModule extends AbstractServlet {
 
     private static final Logger LOG = LoggerFactory.getLogger(UsersModule.class);
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/HttpMethod.kt	Tue Jan 05 19:19:31 2021 +0100
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021 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
+
+enum class HttpMethod {
+    GET, POST, PUT, DELETE, TRACE, HEAD, OPTIONS
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt	Tue Jan 05 19:19:31 2021 +0100
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2021 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 kotlin.math.min
+
+/**
+ * Maps requests to methods.
+ *
+ * This annotation is used to annotate methods within classes which
+ * override [AbstractServlet].
+ */
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FUNCTION)
+annotation class RequestMapping(
+
+    /**
+     * Specifies the HTTP method.
+     *
+     * @return the HTTP method handled by the annotated Java method
+     */
+    val method: HttpMethod,
+
+    /**
+     * Specifies the request path relative to the module path.
+     * The trailing slash is important.
+     * A node may start with a dollar ($) sign.
+     * This part of the path is then treated as an path parameter.
+     * Path parameters can be obtained by including the [PathParameters] type in the signature.
+     *
+     * @return the request path the annotated method should handle
+     */
+    val requestPath: String = "/"
+)
+
+class PathParameters : HashMap<String, String>()
+
+/**
+ * A path pattern optionally containing placeholders.
+ *
+ * The special directories . and .. are disallowed in the pattern.
+ * Placeholders start with a $ sign.
+ *
+ * @param pattern the pattern
+ */
+class PathPattern(pattern: String) {
+    private val nodePatterns: List<String>
+    private val collection: Boolean
+
+    private fun parse(pattern: String): List<String> {
+        val nodes = pattern.split("/").filter { it.isNotBlank() }.toList()
+        require(nodes.none { it == "." || it == ".." }) { "Path must not contain '.' or '..' nodes." }
+        return nodes
+    }
+
+    /**
+     * Matches a path against this pattern.
+     * The path must be canonical in the sense that no . or .. parts occur.
+     *
+     * @param path the path to match
+     * @return true if the path matches the pattern, false otherwise
+     */
+    fun matches(path: String): Boolean {
+        if (collection xor path.endsWith("/")) return false
+        val nodes = parse(path)
+        if (nodePatterns.size != nodes.size) return false
+        for (i in nodePatterns.indices) {
+            val pattern = nodePatterns[i]
+            val node = nodes[i]
+            if (pattern.startsWith("$")) continue
+            if (pattern != node) return false
+        }
+        return true
+    }
+
+    /**
+     * Returns the path parameters found in the specified path using this pattern.
+     * The return value of this method is undefined, if the patter does not match.
+     *
+     * @param path the path
+     * @return the path parameters, if any, or an empty map
+     * @see .matches
+     */
+    fun obtainPathParameters(path: String): PathParameters {
+        val params = PathParameters()
+        val nodes = parse(path)
+        for (i in 0 until min(nodes.size, nodePatterns.size)) {
+            val pattern = nodePatterns[i]
+            val node = nodes[i]
+            if (pattern.startsWith("$")) {
+                params[pattern.substring(1)] = node
+            }
+        }
+        return params
+    }
+
+    override fun hashCode(): Int {
+        val str = StringBuilder()
+        for (node in nodePatterns) {
+            if (node.startsWith("$")) {
+                str.append("/$")
+            } else {
+                str.append('/')
+                str.append(node)
+            }
+        }
+        if (collection) str.append('/')
+        return str.toString().hashCode()
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (other is PathPattern) {
+            if (collection xor other.collection || nodePatterns.size != other.nodePatterns.size) return false
+            for (i in nodePatterns.indices) {
+                val left = nodePatterns[i]
+                val right = other.nodePatterns[i]
+                if (left.startsWith("$") && right.startsWith("$")) continue
+                if (left != right) return false
+            }
+            return true
+        } else {
+            return false
+        }
+    }
+
+    init {
+        nodePatterns = parse(pattern)
+        collection = pattern.endsWith("/")
+    }
+}
+

mercurial