# HG changeset patch # User Mike Becker # Date 1609870771 -3600 # Node ID 623c340058f317581838b0d1fa57973a7be1daa2 # Parent 88207b860cba363f91a122548aac991bc41fb613 migrates the utility classes for the AbstractServlet diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java --- 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. - *

- * Paths in this map must always start with a leading slash, although - * the specification in the annotation must not start with a leading slash. - *

- * The reason for this is the different handling of empty paths in - * {@link HttpServletRequest#getPathInfo()}. - */ - private final Map> 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 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 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. - *

- * 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. - *

- * 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 Optional parseParameter(String paramValue, Class 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 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 the expected type - * @return the parameter value or an empty optional, if no parameter with the specified name was found - */ - protected Optional getParameter(HttpServletRequest req, Class 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 the type of the request parameter - * @param 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 Optional findByParameter(HttpServletRequest req, Class clazz, String name, Function 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> 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 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> availableLanguages = availableLanguages().map(Arrays::asList); - Optional 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); - } -} diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/AbstractServlet.java --- /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. + *

+ * Paths in this map must always start with a leading slash, although + * the specification in the annotation must not start with a leading slash. + *

+ * The reason for this is the different handling of empty paths in + * {@link HttpServletRequest#getPathInfo()}. + */ + private final Map> 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 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 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. + *

+ * 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. + *

+ * 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 Optional parseParameter(String paramValue, Class 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 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 the expected type + * @return the parameter value or an empty optional, if no parameter with the specified name was found + */ + protected Optional getParameter(HttpServletRequest req, Class 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 the type of the request parameter + * @param 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 Optional findByParameter(HttpServletRequest req, Class clazz, String name, Function 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> 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 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> availableLanguages = availableLanguages().map(Arrays::asList); + Optional 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); + } +} diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/HttpMethod.java --- 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 -} diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/PathParameters.java --- 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 { -} diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/PathPattern.java --- 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 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 parse(String pattern) { - - var nodes = new ArrayList(); - 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; - } -} diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/RequestMapping.java --- 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. - *

- * 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 "/"; -} diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/ResourceKey.java --- 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); - } -} diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/modules/ErrorModule.java --- 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"; diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/modules/LanguageModule.java --- 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); diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java --- 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); diff -r 88207b860cba -r 623c340058f3 src/main/java/de/uapcore/lightpit/modules/UsersModule.java --- 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); diff -r 88207b860cba -r 623c340058f3 src/main/kotlin/de/uapcore/lightpit/HttpMethod.kt --- /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 +} diff -r 88207b860cba -r 623c340058f3 src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt --- /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() + +/** + * 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 + private val collection: Boolean + + private fun parse(pattern: String): List { + 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("/") + } +} +