org.wisdom.router.RequestRouter.java Source code

Java tutorial

Introduction

Here is the source code for org.wisdom.router.RequestRouter.java

Source

/*
 * #%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();
        }
    }
}