gribbit.route.Route.java Source code

Java tutorial

Introduction

Here is the source code for gribbit.route.Route.java

Source

/**
 * This file is part of the Gribbit Web Framework.
 * 
 *     https://github.com/lukehutch/gribbit
 * 
 * @author Luke Hutchison
 * 
 * --
 * 
 * @license Apache 2.0 
 * 
 * Copyright 2015 Luke Hutchison
 *
 * 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.
 */
package gribbit.route;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.List;

import gribbit.auth.CSRF;
import gribbit.auth.User;
import gribbit.handler.route.annotation.Public;
import gribbit.handler.route.annotation.Roles;
import gribbit.model.DataModel;
import gribbit.response.ErrorResponse;
import gribbit.response.HTMLPageResponse;
import gribbit.response.HTMLResponse;
import gribbit.response.Response;
import gribbit.response.exception.BadRequestException;
import gribbit.response.exception.InternalServerErrorException;
import gribbit.response.exception.ResponseException;
import gribbit.response.exception.UnauthorizedException;
import gribbit.server.GribbitServer;
import gribbit.util.Log;
import gribbit.util.Reflection;
import gribbit.util.URLUtils;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.ext.web.RoutingContext;

/** The metadata about a Route. */
public class Route {
    private ParsedURL routePath;
    private boolean routeIsPublic;
    private Class<? extends RouteHandler> handlerClass;
    private Roles getRoles, postRoles;

    private Method getMethod;
    private Class<?>[] getParamTypes;

    private Method postMethod;
    private Class<? extends DataModel> postParamType;

    // -----------------------------------------------------------------------------------------------------------------

    public Route(Class<? extends RouteHandler> handlerClass, String routePath) {
        this.handlerClass = handlerClass;
        this.routePath = new ParsedURL(routePath);
        this.routeIsPublic = handlerClass.getAnnotation(Public.class) != null;
        Roles classRoles = handlerClass.getAnnotation(Roles.class);

        // Check for methods get() and post() in the handler subinterface
        for (Method method : handlerClass.getMethods()) {
            String methodName = method.getName();
            Class<?>[] paramTypes = method.getParameterTypes();
            boolean isGet = methodName.equals("get"), isPost = methodName.equals("post");

            if (isGet || isPost) {
                // Check method modifiers
                if ((method.getModifiers() & Modifier.STATIC) != 0) {
                    throw new RuntimeException(
                            "Method " + handlerClass.getName() + "." + methodName + " should not be static");
                }
                // Check return type
                if (!Response.class.isAssignableFrom(method.getReturnType())) {
                    throw new RuntimeException("Method " + handlerClass.getName() + "." + methodName
                            + " should have a return type of " + Response.class.getName()
                            + " or a subclass, instead of " + method.getReturnType().getName());
                }
            }

            if (isGet) {
                // Check method parameters are key value pairs, and that the keys are strings, and the
                // values are String or Integer
                for (int j = 0; j < paramTypes.length; j++) {
                    if (paramTypes[j] != String.class && paramTypes[j] != Integer.class
                            && paramTypes[j] != Integer.TYPE) {
                        throw new RuntimeException(
                                "Method " + handlerClass.getName() + "." + methodName + " has a param of type "
                                        + paramTypes[j].getName() + ", needs to be String, int or Integer");
                    }
                }

                if (routePath.equals("/") && paramTypes.length != 0) {
                    // Can't pass URL parameters into the root URL, because the root URL may be a prefix
                    // of another handler's URL (it is the only URL that this is allowed for, and in
                    // general it's not a problem because URLs are determined from the RestHandler's full
                    // classname). If we allowed the root URL to take params, then the params would match
                    // URL path components.
                    throw new RuntimeException("Interface " + handlerClass.getName()
                            + " has a route override of \"/\", so it serves the root URL, "
                            + "but the get() method takes one or more parameters. "
                            + "(The root URL cannot take URL parameters, because they "
                            + "would match path components of other URLs.)");
                }

                if (getMethod != null) {
                    // Make sure there is only one method of each name
                    throw new RuntimeException("Class " + handlerClass.getName() + " has two get() methods");
                }
                getMethod = method;
                getParamTypes = paramTypes;
                getRoles = method.getAnnotation(Roles.class);
                if (getRoles == null) {
                    getRoles = classRoles;
                }

            } else if (isPost) {
                if (paramTypes.length > 1) {
                    throw new RuntimeException("Method " + handlerClass.getName() + "." + methodName
                            + " needs zero parameters or one parameter of type " + DataModel.class.getSimpleName()
                            + "; takes " + paramTypes.length + " parameters");
                }

                if (paramTypes.length == 1) {
                    if (!DataModel.class.isAssignableFrom(paramTypes[0])) {
                        throw new RuntimeException("The single parameter of method " + handlerClass.getName() + "."
                                + methodName + " needs to be a subclass of " + DataModel.class.getName());
                    }
                    @SuppressWarnings("unchecked")
                    Class<? extends DataModel> paramType = (Class<? extends DataModel>) paramTypes[0];
                    postParamType = paramType;
                }

                if (postMethod != null) {
                    // Make sure there is only one method of each name
                    throw new RuntimeException("Interface " + handlerClass.getName() + " has two post() methods");
                }
                postMethod = method;
                postRoles = method.getAnnotation(Roles.class);
                if (postRoles == null) {
                    postRoles = classRoles;
                }
            }
        }
    }

    // -----------------------------------------------------------------------------------------------------------------

    /**
     * If a post() method takes exactly one parameter, then bind the param value from the POST data.
     * 
     * @param user
     */
    private Object[] bindPostParamFromPOSTData(HttpServerRequest request, ParsedURL reqURL)
            throws ResponseException {
        if (reqURL.getUnescapedURLParts().size() != routePath.getUnescapedURLParts().size()) {
            throw new BadRequestException("POST requests should not have URL parameters");
        }
        if (postParamType == null) {
            // post() takes no params
            return null;

        } else {
            // post() takes one param, which is the form model to bind
            DataModel postParam;
            try {
                postParam = Reflection.instantiateWithDefaultConstructor(postParamType);
            } catch (InstantiationException e) {
                // Should never happen, we already tried instantiating all DataModel subclasses
                // that are bound to POST request handlers when site resources were loaded
                throw new InternalServerErrorException(
                        "Could not instantiate POST parameter of type " + postParamType.getName(), e);
            }

            // Bind POST param object from request
            postParam.bindFromPost(request);

            // Wrap bound object Object[1]
            Object[] paramVal = new Object[1];
            paramVal[0] = postParam;
            return paramVal;
        }
    }

    /**
     * If a get() method takes one or more parameters, bind the parameters from URI segments after the end of the
     * route's base URI, e.g. /person/53 for a route of /person gives one Integer-typed param value of 53.
     * 
     * FIXME: Use Vertx' URL binding syntax, e.g. "/person/:id" puts the URL value into the param "id". FIXME: Move
     * binding code into the RequestURL class.
     */
    private Object[] bindGetParamsFromURI(ParsedURL reqURL) throws ResponseException {
        if (getParamTypes.length == 0) {
            // get() takes no params
            int numUrlParams = reqURL.getUnescapedURLParts().size();
            int expectedNumUrlParams = routePath.getUnescapedURLParts().size();
            if (numUrlParams != expectedNumUrlParams) {
                throw new BadRequestException(
                        "Wrong number of URL parameters: expected 0, got " + (numUrlParams - expectedNumUrlParams));
            }
            return null;

        } else {
            // get() takes one or more params
            Object[] getParamVals = new Object[getParamTypes.length];
            if (!reqURL.startsWith(routePath)) {
                // This is an error handler that has been called to replace the normal route handler;
                // don't try to parse URL params (leave them all as null)

            } else {
                // Parse URL params
                List<String> urlParams = reqURL.getUnescapedURLParts(routePath.getNumURLParts());
                if (urlParams.size() != getParamTypes.length) {
                    throw new BadRequestException("Wrong number of URL parameters: expected " + getParamTypes.length
                            + ", got " + urlParams.size());
                }
                for (int i = 0; i < getParamTypes.length; i++) {
                    String uriSegment = urlParams.get(i);
                    if (getParamTypes[i] == Integer.class || getParamTypes[i] == Integer.TYPE) {
                        try {
                            // Specifically parse integers for int-typed method parameters 
                            getParamVals[i] = Integer.parseInt(uriSegment);
                        } catch (NumberFormatException e) {
                            throw new BadRequestException(
                                    "Malformed URL parameter, expected integer for URI parameter");
                        }
                    } else {
                        // N.B. parameter values should not be unescaped again after this, to prevent
                        // double-encoding attacks: see https://www.owasp.org/index.php/Double_Encoding
                        getParamVals[i] = uriSegment;
                    }
                }
            }
            return getParamVals;
        }
    }

    // -----------------------------------------------------------------------------------------------------------------

    /** Invoke a default method in a Route subinterface. */
    private Response invokeMethod(RoutingContext routingContext, Method method, Object[] methodParamVals,
            Roles methodRoles, boolean checkAuthorized, boolean checkCSRFTok) throws ResponseException {
        // Create a handler instance
        RouteHandler instance;
        try {
            instance = handlerClass.newInstance();
        } catch (InstantiationException | IllegalAccessException e) {
            throw new InternalServerErrorException(
                    "Exception while creating instance of handler class " + handlerClass.getName(), e);
        }
        instance.routingContext = routingContext;
        instance.session = routingContext.session();
        if (instance.session != null) {
            instance.user = User.fromSession(instance.session);
        }

        if (checkCSRFTok) {
            if (!CSRF.csrfTokMatches(routingContext.request().getParam(CSRF.CSRF_PARAM_NAME), instance.user)) {
                throw new BadRequestException("Missing or incorrect CSRF token in POST request");
            }
        }

        // Check if user can call method
        if (checkAuthorized && !routeIsPublic && !User.userIsAuthorized(instance.user, methodRoles)) {
            throw new UnauthorizedException();
        }

        try {
            // Invoke the method
            Response response = (Response) method.invoke(instance, methodParamVals);

            // The Response object should not be null, but if it is, respond with No Content
            if (response == null) {
                Log.warning(handlerClass.getName() + "." + method.getName()
                        + " returned a null response -- responding with 204: No Content");
                response = new ErrorResponse(HttpResponseStatus.NO_CONTENT, "");
            }

            // For non-error responses
            if (response.getStatus() == HttpResponseStatus.OK) {
                // Add the user's CSRF token to the response if user is logged in
                if (instance.user != null) {
                    String csrfTok = instance.user.csrfTok;
                    if (csrfTok != null) {
                        CSRF.setCsrfCookie(csrfTok, "/", response);
                        if (response instanceof HTMLResponse) {
                            ((HTMLResponse) response).setCsrfTok(csrfTok);
                        }
                    }
                }
                // Add any flash messages to response
                if (response instanceof HTMLPageResponse) {
                    ((HTMLPageResponse) response).setFlashMessages(routingContext.session());
                }
            }
            return response;

        } catch (InvocationTargetException e) {
            // If method.invoke() throws a ResponseException, it gets wrapped in an InvocationTargetException.
            // Unwrap it and re-throw it.
            Throwable cause = e.getCause();
            if (cause instanceof ResponseException) {
                throw (ResponseException) cause;
            } else if (cause instanceof Exception) {
                throw new InternalServerErrorException(
                        "Exception while invoking the method " + handlerClass.getName() + "." + method.getName(),
                        (Exception) cause);
            } else {
                throw new InternalServerErrorException("Exception while invoking the method "
                        + handlerClass.getName() + "." + method.getName() + ": caused by " + cause.getMessage());
            }
        } catch (Exception e) {
            throw new InternalServerErrorException(
                    "Exception while invoking the method " + handlerClass.getName() + "." + method.getName(), e);
        }
    }

    // -----------------------------------------------------------------------------------------------------------------

    /**
     * Call the get() or post() method for the Route corresponding to the request URI.
     * 
     * @param reqURL
     */
    public Response callHandler(RoutingContext routingContext, ParsedURL reqURL) throws ResponseException {
        Response response;
        // Determine param vals for method
        HttpServerRequest request = routingContext.request();
        HttpMethod reqMethod = request.method();

        if (reqMethod == HttpMethod.GET) {
            // Bind URI params
            Object[] getParamVals = bindGetParamsFromURI(reqURL);

            // Invoke the get() method with URI params
            response = invokeMethod(routingContext, getMethod, getParamVals, getRoles, /* checkAuthorized = */ true,
                    /* checkCSRFTok = */ false);

        } else if (reqMethod == HttpMethod.POST) {
            // Bind the post() method's single parameter (if it has one) from the POST data in the request
            Object[] postParamVal = bindPostParamFromPOSTData(request, reqURL);

            // Invoke the post() method
            response = invokeMethod(routingContext, postMethod, postParamVal, postRoles,
                    /* checkAuthorized = */ true, /* checkCSRFTok = */ true);

        } else {
            // Method not allowed
            response = new ErrorResponse(HttpResponseStatus.METHOD_NOT_ALLOWED, "HTTP method not allowed");
        }

        if (response == null) {
            // Should not happen
            throw new InternalServerErrorException("Didn't generate a response");
        }

        return response;
    }

    /** Call the get() method of an error handler. */
    public Response callErrorHandler(RoutingContext routingContext, ResponseException responseException) {
        try {
            // TODO: responseException is currently ignored, but could be passed into the get() method of
            // custom error handlers as the first parameter to provide more info about what went wrong.
            // (Would need to change the expected number of params for a get() method of an error handler
            // from 0 to 1 in RouteMapping.)
            Response response = invokeMethod(routingContext, getMethod, /* getParamVals = */ null, getRoles,
                    /* checkAuthorized = */ false, /* checkCSRFTok = */ false);
            if (response == null) {
                // Should not happen
                throw new RuntimeException("Error handler didn't generate a response");
            }
            return response;
        } catch (ResponseException e) {
            throw new RuntimeException("Exception in error handler", e);
        }
    }

    // -----------------------------------------------------------------------------------------------------------------

    /**
     * Get the route annotated with the given HTTP method on the given RestHandler, substituting the key/value pairs
     * into the params in the URL template.
     * 
     * Can call this method with no urlParams if the handler takes no params for this httpMethod, otherwise list URL
     * params in the varargs. URL params can be any type, and their toString() method will be called. Param values
     * will be URL-escaped (turned into UTF-8, then byte-escaped if the bytes are not safe).
     */
    private static String forMethod(HttpMethod httpMethod, Class<? extends RouteHandler> handlerClass,
            Object... urlParams) {
        Route handler = GribbitServer.siteResources.routeForClass(handlerClass);

        if (httpMethod == HttpMethod.GET) {
            if (handler.getMethod == null) {
                throw new RuntimeException("Handler " + handlerClass.getName() + " does not have a get() method");
            }

            for (Object param : urlParams) {
                if (param == null) {
                    throw new RuntimeException("null values are not allowed in URL params");
                }
            }
            if (urlParams.length != handler.getParamTypes.length) {
                throw new RuntimeException(
                        "Wrong number of URL params for method " + handlerClass.getName() + ".get()");
            }
            // Build new fully-specified route from route stem and URL params 
            StringBuilder buf = new StringBuilder(handler.routePath.getNormalizedPath());
            for (int i = 0; i < urlParams.length; i++) {
                buf.append('/');
                URLUtils.escapeURLSegment(urlParams[i].toString(), buf);
            }
            return buf.toString();

        } else if (httpMethod == HttpMethod.POST) {
            if (handler.postMethod == null) {
                throw new RuntimeException("Handler " + handlerClass.getName() + " does not have a post() method");
            }
            if (urlParams.length != 0) {
                throw new RuntimeException(
                        "Tried to pass URL params to a POST method, " + handlerClass.getName() + ".post()");
            }
            return handler.routePath.getNormalizedPath();

        } else {
            throw new RuntimeException("Bad HTTP method: " + httpMethod);
        }
    }

    /**
     * Return the route path for the POST method handler of a RestHandler class.
     */
    public static String forPost(Class<? extends RouteHandler> handlerClass) {
        return Route.forMethod(HttpMethod.POST, handlerClass);
    }

    /**
     * Return the route path for the GET method handler of a RestHandler class, appending URL params.
     */
    public static String forGet(Class<? extends RouteHandler> handlerClass, Object... urlParams) {
        return Route.forMethod(HttpMethod.GET, handlerClass, (Object[]) urlParams);
    }

    // -----------------------------------------------------------------------------------------------------------------

    public ParsedURL getRoutePath() {
        return routePath;
    }

    @Override
    public String toString() {
        return routePath.getNormalizedPath();
    }

    public Class<? extends RouteHandler> getHandler() {
        return handlerClass;
    }

    public boolean hasGetMethod() {
        return getMethod != null;
    }

    public boolean hasPostMethod() {
        return postMethod != null;
    }

    /**
     * Returns the post() method DataModel param object type. Returns null if this route handler does not have a
     * post() method, or if the handler does have a post() method but the method doesn't take any parameters.
     */
    public Class<? extends DataModel> getPostParamType() {
        return postParamType;
    }

    /**
     * Returns the number of parameters accepted by the get() method, or 0 if there are no params (or if there is no
     * get() method).
     */
    public int getNumGetParams() {
        return getMethod == null ? 0 : getParamTypes.length;
    }

    public boolean matches(ParsedURL reqURL) {
        if (routePath.getNumURLParts() == 0) {
            // Special case: only "/" should match a routePath of "/", "/something" should not match
            return reqURL.getNumURLParts() == 0;
        }
        return reqURL.startsWith(routePath);
    }
}