All Downloads are FREE. Search and download functionalities are using the official Maven repository.

fathom.rest.controller.ControllerHandler Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (C) 2015 the original author or authors.
 *
 * 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 fathom.rest.controller;

import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.inject.Injector;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import fathom.exception.FatalException;
import fathom.exception.FathomException;
import fathom.rest.Context;
import fathom.rest.controller.exceptions.RangeException;
import fathom.rest.controller.exceptions.RequiredException;
import fathom.rest.controller.extractors.ArgumentExtractor;
import fathom.rest.controller.extractors.CollectionExtractor;
import fathom.rest.controller.extractors.ConfigurableExtractor;
import fathom.rest.controller.extractors.FileItemExtractor;
import fathom.rest.controller.extractors.NamedExtractor;
import fathom.rest.controller.extractors.SuffixExtractor;
import fathom.rest.controller.extractors.TypedExtractor;
import fathom.utils.ClassUtil;
import fathom.utils.Util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ro.pippo.core.ContentTypeEngines;
import ro.pippo.core.FileItem;
import ro.pippo.core.HttpConstants;
import ro.pippo.core.Messages;
import ro.pippo.core.route.Route;
import ro.pippo.core.route.RouteHandler;
import ro.pippo.core.route.RouteMatch;
import ro.pippo.core.util.StringUtils;

import java.io.File;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * ControllerHandler executes controller methods.
 *
 * @author James Moger
 */
public class ControllerHandler implements RouteHandler {

    private static final Logger log = LoggerFactory.getLogger(ControllerHandler.class);

    // Matches: {id} AND {id: .*?}
    // group(1) extracts the name of the group (in that case "id").
    // group(3) extracts the regex if defined
    private static final Pattern PATTERN_FOR_VARIABLE_PARTS_OF_ROUTE = Pattern.compile("\\{(.*?)(:\\s(.*?))?\\}");


    protected final Class controllerClass;
    protected final Provider controllerProvider;
    protected final Method method;
    protected final Messages messages;
    protected final List> routeInterceptors;
    protected final List declaredConsumes;
    protected final List declaredProduces;
    protected final Collection declaredReturns;
    protected final Set contentTypeSuffixes;
    protected final boolean isNoCache;
    protected ArgumentExtractor[] extractors;
    protected String[] patterns;

    public ControllerHandler(Injector injector, Class controllerClass, String methodName) {
        if (controllerClass.isAnnotationPresent(Singleton.class)
                || controllerClass.isAnnotationPresent(javax.inject.Singleton.class)) {
            throw new FathomException("Controller '{}' may not be annotated as a Singleton!", controllerClass.getName());
        }

        this.controllerClass = controllerClass;
        this.controllerProvider = injector.getProvider(controllerClass);
        this.method = findMethod(controllerClass, methodName);
        this.messages = injector.getInstance(Messages.class);

        Preconditions.checkNotNull(method, "Failed to find method '%s'", Util.toString(controllerClass, methodName));
        log.trace("Obtained method for '{}'", Util.toString(method));

        this.routeInterceptors = new ArrayList<>();
        for (Class> handlerClass : ControllerUtil.collectRouteInterceptors(method)) {
            RouteHandler handler = injector.getInstance(handlerClass);
            this.routeInterceptors.add(handler);
        }

        ContentTypeEngines engines = injector.getInstance(ContentTypeEngines.class);

        this.declaredConsumes = ControllerUtil.getConsumes(method);
        validateConsumes(engines.getContentTypes());

        this.declaredProduces = ControllerUtil.getProduces(method);
        validateProduces(engines.getContentTypes());

        this.declaredReturns = ControllerUtil.getReturns(method);
        validateDeclaredReturns();

        this.contentTypeSuffixes = configureContentTypeSuffixes(engines);
        configureMethodArgs(injector);

        this.isNoCache = ClassUtil.getAnnotation(method, NoCache.class) != null;
    }

    public Class getControllerClass() {
        return controllerClass;
    }

    public Method getControllerMethod() {
        return method;
    }

    public List getDeclaredConsumes() {
        return declaredConsumes;
    }

    public List getDeclaredProduces() {
        return declaredProduces;
    }

    public Collection getDeclaredReturns() {
        return declaredReturns;
    }

    @Override
    public void handle(Context context) {
        try {
            if (!canConsume(context)) {
                context.next();
                return;
            }

            log.trace("Processing '{}' RouteInterceptors", Util.toString(method));
            int preInterceptStatus = context.getResponse().getStatus();
            processRouteInterceptors(context);
            int postInterceptStatus = context.getResponse().getStatus();
            if (context.getResponse().isCommitted()) {
                log.debug("Response committed by RouteInterceptor");
                context.next();
                return;
            } else if (preInterceptStatus != postInterceptStatus && postInterceptStatus >= 300) {
                log.debug("RouteInterceptor set status code to {}, committing response",
                        context.getResponse().getStatus());
                context.getResponse().commit();
                context.next();
                return;
            }

            log.trace("Preparing '{}' arguments from request", Util.toString(method));
            Object[] args = prepareMethodArgs(context);

            log.trace("Invoking '{}'", Util.toString(method));
            Controller controller = controllerProvider.get();
            controller.setContext(context);

            specifyCacheControls(context);
            specifyContentType(context);

            Object result = method.invoke(controller, args);

            if (context.getResponse().isCommitted()) {
                log.debug("Response committed in {}", Util.toString(method));
            } else {
                if (Void.class == method.getReturnType()) {
                    // nothing to return, prepare declared Return for Void type
                    for (Return declaredReturn : declaredReturns) {
                        if (Void.class == declaredReturn.onResult()) {
                            context.status(declaredReturn.code());
                            validateResponseHeaders(declaredReturn, context);
                            break;
                        }
                    }
                } else {
                    // method declares a Return Type
                    if (result == null) {
                        // Null Result, prepare a NOT FOUND (404)
                        context.getResponse().notFound();

                        for (Return declaredReturn : declaredReturns) {
                            if (declaredReturn.code() == HttpConstants.StatusCode.NOT_FOUND) {
                                String message = declaredReturn.description();

                                if (!Strings.isNullOrEmpty(declaredReturn.descriptionKey())) {
                                    // retrieve localized message, fallback to declared message
                                    message = messages.getWithDefault(declaredReturn.descriptionKey(), message, context);
                                }

                                if (!Strings.isNullOrEmpty(message)) {
                                    context.setLocal("message", message);
                                }

                                validateResponseHeaders(declaredReturn, context);
                                break;
                            }
                        }

                    } else {
                        // send returned result
                        Class resultClass = result.getClass();
                        for (Return declaredReturn : declaredReturns) {
                            if (declaredReturn.onResult().isAssignableFrom(resultClass)) {
                                context.status(declaredReturn.code());
                                validateResponseHeaders(declaredReturn, context);
                                break;
                            }
                        }

                        if (result instanceof CharSequence) {
                            // send a charsequence (e.g. pre-formatted JSON, XML, YAML, etc)
                            CharSequence charSequence = (CharSequence) result;
                            context.send(charSequence);
                        } else if (result instanceof File) {
                            // stream a File resource
                            File file = (File) result;
                            context.send(file);
                        } else {
                            // send an object using a ContentTypeEngine
                            context.send(result);
                        }
                    }
                }
            }

            context.next();

        } catch (InvocationTargetException e) {
            // handles exceptions thrown within the proxied controller method
            Throwable t = e.getTargetException();
            if (t instanceof Exception) {
                Exception target = (Exception) t;
                handleDeclaredThrownException(target, method, context);
            } else if (t instanceof Error) {
                throw (Error) t;
            } else {
                log.error("Failed to handle controller method exception", t);
            }
        } catch (Exception e) {
            // handles exceptions thrown within this handle() method
            handleDeclaredThrownException(e, method, context);
        }
    }

    /**
     * Finds the named controller method.
     *
     * @param controllerClass
     * @param name
     * @return the controller method or null
     */
    protected Method findMethod(Class controllerClass, String name) {
        // identify first method which matches the name
        Method controllerMethod = null;
        for (Method method : controllerClass.getMethods()) {
            if (method.getName().equals(name)) {
                if (controllerMethod == null) {
                    controllerMethod = method;
                } else {
                    throw new FatalException("Found overloaded controller method '{}'. Method names must be unique!",
                            Util.toString(method));
                }
            }
        }

        return controllerMethod;
    }

    /**
     * Configures the content-type suffixes
     *
     * @param engines
     * @return acceptable content-type suffixes
     */
    protected Set configureContentTypeSuffixes(ContentTypeEngines engines) {
        if (null == ClassUtil.getAnnotation(method, ContentTypeBySuffix.class)) {
            return Collections.emptySet();
        }

        Set suffixes = new TreeSet<>();
        for (String suffix : engines.getContentTypeSuffixes()) {
            String contentType = engines.getContentTypeEngine(suffix).getContentType();
            if (declaredProduces.contains(contentType)) {
                suffixes.add(suffix);
            }
        }
        return suffixes;
    }

    /**
     * Configures the controller method arguments.
     *
     * @param injector
     */
    protected void configureMethodArgs(Injector injector) {

        Class[] types = method.getParameterTypes();
        extractors = new ArgumentExtractor[types.length];
        patterns = new String[types.length];
        for (int i = 0; i < types.length; i++) {
            final Parameter parameter = method.getParameters()[i];
            final Class collectionType;
            final Class objectType;
            if (Collection.class.isAssignableFrom(types[i])) {
                collectionType = (Class) types[i];
                objectType = getParameterGenericType(parameter);
            } else {
                collectionType = null;
                objectType = types[i];
            }

            // determine the appropriate extractor
            Class extractorType;
            if (FileItem.class == objectType) {
                extractorType = FileItemExtractor.class;
            } else {
                extractorType = ControllerUtil.getArgumentExtractor(parameter);
            }

            // instantiate the extractor
            extractors[i] = injector.getInstance(extractorType);

            // configure the extractor
            if (extractors[i] instanceof ConfigurableExtractor) {
                ConfigurableExtractor extractor = (ConfigurableExtractor) extractors[i];
                Annotation annotation = ClassUtil.getAnnotation(parameter, extractor.getAnnotationClass());
                if (annotation != null) {
                    extractor.configure(annotation);
                }
            }

            if (extractors[i] instanceof SuffixExtractor) {
                // the last parameter can be assigned content type suffixes
                SuffixExtractor extractor = (SuffixExtractor) extractors[i];
                extractor.setSuffixes(contentTypeSuffixes);
            }

            if (collectionType != null) {
                if (extractors[i] instanceof CollectionExtractor) {
                    CollectionExtractor extractor = (CollectionExtractor) extractors[i];
                    extractor.setCollectionType(collectionType);
                } else {
                    throw new FatalException(
                            "Controller method '{}' parameter {} of type '{}' does not specify an argument extractor that supports collections!",
                            Util.toString(method), i + 1, Util.toString(collectionType, objectType));
                }
            }

            if (extractors[i] instanceof TypedExtractor) {
                TypedExtractor extractor = (TypedExtractor) extractors[i];
                extractor.setObjectType(objectType);
            }

            if (extractors[i] instanceof NamedExtractor) {
                // ensure that the extractor has a proper name
                NamedExtractor namedExtractor = (NamedExtractor) extractors[i];
                if (Strings.isNullOrEmpty(namedExtractor.getName())) {
                    // parameter is not named via annotation
                    // try looking for the parameter name in the compiled .class file
                    if (parameter.isNamePresent()) {
                        namedExtractor.setName(parameter.getName());
                    } else {
                        log.error("Properly annotate your controller methods OR specify the '-parameters' flag for your Java compiler!");
                        throw new FatalException(
                                "Controller method '{}' parameter {} of type '{}' does not specify a name!",
                                Util.toString(method), i + 1, Util.toString(collectionType, objectType));
                    }
                }
            }
        }
    }

    /**
     * Validate that the parameters specified in the uri pattern are declared in the method signature.
     *
     * @param uriPattern
     * @throws FatalException if the controller method does not declare all named uri parameters
     */
    public void validateMethodArgs(String uriPattern) {
        Set namedParameters = new LinkedHashSet<>();
        for (ArgumentExtractor extractor : extractors) {
            if (extractor instanceof NamedExtractor) {
                NamedExtractor namedExtractor = (NamedExtractor) extractor;
                namedParameters.add(namedExtractor.getName());
            }
        }

        // validate the url specification and method signature agree on required parameters
        List requiredParameters = getParameterNames(uriPattern);
        if (!namedParameters.containsAll(requiredParameters)) {
            throw new FatalException("Controller method '{}' declares parameters {} but the URL specification requires {}",
                    Util.toString(method), namedParameters, requiredParameters);
        }
    }

    /**
     * Extracts the name of the parameters from a route
     * 

* /{my_id}/{my_name} *

* would return a List with "my_id" and "my_name" * * @param uriPattern * @return a list with the names of all parameters in the url pattern */ private List getParameterNames(String uriPattern) { List list = new ArrayList<>(); Matcher matcher = PATTERN_FOR_VARIABLE_PARTS_OF_ROUTE.matcher(uriPattern); while (matcher.find()) { // group(1) is the name of the group. Must be always there... // "/assets/{file}" and "/assets/{file: [a-zA-Z][a-zA-Z_0-9]}" // will return file. list.add(matcher.group(1)); } return list; } /** * Validates that the declared consumes can actually be processed by Fathom. * * @param fathomContentTypes */ protected void validateConsumes(Collection fathomContentTypes) { Set ignoreConsumes = new TreeSet<>(); ignoreConsumes.add(Consumes.ALL); // these are handled by the TemplateEngine ignoreConsumes.add(Consumes.HTML); ignoreConsumes.add(Consumes.XHTML); // these are handled by the Servlet layer ignoreConsumes.add(Consumes.FORM); ignoreConsumes.add(Consumes.MULTIPART); for (String declaredConsume : declaredConsumes) { if (ignoreConsumes.contains(declaredConsume)) { continue; } String consume = declaredConsume; int fuzz = consume.indexOf('*'); if (fuzz > -1) { // strip fuzz, we must have a registered engine for the unfuzzed content-type consume = consume.substring(0, fuzz); } if (!fathomContentTypes.contains(consume)) { if (consume.equals(declaredConsume)) { throw new FatalException("{} declares @{}(\"{}\") but there is no registered ContentTypeEngine for that type!", Util.toString(method), Consumes.class.getSimpleName(), declaredConsume); } else { throw new FatalException("{} declares @{}(\"{}\") but there is no registered ContentTypeEngine for \"{}\"!", Util.toString(method), Consumes.class.getSimpleName(), declaredConsume, consume); } } } } /** * Validates that the declared content-types can actually be generated by Fathom. * * @param fathomContentTypes */ protected void validateProduces(Collection fathomContentTypes) { Set ignoreProduces = new TreeSet<>(); ignoreProduces.add(Produces.TEXT); ignoreProduces.add(Produces.HTML); ignoreProduces.add(Produces.XHTML); for (String produces : declaredProduces) { if (ignoreProduces.contains(produces)) { continue; } if (!fathomContentTypes.contains(produces)) { throw new FatalException("{} declares @{}(\"{}\") but there is no registered ContentTypeEngine for that type!", Util.toString(method), Produces.class.getSimpleName(), produces); } } } /** * Validates the declared Returns of the controller method. If the controller method returns an object then * it must also declare a successful @Return with a status code in the 200 range. */ protected void validateDeclaredReturns() { boolean returnsObject = void.class != method.getReturnType(); if (returnsObject) { for (Return declaredReturn : declaredReturns) { if (declaredReturn.code() >= 200 && declaredReturn.code() < 300) { return; } } throw new FatalException("{} returns an object but does not declare a successful @{}(code=200, onResult={}.class)", Util.toString(method), Return.class.getSimpleName(), method.getReturnType().getSimpleName()); } } /** * Determines if the incoming request is sending content this route understands. * * @param context * @return true if the route handles the request accept/content-type */ protected boolean canConsume(Context context) { Set contentTypes = context.getContentTypes(); if (!declaredConsumes.isEmpty()) { if (declaredConsumes.contains(Consumes.ALL)) { log.debug("{} will handle Request because it consumes '{}'", Util.toString(method), Consumes.ALL); return true; } Set types = new LinkedHashSet<>(contentTypes); if (types.isEmpty()) { // Request does not specify a Content-Type so add Accept type(s) types.addAll(context.getAcceptTypes()); // Request can handle any type, so consume the Request if (types.contains("*") || types.contains("*/*")) { log.debug("{} will handle Request because it consumes '{}'", Util.toString(method), "*/*"); return true; } } for (String type : types) { if (declaredConsumes.contains(type)) { // explicit content-type match log.debug("{} will handle Request because it consumes '{}'", Util.toString(method), type); return true; } else { // look for a fuzzy content-type match for (String declaredType : declaredConsumes) { int fuzz = declaredType.indexOf('*'); if (fuzz > -1) { String fuzzyType = declaredType.substring(0, fuzz); if (type.startsWith(fuzzyType)) { log.debug("{} will handle Request because it consumes '{}'", Util.toString(method), type); return true; } } } } } if (types.isEmpty()) { log.warn("{} can not handle Request because neither 'Accept' nor 'Content-Type' are set and Route @Consumes '{}'", Util.toString(method), declaredConsumes); } else { log.warn("{} can not handle Request for '{}' because Route @Consumes '{}'", Util.toString(method), types, declaredConsumes); } return false; } return true; } protected void processRouteInterceptors(Context context) { if (routeInterceptors.isEmpty()) { return; } List chain = new ArrayList<>(); for (RouteHandler interceptor : routeInterceptors) { Route route = new Route(context.getRequestMethod(), context.getRequestUri(), interceptor); route.setName(StringUtils.format("{}<{}>", RouteInterceptor.class.getSimpleName(), route.getRouteHandler().getClass().getSimpleName())); RouteMatch match = new RouteMatch(route, null); chain.add(match); } Context subContext = new Context(context, chain); subContext.next(); } protected Object[] prepareMethodArgs(Context context) { Parameter[] parameters = method.getParameters(); if (parameters.length == 0) { return new Object[]{}; } Object[] args = new Object[parameters.length]; for (int i = 0; i < args.length; i++) { Parameter parameter = parameters[i]; Class type = parameter.getType(); ArgumentExtractor extractor = extractors[i]; Object value = extractor.extract(context); validateParameterValue(parameter, value); if (value == null || ClassUtil.isAssignable(value, type)) { args[i] = value; } else { String parameterName = ControllerUtil.getParameterName(parameter); throw new FathomException("Type for '{}' is actually '{}' but was specified as '{}'!", parameterName, value.getClass().getName(), type.getName()); } } return args; } protected void validateParameterValue(Parameter parameter, Object value) { if (value == null && parameter.isAnnotationPresent(Required.class)) { throw new RequiredException("'{}' is a required parameter!", ControllerUtil.getParameterName(parameter)); } if (value != null && value instanceof Number) { Number number = (Number) value; if (parameter.isAnnotationPresent(Min.class)) { // validate required minimum value Min min = parameter.getAnnotation(Min.class); if (number.longValue() < min.value()) { throw new RangeException("'{}' must be >= {}", ControllerUtil.getParameterName(parameter), min.value()); } } if (parameter.isAnnotationPresent(Max.class)) { // validate required maximum value Max max = parameter.getAnnotation(Max.class); if (number.longValue() > max.value()) { throw new RangeException("'{}' must be <= {}", ControllerUtil.getParameterName(parameter), max.value()); } } if (parameter.isAnnotationPresent(Range.class)) { Range range = parameter.getAnnotation(Range.class); if (number.longValue() < range.min()) { throw new RangeException("'{}' must be >= {}", ControllerUtil.getParameterName(parameter), range.min()); } if (number.longValue() > range.max()) { throw new RangeException("'{}' must be <= {}", ControllerUtil.getParameterName(parameter), range.max()); } } } } protected Class getParameterGenericType(Parameter parameter) { Type parameterType = parameter.getParameterizedType(); if (!ParameterizedType.class.isAssignableFrom(parameterType.getClass())) { throw new FathomException("Please specify a generic parameter type for '{}' of '{}'", ControllerUtil.getParameterName(parameter), Util.toString(method)); } ParameterizedType parameterizedType = (ParameterizedType) parameterType; try { Class genericClass = (Class) parameterizedType.getActualTypeArguments()[0]; return genericClass; } catch (ClassCastException e) { throw new FathomException("Please specify a generic parameter type for '{}' of '{}'", ControllerUtil.getParameterName(parameter), Util.toString(method)); } } /** * Specify Response cache controls. * * @param context */ protected void specifyCacheControls(Context context) { if (isNoCache) { log.debug("NoCache detected, response may not be cached"); context.getResponse().noCache(); } } /** * Specify the Response content-type by... *

    *
  1. setting the first Produces content type
  2. *
  3. negotiating with the Request if multiple content-types are specified in Produces
  4. *
* * @param context */ protected void specifyContentType(Context context) { if (!declaredProduces.isEmpty()) { // Specify first Produces content-type String defaultContentType = declaredProduces.get(0); context.getResponse().contentType(defaultContentType); if (declaredProduces.size() > 1) { // negotiate content-type from Request Accept/Content-Type context.negotiateContentType(); } } } protected void handleDeclaredThrownException(Exception e, Method method, Context context) { Class exceptionClass = e.getClass(); for (Return declaredReturn : declaredReturns) { if (exceptionClass.isAssignableFrom(declaredReturn.onResult())) { context.status(declaredReturn.code()); // prefer declared message to exception message String message = Strings.isNullOrEmpty(declaredReturn.description()) ? e.getMessage() : declaredReturn.description(); if (!Strings.isNullOrEmpty(declaredReturn.descriptionKey())) { // retrieve localized message, fallback to declared message message = messages.getWithDefault(declaredReturn.descriptionKey(), message, context); } if (!Strings.isNullOrEmpty(message)) { context.setLocal("message", message); } validateResponseHeaders(declaredReturn, context); log.warn("Handling declared return exception '{}' for '{}'", e.getMessage(), Util.toString(method)); return; } } if (e instanceof RuntimeException) { // pass-through the thrown exception throw (RuntimeException) e; } // undeclared exception, wrap & throw throw new FathomException(e); } protected void validateResponseHeaders(Return aReturn, Context context) { for (Class returnHeader : aReturn.headers()) { ReturnHeader header = ClassUtil.newInstance(returnHeader); String name = header.getHeaderName(); String defaultValue = header.getDefaultValue(); // FIXME need to expose getHeader in Pippo Response String value = null; //context.getHeader(name); if (value == null) { if (Strings.isNullOrEmpty(defaultValue)) { log.warn("No value specified for the declared response header '{}'", name); } else { context.setHeader(name, defaultValue); log.debug("No value specified for the declared response header '{}', defaulting to '{}'", name, defaultValue); } } } } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy