/*
 * Copyright 2014 Red Hat, Inc.
 *
 *  All rights reserved. This program and the accompanying materials
 *  are made available under the terms of the Eclipse Public License v1.0
 *  and Apache License v2.0 which accompanies this distribution.
 *
 *  The Eclipse Public License is available at
 *  http://www.eclipse.org/legal/epl-v10.html
 *
 *  The Apache License v2.0 is available at
 *  http://www.opensource.org/licenses/apache2.0.php
 *
 *  You may elect to redistribute this code under either of these licenses.
 */

package io.vertx.ext.web.impl;

import io.vertx.core.Handler;
import io.vertx.core.http.HttpMethod;
import io.vertx.ext.web.Route;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;

import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

This class is thread-safe

Some parts (e.g. content negotiation) from Yoke by Paulo Lopes

Author:Tim Fox, Paulo Lopes
/** * This class is thread-safe * <p> * Some parts (e.g. content negotiation) from Yoke by Paulo Lopes * * @author <a href="http://tfox.org">Tim Fox</a> * @author <a href="http://pmlopes@gmail.com">Paulo Lopes</a> */
public class RouteImpl implements Route { private final RouterImpl router; private volatile RouteState state; RouteImpl(RouterImpl router, int order) { this.router = router; this.state = new RouteState(this, order); } RouteImpl(RouterImpl router, int order, String path) { this(router, order); checkPath(path); setPath(path); } RouteImpl(RouterImpl router, int order, HttpMethod method, String path) { this(router, order); method(method); checkPath(path); setPath(path); } RouteImpl(RouterImpl router, int order, String regex, boolean bregex) { this(router, order); setRegex(regex); } RouteImpl(RouterImpl router, int order, HttpMethod method, String regex, boolean bregex) { this(router, order); method(method); setRegex(regex); } RouteState state() { return state; } @Override public synchronized Route method(HttpMethod method) { state = state.addMethod(method); return this; } @Override public Route path(String path) { checkPath(path); setPath(path); return this; } @Override public Route pathRegex(String regex) { setRegex(regex); return this; } @Override public synchronized Route produces(String contentType) { state = state.addProduce(new ParsableMIMEValue(contentType).forceParse()); return this; } @Override public synchronized Route consumes(String contentType) { state = state.addConsume(new ParsableMIMEValue(contentType).forceParse()); return this; } @Override public synchronized Route virtualHost(String hostnamePattern) { state = state .setVirtualHostPattern( Pattern.compile( hostnamePattern .replaceAll("\\.", "\\\\.") .replaceAll("[*]", "(.*?)"), Pattern.CASE_INSENSITIVE)); return this; } @Override public synchronized Route order(int order) { if (state.isAdded()) { throw new IllegalStateException("Can't change order after route is active"); } state = state.setOrder(order); return this; } @Override public Route last() { return order(Integer.MAX_VALUE); } @Override public synchronized Route handler(Handler<RoutingContext> contextHandler) { if (state.isExclusive()) { throw new IllegalStateException("This Route is exclusive for already mounted sub router."); } state = state.addContextHandler(contextHandler); checkAdd(); return this; } @Override public Route blockingHandler(Handler<RoutingContext> contextHandler) { return blockingHandler(contextHandler, true); } @Override public synchronized Route subRouter(Router subRouter) { // The route path must end with a wild card if (state.isExactPath()) { throw new IllegalStateException("Sub router cannot be mounted on an exact path."); } // Parameters are allowed but full regex patterns not if (state.getPath() == null && state.getPattern() != null) { throw new IllegalStateException("Sub router cannot be mounted on a regular expression path."); } // No other handler can be registered before or after this call (but they can on a new route object for the same path) if (state.getContextHandlersLength() > 0 || state.getFailureHandlersLength() > 0) { throw new IllegalStateException("Only one sub router per Route object is allowed."); } handler(subRouter::handleContext); failureHandler(subRouter::handleFailure); subRouter.modifiedHandler(this::validateMount); // trigger a validation validateMount(subRouter); // mark the route as exclusive from now on this.state = state.setExclusive(true); return this; } @Override public Route blockingHandler(Handler<RoutingContext> contextHandler, boolean ordered) { return handler(new BlockingHandlerDecorator(contextHandler, ordered)); } @Override public synchronized Route failureHandler(Handler<RoutingContext> exceptionHandler) { if (state.isExclusive()) { throw new IllegalStateException("This Route is exclusive for already mounted sub router."); } state = state.addFailureHandler(exceptionHandler); checkAdd(); return this; } @Override public Route remove() { router.remove(this); return this; } @Override public synchronized Route disable() { state = state.setEnabled(false); return this; } @Override public synchronized Route enable() { state = state.setEnabled(true); return this; } @Override public synchronized Route useNormalizedPath(boolean useNormalizedPath) { state = state.setUseNormalizedPath(useNormalizedPath); return this; } @Override public String getPath() { return state.getPath(); } @Override public boolean isRegexPath() { return state.getPattern() != null; } @Override public Set<HttpMethod> methods() { return state.getMethods(); } @Override public synchronized Route setRegexGroupsNames(List<String> groups) { state = state.setGroups(groups); return this; } @Override public synchronized Route setName(String name) { state = state.setName(name); return this; } @Override public String getName() { return state.getName(); } @Override public String toString() { return "RouteImpl@" + System.identityHashCode(this) + "{" + "state=" + state + '}'; } RouterImpl router() { return router; } private synchronized void setPath(String path) { // See if the path contains ":" - if so then it contains parameter capture groups and we have to generate // a regex for that if (path.charAt(path.length() - 1) != '*') { state = state.setExactPath(true); state = state.setPath(path); } else { state = state.setExactPath(false); state = state.setPath(path.substring(0, path.length() - 1)); } if (path.indexOf(':') != -1) { createPatternRegex(path); } state = state.setPathEndsWithSlash(state.getPath().endsWith("/")); } private synchronized void setRegex(String regex) { state = state.setPattern(Pattern.compile(regex)); state = state.setExactPath(true); findNamedGroups(state.getPattern().pattern()); } private synchronized void findNamedGroups(String path) { Matcher m = Pattern.compile("\\(\\?<([a-zA-Z][a-zA-Z0-9]*)>").matcher(path); while (m.find()) { state = state.addNamedGroupInRegex(m.group(1)); } } // intersection of regex chars and https://tools.ietf.org/html/rfc3986#section-3.3 private static final Pattern RE_OPERATORS_NO_STAR = Pattern.compile("([\\(\\)\\$\\+\\.])"); // Pattern for :<token name> in path private static final Pattern RE_TOKEN_SEARCH = Pattern.compile(":([A-Za-z][A-Za-z0-9_]*)"); private synchronized void createPatternRegex(String path) { // escape path from any regex special chars path = RE_OPERATORS_NO_STAR.matcher(path).replaceAll("\\\\$1"); // allow usage of * at the end as per documentation if (path.charAt(path.length() - 1) == '*') { path = path.substring(0, path.length() - 1) + "(?<rest>.*)"; state = state.setExactPath(false); } else { state = state.setExactPath(true); } // We need to search for any :<token name> tokens in the String and replace them with named capture groups Matcher m = RE_TOKEN_SEARCH.matcher(path); StringBuffer sb = new StringBuffer(); List<String> groups = new ArrayList<>(); int index = 0; while (m.find()) { String param = "p" + index; String group = m.group().substring(1); if (groups.contains(group)) { throw new IllegalArgumentException("Cannot use identifier " + group + " more than once in pattern string"); } m.appendReplacement(sb, "(?<" + param + ">[^/]+)"); groups.add(group); index++; } m.appendTail(sb); path = sb.toString(); state = state.setGroups(groups); state = state.setPattern(Pattern.compile(path)); } private void checkPath(String path) { if ("".equals(path) || path.charAt(0) != '/') { throw new IllegalArgumentException("Path must start with /"); } } int order() { return state.getOrder(); } private synchronized void checkAdd() { if (!state.isAdded()) { router.add(this); state = state.setAdded(true); } } public synchronized RouteImpl setEmptyBodyPermittedWithConsumes(boolean emptyBodyPermittedWithConsumes) { state = state.setEmptyBodyPermittedWithConsumes(emptyBodyPermittedWithConsumes); return this; } private void validateMount(Router router) { for (Route route : router.getRoutes()) { final String combinedPath; if (route.getPath() == null) { // This is a router with pattern and not path // we cannot validate continue; } // this method is similar to what the pattern generation does but // it will not generate a pattern, it will only verify if the paths do not contain // colliding parameter names with the mount path // escape path from any regex special chars combinedPath = RE_OPERATORS_NO_STAR .matcher(state.getPath() + (state.isPathEndsWithSlash() ? route.getPath().substring(1) : route.getPath())) .replaceAll("\\\\$1"); // We need to search for any :<token name> tokens in the String Matcher m = RE_TOKEN_SEARCH.matcher(combinedPath); Set<String> groups = new HashSet<>(); while (m.find()) { String group = m.group(); if (groups.contains(group)) { throw new IllegalStateException("Cannot use identifier " + group + " more than once in pattern string"); } groups.add(group); } } } }