/* * #%L * Wisdom-Framework * %% * Copyright (C) 2013 - 2014 Wisdom Framework * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * #L% */ package org.wisdom.router; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.net.MediaType; import org.apache.felix.ipojo.annotations.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.wisdom.api.Controller; import org.wisdom.api.content.ParameterFactories; import org.wisdom.api.http.HttpMethod; import org.wisdom.api.http.Request; import org.wisdom.api.http.Status; import org.wisdom.api.interception.Filter; import org.wisdom.api.interception.Interceptor; import org.wisdom.api.router.AbstractRouter; import org.wisdom.api.router.Route; import org.wisdom.api.router.RouteUtils; import org.wisdom.api.router.RoutingException; import javax.validation.Validator; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.*; import java.util.stream.Collectors; /** * The request router responsible for handling request and invoke the action methods. */ @Component @Provides @Instantiate(name = "router") public class RequestRouter extends AbstractRouter { private static final Logger LOGGER = LoggerFactory.getLogger(RequestRouter.class); private static final Map<String, String> PERCENT_ENCODING_MAP = new TreeMap<>(); static { // Reserved characters. PERCENT_ENCODING_MAP.put("/", "%2F"); // Common characters PERCENT_ENCODING_MAP.put(" ", "%20"); PERCENT_ENCODING_MAP.put("\"", "%22"); PERCENT_ENCODING_MAP.put("%", "%25"); PERCENT_ENCODING_MAP.put("-", "%2D"); PERCENT_ENCODING_MAP.put("<", "%3C"); PERCENT_ENCODING_MAP.put(">", "%3E"); PERCENT_ENCODING_MAP.put("\\", "%5C"); PERCENT_ENCODING_MAP.put("ˆ", "%5E"); PERCENT_ENCODING_MAP.put("_", "%5F"); PERCENT_ENCODING_MAP.put("`", "%60"); PERCENT_ENCODING_MAP.put("{", "%7B"); PERCENT_ENCODING_MAP.put("|", "%7C"); PERCENT_ENCODING_MAP.put("}", "%7D"); // New line PERCENT_ENCODING_MAP.put("\n", "%0A"); } /** * The comparator used to sort filters. */ private Set<Filter> filters = new FilterSet(); @Requires(optional = true, specification = Interceptor.class) private List<Interceptor<?>> interceptors; @Requires(optional = true, proxy = false) private Validator validator; @Requires(optional = true) private ParameterFactories engine; private Set<RouteDelegate> routes = new LinkedHashSet<>(); /** * Binds a new controller. * * @param controller the controller */ @Bind(aggregate = true, optional = true) public synchronized void bindController(Controller controller) { LOGGER.info("Adding routes from " + controller); List<Route> newRoutes = new ArrayList<>(); try { List<Route> annotatedNewRoutes = RouteUtils.collectRouteFromControllerAnnotations(controller); newRoutes.addAll(annotatedNewRoutes); newRoutes.addAll(controller.routes()); //check if these new routes don't pre-exist ensureNoConflicts(newRoutes); } catch (RoutingException e) { LOGGER.error("The controller {} declares routes conflicting with existing routes, " + "the controller is ignored, reason: {}", controller, e.getMessage(), e); // remove all new routes as one has failed routes.removeAll(newRoutes); //NOSONAR } catch (Exception e) { LOGGER.error("The controller {} declares invalid routes, " + "the controller is ignored, reason: {}", controller, e.getMessage(), e); // remove all new routes as one has failed routes.removeAll(newRoutes); //NOSONAR } } /** * Unbinds a controller. * * @param controller the controller */ @Unbind(aggregate = true) public synchronized void unbindController(Controller controller) { LOGGER.info("Removing routes from " + controller); Collection<RouteDelegate> copy = new LinkedHashSet<>(routes); for (RouteDelegate r : copy) { if (r.getControllerObject().equals(controller)) { routes.remove(r); } } } private void ensureNoConflicts(List<Route> newRoutes) { //check if these new routes don't pre-exist in existingRoutes for (Route newRoute : newRoutes) { if (!isRouteConflictingWithExistingRoutes(newRoute)) { // this routes seems to be clean, store it final RouteDelegate delegate = new RouteDelegate(this, newRoute); routes.add(delegate); } } } private boolean isRouteConflictingWithExistingRoutes(Route route) { for (Route existing : routes) { if (hasSameMethodAndUrl(existing, route)) { // The routes are using the same HTTP Verb and URL, so we need to check the other aspect: accepted // and produced types if (hasSameOrOverlappingAcceptedTypes(existing, route) && hasSameOrOverlappingProducedTypes(existing, route)) { throw new RoutingException(existing.getHttpMethod() + " " + existing.getUrl() + " is already registered by controller " + existing.getControllerClass() + " - " + existing.toString() + " conflicts with " + route.toString()); } } } return false; } private boolean hasSameMethodAndUrl(Route actual, Route other) { return other.getUrl().equals(actual.getUrl()) && other.getHttpMethod() == actual.getHttpMethod(); } private boolean hasSameOrOverlappingAcceptedTypes(Route actual, Route other) { final Set<MediaType> actualAcceptedMediaTypes = actual.getAcceptedMediaTypes(); final Set<MediaType> otherAcceptedMediaTypes = other.getAcceptedMediaTypes(); // Both are empty if (actualAcceptedMediaTypes.isEmpty() && otherAcceptedMediaTypes.isEmpty()) { return true; } // One is empty if (actualAcceptedMediaTypes.isEmpty() || otherAcceptedMediaTypes.isEmpty()) { return true; } // None are empty, check intersection final Sets.SetView<MediaType> intersection = Sets.intersection(actualAcceptedMediaTypes, otherAcceptedMediaTypes); return !intersection.isEmpty(); } private boolean hasSameOrOverlappingProducedTypes(Route actual, Route other) { final Set<MediaType> actualProducedMediaTypes = actual.getProducedMediaTypes(); final Set<MediaType> otherProducedMediaTypes = other.getProducedMediaTypes(); if (actualProducedMediaTypes.isEmpty() && otherProducedMediaTypes.isEmpty()) { return true; } // One is empty if (actualProducedMediaTypes.isEmpty() || otherProducedMediaTypes.isEmpty()) { return true; } final Sets.SetView<MediaType> intersection = Sets.intersection(actualProducedMediaTypes, otherProducedMediaTypes); return !intersection.isEmpty(); } /** * Stopping the router. All routes are cleared. */ @Invalidate public void stop() { routes.clear(); } private synchronized Set<Route> copy() { return new LinkedHashSet<Route>(routes); } /** * Gets the {@link org.wisdom.api.router.Route} object handling the given request. * * @param method the method the request method * @param uri the URL of the request * @param request the incoming request * @return the route, {@literal unbound} if no action method can handle the request. */ @Override public Route getRouteFor(HttpMethod method, String uri, Request request) { // Compute the list of matching routes - only the path is check in this first stage List<Route> list = new ArrayList<>(1); //TODO This can be faster by using an immutable list. list.addAll(copy().stream() .filter(route -> route.matches(method, uri)) .sorted((r1, r2) -> { // Exact match first. if (r1.getUrl().equalsIgnoreCase(uri)) { return -1; } else if (r2.getUrl().equalsIgnoreCase(uri)) { return 1; } // Not comparable return 0; }) .collect(Collectors.toList())); if (list.isEmpty()) { // Creates an unbound route - 404 return new RouteDelegate(this, new Route(method, uri, Status.NOT_FOUND)); } // Find the route that accept the request List<Route> fullMatch = new ArrayList<>(); List<Route> partialMatch = new ArrayList<>(); for (Route route : list) { final int acceptation = route.isCompliantWithRequestContentType(request); switch (acceptation) { case 2: // It's a full match fullMatch.add(route); break; case 1: // It's a wildcard match, we have to see if we don't have a full match later. partialMatch.add(route); break; default: // Not accepted. } } if (fullMatch.isEmpty() && partialMatch.isEmpty()) { // Not Acceptable Content return new RouteDelegate(this, new Route(method, uri, Status.UNSUPPORTED_MEDIA_TYPE)); } // Check against the produce type fullMatch.addAll(partialMatch); for (Route route : fullMatch) { if (route.isCompliantWithRequestAccept(request)) { return route; } } return new RouteDelegate(this, new Route(method, uri, Status.NOT_ACCEPTABLE)); } /** * Gets the URL that would invoke the given action method. * * @param className the controller class * @param method the controller method * @param params map of parameter name - value * @return the computed URL, {@literal null} if no route matches the given action method */ @Override public String getReverseRouteFor(String className, String method, Map<String, Object> params) { for (Route route : copy()) { if (route.getControllerClass().getName().equals(className) && route.getControllerMethod().getName().equals(method)) { return computeUrlForRoute(route, params); } } return null; } /** * @return a copy of the current routes. */ @Override public Collection<Route> getRoutes() { return copy(); } private String computeUrlForRoute(Route route, Map<String, Object> params) { if (params == null) { // No variables, return the raw url. return route.getUrl(); } // The original url. Something like route/user/{id}/{email}/userDashboard String urlWithReplacedPlaceholders = route.getUrl(); Map<String, Object> queryParameterMap = Maps.newHashMap(); for (Map.Entry<String, Object> entry : params.entrySet()) { String originalRegexEscaped = String.format("\\{%s(\\+)?\\}", entry.getKey()); // If regex is in the url as placeholder we replace the placeholder final boolean containVar = urlWithReplacedPlaceholders.contains("{" + entry.getKey() + "}"); final boolean containAndCanSpreadOnSeveralSegment = urlWithReplacedPlaceholders.contains("{" + entry.getKey() + "+}"); if (containVar || containAndCanSpreadOnSeveralSegment) { urlWithReplacedPlaceholders = urlWithReplacedPlaceholders.replaceAll( originalRegexEscaped, pathEncode(entry.getValue().toString(), containAndCanSpreadOnSeveralSegment)); // If the parameter is not there as placeholder we add it as queryParameter } else { queryParameterMap.put(entry.getKey(), entry.getValue()); } } // now prepare the query string for this url if we got some query params if (!queryParameterMap.entrySet().isEmpty()) { StringBuilder queryParameterStringBuffer = new StringBuilder(); // The uri is now replaced => we now have to add potential query parameters for (Iterator<Map.Entry<String, Object>> iterator = queryParameterMap.entrySet().iterator(); iterator.hasNext(); ) { Map.Entry<String, Object> queryParameterEntry = iterator.next(); queryParameterStringBuffer.append(queryParameterEntry.getKey()); queryParameterStringBuffer.append("="); // Don't forget to encode the value. queryParameterStringBuffer.append(encode(queryParameterEntry.getValue().toString())); if (iterator.hasNext()) { queryParameterStringBuffer.append("&"); } } urlWithReplacedPlaceholders = urlWithReplacedPlaceholders + "?" + queryParameterStringBuffer.toString(); } return urlWithReplacedPlaceholders; } private String pathEncode(String s, boolean canSpreadOnSeveralSegments) { String copy = s; for (Map.Entry<String, String> c : PERCENT_ENCODING_MAP.entrySet()) { if (s.contains(c.getKey())) { if (c.getKey().endsWith("/") && canSpreadOnSeveralSegments) { // The canSpreadOnSeveralSegments parameter is true when the uri contains + such as in {path+}. In this // case, we must not convert "/" by the percent value. continue; } copy = copy.replace(c.getKey(), c.getValue()); } } return copy; } private String encode(String v) { try { return URLEncoder.encode(v, "UTF-8"); } catch (UnsupportedEncodingException e) { // UTF-8 is part of the JVM specification. throw new IllegalArgumentException("UTF-8 not supported", e); } } /** * @return the validator object used to validate parameters. */ public Validator getValidator() { return validator; } /** * For testing purpose only. * * @param validator the validator to use */ public void setValidator(Validator validator) { this.validator = validator; } protected Set<Filter> getFilters() { return ImmutableSet.copyOf(filters); } /** * For testing purpose only * @return a direct reference on the filter set. */ protected Set<Filter> getDirectReferenceOnFilters() { return filters; } protected List<Interceptor<?>> getInterceptors() { return interceptors; } protected ParameterFactories getParameterConverterEngine() { return engine; } /** * Binds a filter. * * @param filter the filter */ @Bind(aggregate = true, optional = true) public void bindFilter(Filter filter) { filters.add(filter); } /** * Unbinds a filter. * * @param filter the filter */ @Unbind public synchronized void unbindFilter(Filter filter) { filters.remove(filter); } /** * Sets the parameter converter engine. For testing purpose only. * * @param parameterConverterEngine the parameter converter engine */ public void setParameterConverterEngine(ParameterFactories parameterConverterEngine) { this.engine = parameterConverterEngine; } private static final Comparator<Filter> COMPARATOR = (o1, o2) -> { // In case of object equality, returns 0. if (o1 == o2 || o1.hashCode() == o2.hashCode()) { return 0; } // In all the other cases, we must never return 0, that would mean equality, // and you can't have equal element in a set. int compare = Integer.valueOf(o2.priority()).compareTo(o1.priority()); if (compare == 0) { return -1; } else { return compare; } }; /** * An implementation of a sorted set (backed up on an array list) to manage the list of filter. This * ensures the 'unicity' of the filters by checking object equality and hashcode. Thus it supports proxies. * <p/> * Methods are guarded by the monitor lock. */ private class FilterSet extends ArrayList<Filter> implements Set<Filter> { @Override public synchronized boolean add(Filter filter) { if (!contains(filter)) { super.add(filter); Collections.sort(this, COMPARATOR); return true; } return false; } @Override public synchronized boolean contains(Object o) { for (Object f : this) { if (o == f || o.hashCode() == f.hashCode()) { return true; } } return false; } @Override public synchronized int indexOf(Object o) { if (o == null) { return -1; } for (int i = 0; i < size(); i++) { Object f = get(i); if (o == f || o.hashCode() == f.hashCode()) { return i; } } return -1; } @Override public synchronized boolean remove(Object o) { int index = indexOf(o); if (index != -1) { remove(index); return true; } return false; } @Override public synchronized int size() { return super.size(); } } }