Thu, 15 Oct 2020 18:36:05 +0200
adds the possibility to specify path parameters to RequestMapping
--- a/src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java Thu Oct 15 14:01:49 2020 +0200 +++ b/src/main/java/de/uapcore/lightpit/AbstractLightPITServlet.java Thu Oct 15 18:36:05 2020 +0200 @@ -84,7 +84,7 @@ * The reason for this is the different handling of empty paths in * {@link HttpServletRequest#getPathInfo()}. */ - private final Map<HttpMethod, Map<String, Method>> mappings = new HashMap<>(); + private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>(); /** * Returns the name of the resource bundle associated with this servlet. @@ -108,7 +108,9 @@ throw new AssertionError("Non-exhaustive if-else - this is a bug."); } - private ResponseType invokeMapping(Method method, HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws IOException { + private ResponseType invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DataAccessObjects 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(); @@ -122,6 +124,9 @@ if (paramTypes[i].isAssignableFrom(DataAccessObjects.class)) { paramValues[i] = dao; } + if (paramTypes[i].isAssignableFrom(PathParameters.class)) { + paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req)); + } } return (ResponseType) method.invoke(this, paramValues); } catch (InvocationTargetException ex) { @@ -152,6 +157,13 @@ 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() @@ -175,28 +187,35 @@ for (var param : method.getParameterTypes()) { paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param) || HttpServletResponse.class.isAssignableFrom(param) + || PathParameters.class.isAssignableFrom(param) || DataAccessObjects.class.isAssignableFrom(param); } if (paramsInjectible) { - String requestPath = "/" + mapping.get().requestPath(); + try { + PathPattern pathPattern = new PathPattern(mapping.get().requestPath()); - if (mappings - .computeIfAbsent(mapping.get().method(), k -> new HashMap<>()) - .putIfAbsent(requestPath, method) != null) { - LOG.warn("{} {} has multiple mappings", + if (mappings + .computeIfAbsent(mapping.get().method(), k -> new HashMap<>()) + .putIfAbsent(pathPattern, method) != null) { + LOG.warn("{} {} has multiple mappings", + mapping.get().method(), + mapping.get().requestPath() + ); + } + + LOG.debug("{} {} maps to {}::{}", mapping.get().method(), - mapping.get().requestPath() + 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() ); } - - LOG.debug("{} {} maps to {}::{}", - mapping.get().method(), - requestPath, - getClass().getSimpleName(), - method.getName() - ); } else { - LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest. HttpServletResponse, and DataAccessObjects are allowed", + LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed", method.getName(), RequestMapping.class.getSimpleName() ); } @@ -373,8 +392,12 @@ return Optional.ofNullable(req.getPathInfo()).orElse("/"); } - private Optional<Method> findMapping(HttpMethod method, HttpServletRequest req) { - return Optional.ofNullable(mappings.get(method)).map(rm -> rm.get(sanitizeRequestPath(req))); + private 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() + ); } private void forwardAsSpecified(ResponseType type, HttpServletRequest req, HttpServletResponse resp)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/java/de/uapcore/lightpit/PathParameters.java Thu Oct 15 18:36:05 2020 +0200 @@ -0,0 +1,6 @@ +package de.uapcore.lightpit; + +import java.util.HashMap; + +public class PathParameters extends HashMap<String, String> { +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/java/de/uapcore/lightpit/PathPattern.java Thu Oct 15 18:36:05 2020 +0200 @@ -0,0 +1,125 @@ +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 Thu Oct 15 14:01:49 2020 +0200 +++ b/src/main/java/de/uapcore/lightpit/RequestMapping.java Thu Oct 15 18:36:05 2020 +0200 @@ -51,10 +51,12 @@ /** * Specifies the request path relative to the module path. - * The path must be specified <em>without</em> leading slash, but may have a trailing slash. - * Requests will only be handled if the path exactly matches. + * 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 ""; + String requestPath() default "/"; }