package com.elibom.jogger.middleware.router;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.elibom.jogger.Middleware;
import com.elibom.jogger.MiddlewareChain;
import com.elibom.jogger.http.Path;
import com.elibom.jogger.http.Request;
import com.elibom.jogger.http.Response;
import com.elibom.jogger.middleware.router.Route.HttpMethod;
import com.elibom.jogger.middleware.router.interceptor.Action;
import com.elibom.jogger.middleware.router.interceptor.Controller;
import com.elibom.jogger.middleware.router.interceptor.Interceptor;
import com.elibom.jogger.middleware.router.interceptor.InterceptorEntry;
import com.elibom.jogger.middleware.router.interceptor.InterceptorExecution;
import com.elibom.jogger.util.Preconditions;
/**
* This middleware routes requests through <em>interceptors</em> and <em>controller actions</em> using the <em>routes</em>
* provided by the user.
*
* @author German Escobar
*/
public class RouterMiddleware implements Middleware {
/**
* The list of routes.
*/
private List<Route> routes = new CopyOnWriteArrayList<Route>();
/**
* The list of interceptors.
*/
private List<InterceptorEntry> interceptors = new CopyOnWriteArrayList<InterceptorEntry>();
@Override
public void handle(Request request, Response response, MiddlewareChain chain) throws Exception {
Route route = getRoute(request.getMethod(), request.getPath());
if (route == null) {
chain.next();
return;
}
request.setRoute(route);
response.status(Response.OK);
// load the interceptors of the request
List<Interceptor> requestInterceptors = getInterceptors(request.getPath());
// execute the controller
ControllerExecutor controllerExecutor = new ControllerExecutor(route, request, response, requestInterceptors);
controllerExecutor.proceed();
}
/**
* Returns a list of interceptors that match a path
*
* @param path
* @return a list of {@link Interceptor} objects.
*/
private List<Interceptor> getInterceptors(String path) {
List<Interceptor> ret = new ArrayList<Interceptor>();
for (InterceptorEntry entry : getInterceptors()) {
if (matches(path, entry.getPaths())) {
ret.add(entry.getInterceptor());
}
}
return ret;
}
/**
* Helper method. Checks if the <code>path</code> is in the array of <code>paths</code>.
*
* @param path the path we want to check.
* @param paths the paths to match.
*
* @return true if the <code>path</code> matches the <code>paths</code> (at least one), false otherwise.
*/
private boolean matches(String path, String... paths) {
if (paths.length == 0) {
return true;
}
for (String p : paths) {
/* TODO we should use the same mechanism servlets use to match paths */
if (p.equalsIgnoreCase(path)) {
return true;
}
}
return false;
}
/**
* Retrieves the {@link Route} that matches the specified <code>httpMethod</code> and <code>path</code>.
*
* @param httpMethod the HTTP method to match. Should not be null or empty.
* @param path the path to match. Should not be null but can be empty (which is interpreted as /)
*
* @return a {@link Route} object that matches the arguments or null if no route matches.
*/
private Route getRoute(String httpMethod, String path) {
Preconditions.notEmpty(httpMethod, "no httpMethod provided.");
Preconditions.notNull(path, "no path provided.");
String cleanPath = parsePath(path);
for (Route route : routes) {
if (matchesPath(route.getPath(), cleanPath) && route.getHttpMethod().toString().equalsIgnoreCase(httpMethod)) {
return route;
}
}
return null;
}
private String parsePath(String path) {
path = Path.fixPath(path);
try {
URI uri = new URI(path);
return uri.getPath();
} catch (URISyntaxException e) {
return null;
}
}
/**
* Helper method. Tells if the the HTTP path matches the route path.
*
* @param routePath the path defined for the route.
* @param pathToMatch the path from the HTTP request.
*
* @return true if the path matches, false otherwise.
*/
private boolean matchesPath(String routePath, String pathToMatch) {
routePath = routePath.replaceAll(Path.VAR_REGEXP, Path.VAR_REPLACE);
return pathToMatch.matches("(?i)" + routePath);
}
public List<Route> getRoutes() {
return routes;
}
public void setRoutes(List<Route> routes) {
Preconditions.notNull(routes, "no routes provided");
this.routes = routes;
}
/**
* Adds a route to the list of routes using a {@link Route} object.
*
* @param route the route to be added.
*/
public void addRoute(Route route) {
Preconditions.notNull(route, "no route provided");
this.routes.add(route);
}
/**
* Creates a {@link Route} object from the received arguments and adds it to the list of routes.
*
* @param httpMethod the HTTP method to which this route is going to respond.
* @param path the path to which this route is going to respond.
* @param controller the object that will be invoked when this route matches.
* @param methodName the name of the method in the <code>controller</code> object that will be invoked when this
* route matches.
*
* @throws NoSuchMethodException if the <code>methodName</code> is not found or doesn't have the right signature.
*/
public void addRoute(HttpMethod httpMethod, String path, Object controller, String methodName) throws NoSuchMethodException {
Preconditions.notNull(controller, "no controller provided");
Method method = controller.getClass().getMethod(methodName, Request.class, Response.class);
addRoute(httpMethod, path, controller, method);
}
/**
* Creates a {@link Route} object from the received arguments and adds it to the list of routes.
*
* @param httpMethod the HTTP method to which this route is going to respond.
* @param path the path to which this route is going to respond.
* @param controller the object that will be invoked when this route matches.
* @param method the Method that will be invoked when this route matches.
*/
public void addRoute(HttpMethod httpMethod, String path, Object controller, Method method) {
// validate signature
Class<?>[] paramTypes = method.getParameterTypes();
if (paramTypes.length != 2 || !paramTypes[0].equals(Request.class) || !paramTypes[1].equals(Response.class)) {
throw new RoutesException("Expecting two params of type com.elibom.jogger.http.Request and com.elibom.jogger.http.Response "
+ "respectively");
}
method.setAccessible(true); // to access methods from anonymous classes
routes.add(new Route(httpMethod, path, controller, method));
}
/**
* Creates a {@link Route} object and adds it to the routes list. It will respond to the GET HTTP method and the
* specified <code>path</code> invoking the {@link RouteHandler} object.
*
* @param path the path to which this route will respond.
* @param handler the object that will be invoked when the route matches.
*/
public void get(String path, RouteHandler handler) {
try {
addRoute(HttpMethod.GET, path, handler, "handle");
} catch (NoSuchMethodException e) {
// shouldn't happen unless we change the name of the method in RouteHandler
throw new RuntimeException(e);
}
}
/**
* Creates a {@link Route} object and adds it to the routes list. It will respond to the POST HTTP method and the
* specified <code>path</code> invoking the {@link RouteHandler} object.
*
* @param path the path to which this route will respond.
* @param handler the object that will be invoked when the route matches.
*/
public void post(String path, RouteHandler handler) {
try {
addRoute(HttpMethod.POST, path, handler, "handle");
} catch (NoSuchMethodException e) {
// shouldn't happen unless we change the name of the method in RouteHandler
throw new RuntimeException(e);
}
}
/**
* Creates a {@link Route} object and adds it to the routes list. It will respond to the PUT HTTP method and the
* specified <code>path</code> invoking the {@link RouteHandler} object.
*
* @param path the path to which this route will respond.
* @param handler the object that will be invoked when the route matches.
*/
public void put(String path, RouteHandler handler) {
try {
addRoute(HttpMethod.PUT, path, handler, "handle");
} catch (NoSuchMethodException e) {
// shouldn't happen unless we change the name of the method in RouteHandler
throw new RuntimeException(e);
}
}
/**
* Creates a {@link Route} object and adds it to the routes list. It will respond to the DELETE HTTP method and the
* specified <code>path</code> invoking the {@link RouteHandler} object.
*
* @param path the path to which this route will respond.
* @param handler the object that will be invoked when the route matches.
*/
public void delete(String path, RouteHandler handler) {
try {
addRoute(HttpMethod.DELETE, path, handler, "handle");
} catch (NoSuchMethodException e) {
// shouldn't happen unless we change the name of the method in RouteHandler
throw new RuntimeException(e);
}
}
public List<InterceptorEntry> getInterceptors() {
return interceptors;
}
public void setInterceptors(List<InterceptorEntry> interceptors) {
Preconditions.notNull(interceptors, "no interceptors provided");
this.interceptors = interceptors;
}
/**
* Adds the <code>interceptor</code> to the list of interceptors that will matches the specified
* <code>paths</code>.
*
* @param interceptor the interceptor object to be added.
* @param paths the paths in which this interceptor will be invoked, an empty array to respond to all paths.
*/
public void addInterceptor(Interceptor interceptor, String... paths) {
Preconditions.notNull(interceptor, "no interceptor provided");
interceptors.add(new InterceptorEntry(interceptor, paths));
}
/**
* This is a helper class that executes the interceptor chain and calls the controller. Notice that this class uses
* recursion to call each interceptor and the controller. It uses an index to keep track of the next interceptor to be
* executed. Finally, it calls the controller.
*
* @author German Escobar
*/
private class ControllerExecutor implements InterceptorExecution {
private Route route;
private Request request;
private Response response;
private List<Interceptor> interceptors;
private int index = 0;
/**
* Constructor. Initializes the object with the specified parameters.
*
* @param route
* @param request the Jogger HTTP request.
* @param response the Jogger HTTP response.
* @param interceptors a list of interceptors that we need to execute before calling the action.
*/
public ControllerExecutor(Route route, Request request, Response response, List<Interceptor> interceptors) {
this.route = route;
this.request = request;
this.response = response;
this.interceptors = interceptors;
}
@Override
public void proceed() throws Exception {
// if we finished executing all the interceptors, call the controller method
if (index == interceptors.size()) {
Object controller = route.getController();
Method method = route.getAction();
try {
method.invoke(controller, request, response);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
return;
}
// retrieve the interceptor and increase the index
Interceptor interceptor = interceptors.get(index);
index++;
// execute the interceptor - notice that the interceptor can eventually call the proceed() method recursively.
interceptor.intercept(request, response, this);
}
@Override
public Controller getController() {
// create and return a new instance of the Controller class
return new Controller() {
public <A extends Annotation> A getAnnotation(Class<A> annotation) {
return route.getController().getClass().getAnnotation(annotation);
}
};
}
@Override
public Action getAction() {
// create and return a new instance of the Action class
return new Action() {
public <A extends Annotation> A getAnnotation(Class<A> annotationClass) {
return findAnnotation(route.getAction(), annotationClass);
}
/**
* Helper method. Tries to find the annotation in the method or its super methods. Annotations on
* methods are not inherited by default, so we need to handle this explicitly.
*
* @param method the method from which we want to find the annotation.
* @param annotationClass the class of the annotation we are searching for.
*
* @return the annotation or null if not found.
*/
private <A extends Annotation> A findAnnotation(Method method, Class<A> annotationClass) {
A annotation = method.getAnnotation(annotationClass);
if (annotation != null) {
return annotation;
}
Method superMethod = getSuperMethod(method);
if (superMethod != null) {
return findAnnotation(superMethod, annotationClass);
}
return null;
}
private Method getSuperMethod(Method method) {
Class<?> superClass = method.getDeclaringClass().getSuperclass();
try {
return superClass.getDeclaredMethod(method.getName(), method.getParameterTypes());
} catch (NoSuchMethodException e) {
return null;
}
}
};
}
}
}