Create web controllers using annotation processor

Build your own framework — series part 3

Jacek Dubikowski
VirtusLab

--

Welcome back to the third part of our series on writing a framework on your own using an annotation processor in Java. In the previous two articles, we covered the basics of annotation processing and showed how to use it for dependency injection and declarative transaction handling. In this article, we will continue our journey by building on the knowledge we gained and exploring how to create a REST controller using the annotation processor mechanism.

Why REST controller? Because it is a popular feature. The three main Java app frameworks provide support for creating controllers through annotations.

By the end of this article, readers will understand how to use annotation processing to generate code for a REST controller.

Before diving into this article, I recommend reading or glancing through the previous articles in this series to familiarise yourself with the basics of annotation processors and the framework we have been building so far.

First article: Build your own framework using an annotation processor.

Second article: Transaction handling using an annotation processor.

So let’s start and learn how to create controllers using annotation processors in Java!

What are we going to build

Let me show you what we will achieve in the article.

A REST controller is a web application component that handles HTTP requests and responses using the REST architectural style.

Once you start the demo application from the event organisation domain, it will print the port you can use for communication.

Port: <port-value>

Thanks to that, you can easily interact with the app using, for example, curl:

-> % curl -v http://localhost:<port-value>/participate -d ‘{“eventId”: “id”, “participationId”: “partId”}’
> …
< HTTP/1.1 200 OK
< Date: Sat, 25 Feb 2023 11:01:18 GMT
< Content-Type: application/json
< Content-Length: 74
< Server: Jetty(11.0.13)
<
{“accepted”:{“eventId”:{“value”:”id”},”participantId”:{“value”:”partId”}}}

The class responsible for this response is ParticipationController:

The solution and its architecture

The Controller above wouldn’t work without proper annotation processing and some server (Jetty) that handles the traffic. Hence, I split the text into two parts. The first would cover the annotation processing part. The second would explain how it was used alongside Jetty. First of all, why I picked Jetty for my text? The answer is not complicated, it is one of the most popular servers out there, and I thought its API would serve my text well. Nevertheless, you can use any server.

Additionally, the architecture of the solution can follow the same simple division. Please look at the simplified diagram:

Web “class” diagram
Web “class” diagram

As you can see, the previously created and used BeanProcessor would use the new WebPlugin. The plugin is responsible for creating implementations of RequestHandler based on RequestHandle annotations in the code. Then the server side would pick it up using DI from the previous part and translate it to a working Jetty solution using FrameworkHandler class that extends Jetty native AbstractHandler.

Assumptions and verification

In the text, I will present a very simplistic approach to show you the idea that could lead to a fully-fledged framework. So proper and complex error handling, complicated path matching, resolving path variables, query params, and the request body is out of scope. I also do not care here for REST levels and HATEOAS. Nevertheless, I will show places where you can add code to handle that. The endpoint method can accept at most one parameter represented in the form of io.jd.framework.webapp.Request. I will discuss it later on.

To see if the code works, I created one acceptance test that, once working, would make us satisfied with the result we have achieved.

Annotation processing

As you surely noticed, a new annotation was introduced. The @RequestHandle is very simple and responsible for declaring the HTTP endpoint.

It is translated during annotation processing to the implementation of the RequestHandler interface.

So for any declared endpoint, there will be one corresponding RequestHandler generated automatically.

The autogenerated implementation for the endpoint is declared below:

would look like this:

  1. It is annotated with @Singleton so that our DI solution can inject it.
  2. The name consists of 4 parts, once split on the ‘$’ sign:
    1. ExampleController — the class name the endpoint was declared in.
    2. getIntFromString — the method’s name annotated with @RequestHandle
    3. 1 — ordinal number, provided for cases when two methods have the same name but different parameter lists.
    4. handler — an indicator that it is just a handler.
  3. The controller field is used to do the actual processing.
  4. The process method that uses the controller to process the request returns Object so the solution can be flexible.

Now that we have covered the outcome of the annotation processing, it’s time to see how the annotation processor does this. This process is crucial to understanding how our framework works. So, let’s delve into the magic.

Annotation processor work

First, lets us see the WebPlugin. It extends ProcessorPlugin, introduced in the previous article, to split the processor’s responsibility among plugins.

The code is reasonably simple (error logging is skipped):

The process method founds methods annotated with RequestHandle. Then group the methods by the class they are declared in and the method name. The methods grouped that way become the building block for creating the handlers.

  1. NameData record is just a container for the name of the class the method was declared in and the method name. It also provides a method to create a handler implementation name once provided with an index value.
  2. In the first step, we enumerate the methods that share a common class and method name.
  3. In the handle method, the type is created using HandlerWriter and creates the JavaFile result, merging it with the package data.
  4. The createType method is just called to the HandlerWriter with a class name to be created, the element representing the annotated method, the controller’s name and the RequestHandle annotation instance of the method.

As you can see, no code generation is done in the plugin itself. The whole JavaPoet soup is in the HandlerWriter. So let’s see it. However, it will try to minimise the JavaPoet boilerplate to the minimum. If you read this, I think you get the JavaPoet’s idea.

All the fields are populated in the constructor that I omitted. The constructor of the handler being written accepts just the Controller that contains the method to be called in the process method.

  1. The requestType field represents the compile-time type of the Request. The endpoint must be able to accept some parameters and get the data out of it, hence the class. In our simplified context, the interface is simplistic.
    public interface Request {
    String body();
    }
    This interface should have multiple methods to access headers, body and other stuff in the real world.
  2. In the first part of the process method, the controller code is prepared, and then we check whether the controller is supposed to return something.
  3. The value-returning case is straightforward. The process method returns the result of the call to the controller.
  4. The void method is a little different. We need to return something to be used for the HTTP response, but there is nothing. What was I supposed to do? I introduced the Response interface to solve the problem. The interface is simple and provides one utility method to create a response with no body and 204 No Content status.
    public interface Response {
    int statusCode();
    String body();
    static Response noContent() {…} // instance that returns body as null and 204 status code
    }
    But why return the Object from the method as there is a proper type for it? The solution is very elastic, and the return type can be interpreted later in the code. Thanks to that, we can provide reasonable default behaviour when a return type is just a regular object. You will see the matching code shortly, don’t worry.
  5. The controllerCall is a straightforward method in our case. We start by validating if there is at most one parameter of the expected type. This is where you can provide extensive support for request bodies, path variables, and query parameters.

This is it for the annotation processing side. Let us dive deep into the server side now.

Server-side

As mentioned, you only need to provide a matching glue code for Jetty, which would work.

Let’s start with translating our handlers to Jetty handlers. So please take a look at FrameworkHandler.

This is also a straightforward solution. It is far from a production solution but serves its purpose. This is the part in the code where error handling could be provided and serialisation and deserialisation if needed. As mentioned before, this is the place where the code will differentiate between return types. The Response type would be treated differently than any other type of object.

  1. The first important thing is to match the path. Our framework doesn’t support the path variables or path patterns, so the matching is straightforward. This is the place where you could implement more complex matching to be able to provide a production-ready solution.
  2. Some basic attributes are passed to the response, like encoding and content type (from the handler).
  3. We create a simple instance of the Request that would be used to call the handler and call the RequestHandler#process method. Then we interpret the response.
  4. If the result of the handler call is a Response instance, then we translate its status code and body to the response.
  5. If the result is not a Response instance, we return status 200 OK and write the value to the response the way it is. In this example, the caller should worry if the object would be written as proper JSON, but in the real world, it is the framework’s job.
    In case of any error 500 status code with a JSON error body is returned to the caller.

Having all that in mind, we can look at the ServerContainer.

The server container is just a utility.

  1. In the constructor, the RequestHandlers are mapped to FrameworkHandlers.
  2. Once a server is started using, who would guess, the start method, handlers are passed to it for traffic handling.
  3. After the start, the port method returns the port the server listens to.
  4. The stop method also serves as a name state.

From now on, all tests pass, and the example application works. If you want to write the code to use it, ask BeanProvider for an instance of the ServerContainer and run the server as in the example here.

Summary

Congratulations! You made it to the end of our journey together (not just the web part). It’s been a pleasure having you join me on this coding adventure.

This series of articles explored creating an educational Java framework that relies on annotation processing. It all started by creating a dependency injection framework. Subsequently, we incorporated the @Transactional annotation to support transaction management. In the final stage, we enriched our framework with a web component by creating HTTP endpoints using annotations. I sincerely hope you appreciate the framework as much as I do now. While I understand it will never be put to practical use, it’s simple, easy to explain, and a great way to learn something new.

Throughout this journey, we learned much about the annotation processing API and its potential. We discovered that the annotation processor is a flexible tool with limits. However, once you get used to the API, changes can be introduced rapidly and efficiently.

Remarkably, metaprogramming capabilities were integrated without the need to learn a separate meta-language, and the code was written in full Java using IDE support. I noticed that the complexity of the API is on par with the Reflection API, which is widely spread yet working in runtime (so you cannot see the effects of your metaprogramming directly).

Evidently, there is also a gain in the speed of the code as reflection tends to be slow and annotation processing is fast enough to go unnoticed during project compilation. Additionally, debugging tends to be simpler as the source code exists and can be checked, contrary to, e.g. proxies created in runtime.

Furthermore, I expect the usage of the annotation processor to grow in the coming years as it has already been adopted by Micronaut and Quarkus frameworks. The annotation processor approach seems to click with GraalVM and native Java applications, giving the processor chance to shine in the cloud environment.

I hope I have convinced you that better, more efficient, and more robust frameworks can be created using the approach. I would love to hear that the text inspired you to take the next step by attempting to create your own annotation processor someday. Remember, the possibilities are limitless, the coding landscape is constantly changing, and Java is also. As JDK continues to evolve, new features could enhance the usefulness of the annotation processor even further.

--

--