New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New controller concept #341

Merged
merged 8 commits into from Jan 16, 2017

Conversation

Projects
None yet
2 participants
@decebals
Copy link
Member

decebals commented Jan 12, 2017

After a hard work, the new controller concept is here :)

Because they are many things to say about this PR, and maybe some readers are not interested in technical details, I will start with a demo code:

  • Add routes and controllers in application
import ro.pippo.core.route.RouteContext;
import ro.pippo.core.route.RouteHandler;
import ro.pippo.metrics.Counted;
import ro.pippo.metrics.Timed;

public class MyApplication extends ControllerApplication {

    @Override
    protected void onInit() {
        // add routes for static content
        addPublicResourceRoute();
        addWebjarsResourceRoute();

        // add regular route with metrics support
        GET("/", new RouteHandler() {

            @Timed
            @Counted
            public void handle(RouteContext routeContext) {
                routeContext.send("Hello");
            }

        });

        // add other regular route
        GET("/test", routeContext -> routeContext.send("Test"));

        // add controller(s)
        addControllers(ContactsController.class);
    }

}
  • Create controller class
import ro.pippo.controller.Controller;
import ro.pippo.controller.ControllerRouter;
import ro.pippo.controller.GET;
import ro.pippo.controller.Named;
import ro.pippo.controller.NoCache;
import ro.pippo.controller.Path;
import ro.pippo.controller.Produces;
import ro.pippo.controller.extractor.Header;
import ro.pippo.controller.extractor.Param;
import ro.pippo.controller.extractor.Session;
import ro.pippo.demo.common.ContactService;
import ro.pippo.demo.common.InMemoryContactService;
import ro.pippo.metrics.Metered;
import ro.pippo.metrics.Timed;

import java.util.HashMap;
import java.util.Map;

@Path("/contacts")
public class ContactsController extends Controller {

    private ContactService contactService;

    public ContactsController() {
        contactService = new InMemoryContactService();
    }

    @GET
    @Named("all")
    @Metered
    public void index() {
        // inject "user" attribute in session
        getRouteContext().setSession("user", "decebal");

        getResponse()
            .bind("contacts", contactService.getContacts())
            .render("contacts");
    }

    @GET("/{id: [0-9]+}")
    @Named("uriFor")
    @Timed
    public void uriFor(@Param int id, @Param String action, @Header String host, @Session String user) {
        System.out.println("id = " + id);
        System.out.println("action = " + action);
        System.out.println("host = " + host);
        System.out.println("user = " + user);

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("id", id);
        parameters.put("action", action);
        ControllerRouter router = getApplication().getRouter();
        String uri = router.uriFor("uriFor", parameters);

        getResponse().send("id = " + id + "; uri = " + uri);
    }

    @GET("/text")
    @Named("text")
    @Produces(Produces.TEXT)
    @NoCache
    public void text() {
        getResponse().send(contactService.getContacts());
    }

}

That it's all. I think that it's simple and easy to read.
In the next comment I will come with details about the implementation.

@coveralls

This comment has been minimized.

Copy link

coveralls commented Jan 12, 2017

Coverage Status

Coverage decreased (-0.7%) to 18.126% when pulling bf2554c on new_controller into 5de31ed on master.

@decebals

This comment has been minimized.

Copy link
Member Author

decebals commented Jan 12, 2017

Now, the technical details.
First I want to thank @gitblit because his work inspired me to create this improvement.

So, I tried to obtain a scalable concept with as much as possible amount of code (interfaces, annotations, classes). I tried to make the controller to use the some concept available in pippo-core (for example you can see a controller like a route group and a controller's method like a route).
The whole work is based on three main concepts:

Extractors

With few words an extractor create a method parameter value from the RouteContext and the MethodParameter (an useful wrapper over Parameter class from Java).

For example, see the controller route defined with method:

void uriFor(@Param int id, @Param String action, @Header String host, @Session String user);

When a request comes for the above route, Pippo uses extractors to extract values for the method's parameters: id, action, host and user.
This PR comes with some builtin extractors that extract values from multiple locations:

  • Param extracts value from the request parameter (similar with request.getParamater())
  • Header extracts value from the request header
  • Session extracts value from a session's attribute
    They are also other builtin extractors that are not presented in this post.

The beauty of Pippo here is that you can define any extractor you want/need with no effort.
All you must to do is to implement MethodParameterExtractor interface:

public interface MethodParameterExtractor {

    /**
     * Returns true if this extractor is applicable to the given {@link MethodParameter}.
     */
    boolean isApplicable(MethodParameter parameter);

    /**
     * Extract a value from a {@link MethodParameter} for a specified {@link RouteContext}.
     */
    Object extract(MethodParameter parameter, RouteContext routeContext);

}

Example, BeanExtractor that create a bean from the request parameters:

@MetaInfServices
public class BeanExtractor implements MethodParameterExtractor {

    @Override
    public boolean isApplicable(MethodParameter parameter) {
        return parameter.isAnnotationPresent(Bean.class);
    }

    @Override
    public Object extract(MethodParameter parameter, RouteContext routeContext) {
        Class<?> parameterType = parameter.getParameterType();

        return routeContext.createEntityFromParameters(parameterType);
    }

}

After you create the new extractor you should add it to Pippo:

  • automatically (annotate your extractor with @MetaInfServices)
  • manually (via ControllerApplication.addExtractors(extractor) method)

You are not forced to create an Extractor using only annotation (it's not annotation driven).
Below, I present you a new extractor, FileItemExtractor that does't use annotation (it looks after parameter type).

@MetaInfServices
public class FileItemExtractor implements MethodParameterExtractor {

    @Override
    public boolean isApplicable(MethodParameter parameter) {
        return FileItem.class == parameter.getParameterType();
    }

    @Override
    public Object extract(MethodParameter parameter, RouteContext routeContext) {
        String name = parameter.getParameterName();

        return routeContext.getRequest().getFile(name);
    }

}

The signature for a possible controller's route that uses a FileItemExtractor can might be:

void upload(FileItem file);

In this mode you can create any extractor you wish.

Transformers

The RouteTransformer is another powerful concept in Pippo. It was added in pippo-core module, can be used in any regular (non controller) route, but it's very useful in controllers.
The signature of this concept is very simple:

/**
 * A {@code RouteTransformer} transform a route.
 * For example you can modify the route name or the route handler.
 * If the returned route is null then the route will be removed from the route list,
 * so the route will not be added to router. 
 */
public interface RouteTransformer {

    /**
     * Transform the supplied route and return a new replacement route.
     * If you want to disable/remove the supplied route you can returns null.
     *
     * @param route
     * @return a transformed route or null
     */
    Route transform(Route route);

}

A very simple transformer is NameTransformer:

@MetaInfServices
public class NameTransformer implements RouteTransformer {

    @Override
    public Route transform(Route route) {
        Method method = route.getAttribute("__controllerMethod");
        if (method == null) {
            // it's not a controller route; do nothing
            return route;
        }

        if (method.isAnnotationPresent(Named.class)) {
            Named named = method.getAnnotation(Named.class);
            String name = named.value();
            route.setName(name);
        }

        return route;
    }

}

The above transformer set the name of the route for a controller's route using @Named annotation.
The signature for a possible controller's route that uses a NameTransformer can might be:

@Named("upload")
void upload(FileItem file);

The route's name is important because you can refer a route using a simple name.

Another useful transformer is MetricsTranformer. It can be used for both routes types: regular and controller.
After you create the new extractor you should add it to Pippo:

  • automatically (annotate your extractor with @MetaInfServices)
  • manually (via Application.addRouteTransformer(transformer) method)

Routes compilation

A new method was introduced in the Router interface, Router.compileRoutes, with this description:

Compile each added route via {@code addRoute, addRouteGroup} and apply transformers.

The idea is that we add routes (Route objects that contain metadata about an endpoint: verb, path, handler, name) in our application and the Router "compile" these routes and apply all transformers available in application.
What means route compile? I will respond you with the implementation of this method in DefaultRouter:

private Route compileRoute(Route route) {
    String uriPattern = route.getUriPattern();
    String regex = getRegex(uriPattern);
    List<String> parameterNames = getParameterNames(uriPattern);

    Route compiledRoute = new Route(route);
    // add additional metadata as attributes
    compiledRoute.bind("__regex", regex);
    compiledRoute.bind("__pattern", Pattern.compile(regex));
    compiledRoute.bind("__parameterNames", parameterNames);

    return compiledRoute;
}    

So, a compiled route it's a "raw" route (added with addRoute, addRouteGroup) enhanced with some "private" attributes (regex, pattern, parameterNames), attributes that are used by the router to match a request url/uri with a route. I think that definition is simple.
You observed already that I introduced a new property in Route, attributes, to store additional metadata. You can add a new attribute to a route using Route.bind(name. value) method.
The attributes that started with "__" are private and they are produced and used by Pippo.
In Pippo I need to materialize two route types:

  • CompiledRoute
  • ControllerRoute
    I preferred to not create a class for each route type and, to model these types with private attributes (__regex, __pattern, __parameterNames for CompiledRoute and __controllerClass, __controllerMethod for ControllerRoute).
    If a route contains __regex attribute means that route is compiled. If a route contains __controllerClass means that route is a route from a controller.

If you have any question, I am happy to answer you.

That is all for now. Enjoy!

@coveralls

This comment has been minimized.

Copy link

coveralls commented Jan 14, 2017

Coverage Status

Coverage decreased (-0.6%) to 18.27% when pulling 018e8e3 on new_controller into 5de31ed on master.

@decebals

This comment has been minimized.

Copy link
Member Author

decebals commented Jan 14, 2017

In this comment I will talk about RouteInterceptor(s).

Interceptors

An interceptor is in fact a RouteHandler that is executed in front of controller route.
We can use interceptors in controllers to implement a cross cutting concern. As examples of simple cross cutting concerns we can enumerate the logging/audit and authorization.
So, lets try to create a logging interceptor that does the logging. The logging interceptor will be executed when a controller route (a method marked as route) annotated with @logging is executed.
First let's define the Logging annotation:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Interceptor(LoggingHandler.class)
public @interface Logging {
}

From above code you can see that I specified the interceptor (LoggingHandler) using the Interceptor annotation.

Our logic/business is implemented in LoggingHandler class:

public class LoggingHandler implements RouteHandler {

    @Override
    public void handle(RouteContext routeContext) {
        System.out.println("LoggingHandler.handle");
    }

}

The last step is to mark the controller route that we want to log:

@Path("/contacts")
public class ContactsController extends Controller {

    @GET("/")
    @Named("myRoute")
    @Logging
    public void myRoute(@Param int id, @Param String action) { ... }

}

It was enough to add the @Logging annotation on myRoute method.
If we run the application, in the console I see:

23:06:45 [qtp215595166-19] DEBUG ro.pippo.core.PippoFilter - Request GET '/contacts'
23:06:45 [qtp215595166-19] DEBUG ro.pippo.core.route.DefaultRouter - Found 1 route matches for GET '/contacts'
23:06:45 [qtp215595166-19] DEBUG ro.pippo.core.route.DefaultRouteContext - Executing 'myRoute' for GET '/contacts'
23:06:45 [qtp215595166-19] DEBUG ro.pippo.core.route.DefaultRouteContext - Executing 'Interceptor<LoggingHandler>' for GET '/contacts'
LoggingHandler.handle

You can change the status code of response from interceptor handler and if this new code is an error (> 300) then Pippo will we send (commit) the error to client.
Also, you can commit the response from interceptor handler. In both cases, Pippo will not call the controller route.

As an easy exercise you can try write a security interceptor handler RoleHandler that is activated by @RequireRole(<role_name>) annotation. If the caller does not have the role name specified in annotation then the interceptor handler will return 403 (response status code for unauthorized) to client.

Another trivial security related interceptor can be a CSRF protection:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Interceptor(CSRFHandler.class)
public @interface CSRF {
}

where class CSRFHandler already exists in ro.pippo.core.route package from pippo-core module.

That is all about interceptors. Enjoy!

@decebals

This comment has been minimized.

Copy link
Member Author

decebals commented Jan 16, 2017

In this comment I want to talk about the return value for a controller route.

A controller route method could return a value.
We have the following possible scenarios:

  • value is NULL then a NOT FOUND (404) is sent
  • value is NOT NULL and the type is an instanceof CharSequence
// send a char sequence (e.g. pre-formatted JSON, XML, YAML, etc)
CharSequence charSequence = (CharSequence) result;
routeContext.send(charSequence);
  • value is NOT NULL and the type is an instanceof File
// stream a File resource
File file = (File) result;
routeContext.send(file);
  • value is NOT NULL and the type is not CharSequence or File
// send an object using a ContentTypeEngine
routeContext.send(result);
@coveralls

This comment has been minimized.

Copy link

coveralls commented Jan 16, 2017

Coverage Status

Coverage decreased (-0.6%) to 18.264% when pulling 2184de0 on new_controller into 5de31ed on master.

@decebals decebals merged commit 0e00c99 into master Jan 16, 2017

2 of 3 checks passed

coverage/coveralls Coverage decreased (-0.6%) to 18.264%
Details
continuous-integration/travis-ci/pr The Travis CI build passed
Details
continuous-integration/travis-ci/push The Travis CI build passed
Details
@decebals

This comment has been minimized.

Copy link
Member Author

decebals commented Jan 20, 2017

The new Controller module may depend on the -parameters flag of the Java 8 javac compiler. This flag embeds the names of method parameters in the generated .class files.

By default Java 8 does not compile with this flag set so you must specify javac compiler arguments for Maven and your IDE.

<build>
  <plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <configuration>
            <source>1.8</source>
            <target>1.8</target>
            <compilerArguments>
                <parameters/>
            </compilerArguments>
        </configuration>
    </plugin>
  </plugins>
</build>

So, if you you compile with -parameters flag you can define the controller method like:

void invoice(@Param orderId) { ... }

instead of:

void invoice(@Param("orderId") orderId) { ... }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment