A note when examining Javalin

Overview

** What is Javalin **

Javalin is a lightweight web framework for Java and Kotlin. Forked from a web framework called Spark Framework, version 0.0.1 was released in May 2017, and 3.4.1 was released as of August 2019. The main features are quoted from GitHub below. (Japanese translation is Google Translate)

https://github.com/tipsy/javalin

Javalin is more of a library than a framework. Some key points:

Javelin is a library rather than a framework. Some key points:

The first half of this article is a memo about the implementation of an application that displays "Hello World" with the minimum configuration, and the second half is a memo of what I investigated / tried in the implementation of a simple Web application.

environment

reference

Hello World with minimal configuration

Project structure

It's a normal Maven project.

[project_root]
 |
 `--- /src
 |      |
 |      `--- /main
 |      |      |
 |      |      `--- /java
 |      |      |      |
 |      |      |      `--- com.example.demo (package)
 |      |      |            |
 |      |      |            `--- App.java
 |      |      |
 |      |      `--- /resources
 |      |
 |      `--- /test
 |             |
 |             `--- /java
 |             |
 |             `--- /resources
 |
 `--- pom.xml

pom.xml

The required dependencies for a minimal Javalin application are javalin and slf4j-simple.

pom.xml


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>demo-java12-javalin3</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>demo java12 javalin 3.4.1</name>
    <description>Demo project for Javalin 3 with Java12</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>12</java.version>
        <maven.clean.version>3.1.0</maven.clean.version>
        <maven.resources.version>3.1.0</maven.resources.version>
        <maven.compiler.version>3.8.1</maven.compiler.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
        <maven.compiler.showWarnings>true</maven.compiler.showWarnings>
        <maven.jar.version>3.1.1</maven.jar.version>
        <maven.assembly.version>3.1.1</maven.assembly.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>io.javalin</groupId>
            <artifactId>javalin</artifactId>
            <version>3.4.1</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.26</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-clean-plugin</artifactId>
                <version>${maven.clean.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>${maven.resources.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>${maven.compiler.version}</version>
                <configuration>
                    <compilerArgs>
                        <arg>-Xlint:all</arg>
                    </compilerArgs>
                    <release>${java.version}</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>${maven.jar.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>${maven.assembly.version}</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifest>
                            <mainClass>com.example.demo.App</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Implementation of application launch point

Implement the Javalin application launch point in the main method of the App class (class name is arbitrary).

import io.javalin.Javalin;

public class App {
  public static void main(String ... args) {
    // (1)
    Javalin app = Javalin.create().start(7000);
    // (2)
    app.get("/", ctx -> ctx.result("Hello World"));
  }
}

Launch from IDE

When you execute the App.main method, Javalin will start and the following log will be output to the console. As you can see in the log, it took less than a second to boot in my development environment. When you access http: // localhost: 7000, you will get a text / plain response that says" Hello World ".

[main] INFO io.javalin.Javalin - 
           __                      __ _
          / /____ _ _   __ ____ _ / /(_)____
     __  / // __ `/| | / // __ `// // // __ \
    / /_/ // /_/ / | |/ // /_/ // // // / / /
    \____/ \__,_/  |___/ \__,_//_//_//_/ /_/

        https://javalin.io/documentation

[main] INFO org.eclipse.jetty.util.log - Logging initialized @827ms to org.eclipse.jetty.util.log.Slf4jLog
[main] INFO io.javalin.Javalin - Starting Javalin ...
[main] INFO io.javalin.Javalin - Listening on http://localhost:7000/
[main] INFO io.javalin.Javalin - Javalin started in 731ms \o/

Start from jar file

When you build, a jar file called demo-java12-javalin3-1.0.0-SNAPSHOT-jar-with-dependencies.jar will be generated under the target directory. Javalin applications use embedded Jetty, so you can start your web application with java -jar <jar file>.

> java --version
openjdk 12.0.2 2019-07-16
OpenJDK Runtime Environment (build 12.0.2+10)
OpenJDK 64-Bit Server VM (build 12.0.2+10, mixed mode, sharing)

> java -jar demo-java12-javalin3-1.0.0-SNAPSHOT-jar-with-dependencies.jar

The following is an example of changing the default locale with the JVM parameters.

> java -Duser.language=en -Duser.country=US -jar demo-java12-javalin3-1.0.0-SNAPSHOT-jar-with-dependencies.jar

This completes the implementation of the "Hello World with minimum configuration" application.

About Javalin features

From here, it will be a memo when implementing a simple web application with Javalin.

How to implement HTTP handler

The GET request handler (Pattern 1 below) written above can also be implemented as Pattern 2 and Pattern 3.

pattern 1

app.get("/", ctx -> ctx.result("Hello World"));

** Pattern 2 **

public class App {

  void index2(@NotNull Context ctx) {
    ctx.result("Hello World2");
  }

  public static void main(String ... args) {
    Javalin app = Javalin.create().start(7000);
    App handler = new App();
    app.get("/2", handler::index2);
  }

}

** Pattern 3 **

In the tutorial on the official site, there is also an example described in the static field.

public class App {

  static Handler index3 = ctx -> {
    ctx.result("Hello World3");
  };

  public static void main(String ... args) {
    Javalin app = Javalin.create().start(7000);
    app.get("/3", App.index3);
  }

}

HTTP handler

Besides GET, there are POST, PUT, PATCH, DELETE, HEAD and OPTIONS.

HTTP method Signature
GET public Javalin get(@NotNull String path, @NotNull Handler handler)
POST public Javalin post(@NotNull String path, @NotNull Handler handler)
PUT public Javalin put(@NotNull String path, @NotNull Handler handler)
PATCH public Javalin patch(@NotNull String path, @NotNull Handler handler)
DELTE public Javalin delete(@NotNull String path, @NotNull Handler handler)
HEAD public Javalin head(@NotNull String path, @NotNull Handler handler)
OPTIONS public Javalin options(@NotNull String path, @NotNull Handler handler)

before / after endpoint

You can set before / after endpoints to run before and after the HTTP handler.

end point Signature
before public Javalin before(@NotNull String path, @NotNull Handler handler)
before public Javalin before(@NotNull Handler handler)
after public Javalin after(@NotNull String path, @NotNull Handler handler)
after public Javalin after(@NotNull Handler handler)

HTTP handler grouping

end point HTTP method function
/api/v1/users GET Get user list
/api/v1/user/:id GET Get user by specifying ID
/api/v1/user POST Register user
/api/v1/user/:id DELETE Delete user by specifying ID
/api/v2/users GET Get user list
/api/v2/user/:id GET Get user by specifying ID
/api/v2/user POST Register user
/api/v2/user/:id DELETE Delete user by specifying ID

If you have two such endpoints, v1 and v2, you can group and define HTTP handlers as shown below.

UserController v1Controller = /*Version 1 implementation*/
UserController v2Controller = /*Version 2 implementation*/

app.routes(() -> {

  // version 1
  ApiBuilder.path("/api/v1", () -> {
    // GET /api/v1/users
    ApiBuilder.get("/users", v1Controller::findAll);
    ApiBuilder.path("/user", () -> {
      // GET /api/v1/user/:id
      ApiBuilder.get(":id", v1Controller::findById);
      // POST /api/v1/user
      ApiBuilder.post(v1Controller::store);
      // DELETE /api/v1/user/:id
      ApiBuilder.delete(":id", v1Controller::remove);
    });
  });

  // version 2
  ApiBuilder.path("/api/v2", () -> {
    // GET /api/v2/users
    ApiBuilder.get("/users", v2Controller::findAll);
    ApiBuilder.path("/user", () -> {
      // GET /api/v2/user/:id
      ApiBuilder.get(":id", v2Controller::findById);
      // POST /api/v2/user
      ApiBuilder.post(v2Controller::store);
      // DELETE /api/v2/user/:id
      ApiBuilder.delete(":id", v2Controller::remove);
    });
  });

});

About io.javalin.http.Context

The HTTP handler method parameter ʻio.javalin.http.Context` class provides the functionality and data needed for HTTP requests and responses. The following is a quote from the document page of the official site.

https://javalin.io/documentation#context

The Context object provides you with everything you need to handle a http-request. It contains the underlying servlet-request and servlet-response, and a bunch of getters and setters. The getters operate mostly on the request-object, while the setters operate exclusively on the response object.

The Context also has req and res fields that hold instances of HttpServletRequest and HttpServletResponse.

javax.servlet.http.HttpServletRequest request = ctx.req;
javax.servlet.http.HttpServletResponse response = ctx.res;

How to receive request parameters

Query parameters

A method that receives the parameters specified in the query string

Signature
String queryParam(String key)
String queryParam(String key, String default)
List<String> queryParams(String key)
Map<String, List<String>> queryParamMap()
Validator<T> queryParam(String key, Class<T> clazz)

Form parameters

Forms have methods similar to query parameters. Unfortunately it doesn't seem to be possible to bind the fields of the entire form to any object.

Signature
String formParam(String key)
String formParam(String key, String default)
List<String> formParams(String key)
Map<String, List<String>> formParamMap()
Validator<T> formParam(String key, Class<T> clazz)

Path parameters

A method that receives a part of it as a parameter from a path like / api / user / 1

Signature
String pathParam(String key)
Map<String, String> pathParamMap()
Validator<T> pathParam(String key, Class<T> clazz)

The part of the request handler path with a colon (:) is identified as the path parameter.

app.get("/api/user/:id", ctx -> {
  Long userId = ctx.pathParam("id", Long.class).get();
});

Javelin application configuration

You can customize it by passing an instance of the JavalinConfig class to the Javalin.create method. In addition to the method of passing an instance of JavalinConfig as shown below, there is also a method of writing with a lambda expression.

instance

configuration


JavalinConfig config = new JavalinConfig();
// configuration
Javalin
    .create(config)
// ...abridgement...

** lambda expression **

configuration


Javalin
    .create(config -> {
        // configuration
    })
// ...abridgement...

Main configurations

Context path setting (default is "/")

config.contextPath = "/app";

Setting the default content type (default is "text / plain")

config.defaultContentType = "application/json";

ETag setting (default is false)

config.autogenerateEtags = true;

The output of the request log can be set with JavalinConfig.requestLogger. The second argument (execTime) is the time taken for the request (ms).

configuration


config.requestLogger((ctx, execTime) -> {
  LOG.debug("[{}] {} - {} ms.", ctx.fullUrl(), ctx.userAgent(), execTime);
});

Development configuration

Some features are useful to enable during development.

enableDevLogging

configuration


config.enableDevLogging();

Since there is a lot of information, I think that it will be used to enable it when investigating when a problem occurs, rather than always enabling it.

[qtp2092769598-18] INFO io.javalin.Javalin - JAVALIN REQUEST DEBUG LOG:
Request: GET [/overview]
    Matching endpoint-handlers: [GET=/overview]
    Headers: {Cookie=Idea-c29ae3c1=53d6d99f-1169-42ed-8d69-a63cfe80a87a, Accept=text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3, Upgrade-Insecure-Requests=1, Connection=keep-alive, User-Agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36, Sec-Fetch-Site=none, Host=localhost:7000, Sec-Fetch-User=?1, DNT=1, Accept-Encoding=gzip, deflate, br, Accept-Language=ja,en-US;q=0.9,en;q=0.8,en-GB;q=0.7, Sec-Fetch-Mode=navigate}
    Cookies: {Idea-c29ae3c1=53d6d99f-1169-42ed-8d69-a63cfe80a87a}
    Body: 
    QueryString: null
    QueryParams: {}
    FormParams: {}
Response: [200], execution took 26.37 ms
    Headers: {Server=Javalin, Content-Encoding=gzip, Date=Wed, 21 Aug 2019 13:44:46 GMT, Content-Type=text/html}
    Body is gzipped (4464 bytes, not logged)
----------------------------------------------------------------------------------

RouteOverviewPlugin

configuration


config.registerPlugin(new RouteOverviewPlugin("/overview"));

You can see the list of request handlers added to your Javalin application by registering this plugin and accessing http: // localhost: 7000 / overview. overview.png

Customize error response

There is an exception class corresponding to the HTTP status that inherits Javalin's HttpResponseException.

Status Exception class message
302 RedirectResponse Redirected
400 BadRequestResponse Bad request
401 UnauthorizedResponse Unauthorized
403 ForbiddenResponse Forbidden
404 NotFoundResponse Not found
405 MethodNotAllowedResponse Method not allowed
409 ConflictResponse Conflict
410 GoneResponse Gone
500 InternalServerErrorResponse Internal server error
502 BadGatewayResponse Bad gateway
503 ServiceUnavailableResponse Service unavailable
504 GatewayTimeoutResponse Gateway timeout

Handle unique exceptions

You can handle application-specific exceptions and return arbitrary error responses. If an application-specific exception called AppServiceException below is thrown,

AppServiceException


public class AppServiceException extends RuntimeException {
  public AppServiceException() {
  }
  public AppServiceException(String message) {
    super(message);
  }
  public AppServiceException(String message, Throwable cause) {
    super(message, cause);
  }
}

To handle this exception and return the HTTP status, map the exception to your Javalin application. This example returns only 500 status.

app.exception(AppServiceException.class, (e, ctx) -> {
  ctx.status(500);
});

To return an arbitrary error response message and HTTP status, implement as follows. You can also return a json format message by using the json method instead of the result method.

app.exception(AppServiceException.class, (e, ctx) -> {
  ctx.result("Application Error : " + e.getMessage()).status(500);
});

Handle json in request / response

Add jackson to the dependency so that the request / response can handle json. The second jackson-datatype-jsr310 is needed when working with the Java 8 Date and Time API.

pom.xml


<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.10.0.pr1</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.10.0.pr1</version>
</dependency>

Implement a GET request handler that responds to json. Use the json method to respond to json.

public static void main(String ... args) {
  // ...abridgement

  // (1)
  ObjectMapper objectMapper = new ObjectMapper();
  objectMapper.findAndRegisterModules();
  objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"));
  // (2)
  JavalinJackson.configure(objectMapper);

  // (3)
  app.get("/json", ctx -> {
    Map<String, Object> model = new HashMap<>();
    model.put("message", "Hello World");
    model.put("now", LocalDateTime.now());
    //Use the json method.
    ctx.json(model);
  });

  // (4)
  app.post("/json", ctx -> {
    Map<String, Object> model = ctx.bodyAsClass(Map.class);
    //Returns HTTP Status 200.
    ctx.status(200);
  });

}

Deliver static files

To distribute the files placed on the classpath, set in JavalinConfig.

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /resources
                      |
                      `--- /public
                             |
                             `--- index.html

To deliver ʻindex.html` placed in the above location, specify the path in JavalinConfig.addStaticFiles.

configuration


config.addStaticFiles("/public");

Comment out the following code because the GET request handler and path for the / added in the "Hello World with minimum configuration" application will be covered.

// app.get("/", ctx -> ctx.result("Hello World"));

If you start the Javalin application in this state and access http: // localhost: 7000 / index.html or http: // localhost: 7000, public / index.html will be returned.

You can also work with directories outside the classpath. For example, if you want to handle files under the C: \ var \ static directory as shown below,

C:\var
    |
    `--- \static
           |
           `--- \images
                  |
                  `--- sample_1.jpg

Specify the absolute path in JavalinConfig.addStaticFiles. If the path specified here cannot be accessed, an error will occur when the application is started.

configuration


config.addStaticFiles("C:\\var\\static", Location.EXTERNAL);

Specify the path in the html file as shown below.

<img src="/images/sample_1.jpg " />

Use WebJars

Add the library you want to use for the dependency of pom.xml. In this example, we used jQuery.

pom.xml


<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>3.4.1</version>
</dependency>

This alone will not enable jQuery. Enable Webjars in JavalinConfig.enableWebjars to make them available to your application.

configuration


config.enableWebjars();

Specify the path in the html file as shown below.

<script src="/webjars/jquery/3.4.1/jquery.min.js"></script>

Use logback for logging

Use slf4j and logback for logging. Since slf4j-api already depends on Javalin, we only need to add logback-classic. Also, comment out the slf4j-simple that was added first.

pom.xml


<!--
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>1.7.26</version>
</dependency>
-->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
    <scope>runtime</scope>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>

Create the logback configuration file logback.xml in the following location.

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /resources
                      |
                      `--- logback.xml

logback.xml

logback.xml


<?xml version="1.0" encoding="UTF-8"?>
<configuration status="INFO">
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <Pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Pattern>
        </encoder>
    </appender>

    <logger name="com.example.demo" level="debug" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>

    <logger name="org.jooq" level="info" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>
    <logger name="org.thymeleaf" level="info" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>
    <root level="info">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

Application log output

Define the logger in the class you want to output the log as follows.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class App {
  static final Logger LOG = LoggerFactory.getLogger(App.class);

  // ...abridgement...

}

Use Thymeleaf as a template engine

Javalin makes it easy to use template engines such as Velocity, Freemarker, and Thymeleaf. This example uses Thymeleaf.

pom.xml


<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf</artifactId>
    <version>3.0.9.RELEASE</version>
</dependency>

Place a template file called hello_world.html in the following location. The property file with the same file name is the message resource.

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /resources
                      |
                      `--- /WEB-INF
                             |
                             `--- /templates
                                   |
                                   `--- hello_world.html
                                   `--- hello_world.properties
                                   `--- hello_world_ja.properties
                                   `--- hello_world_en.properties

hello_world.html

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <script th:src="@{/webjars/jquery/3.4.1/jquery.min.js}"></script>
    <title>Hello World</title>
</head>
<body>
<div>
    <p th:text="${message}">message</p>
    <p th:text="${now}">now</p>
    <p th:text="#{msg.hello}">message</p>
</div>
</body>
</html>

Message resource definition

hello_world.properties


msg.hello = Hello World (default)

hello_world_en.properties


msg.hello = Hello World

hello_world_ja.properties


msg.hello =Hello World

Use the render method to respond using the template engine. Which template engine is used is determined by the extension of the template file. In the case of Thymeleaf, .html, .tl, .thyme, .thymeleaf are applicable.

app.get("/hello", ctx -> {
  Map<String, Object> model = new HashMap<>();
  model.put("message", "Hello World");
  model.put("now", LocalDateTime.now());
  ctx.render("/WEB-INF/templates/hello_world.html", model);
});

When you access localhost: 7000 / hello, you will see the html page drawn by the template engine.

Switch messages with Accept-Language in your browser

By default, the default locale when the application starts determines the message resource. To use the message resource of a certain language, specify the locale in the JVM option when starting the Javalin application. For example, to use the message resource of en, specify the following options.

> java -Duser.language=en -Duser.country=US -jar <jar file>

To use the message resource corresponding to Accept-Language of the browser like Spring Boot, create a class that implements FileRenderer.

CustomThymeleafRenderer


import io.javalin.http.Context;
import io.javalin.plugin.rendering.FileRenderer;
import org.jetbrains.annotations.NotNull;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;

import java.util.Map;
import java.util.concurrent.TimeUnit;

public class CustomThymeleafRenderer implements FileRenderer {

  private final TemplateEngine templateEngine;

  public CustomThymeleafRenderer() {
    templateEngine = templateEngine();
  }

  @Override
  public String render(@NotNull String filePath, @NotNull Map<String, Object> model, @NotNull Context ctx) {
    WebContext context = new WebContext(ctx.req, ctx.res, ctx.req.getServletContext(), ctx.req.getLocale());
    context.setVariables(model);
    return templateEngine.process(filePath, context);
  }

  private TemplateEngine templateEngine() {
    TemplateEngine templateEngine = new TemplateEngine();
    templateEngine.setTemplateResolver(templateResolver());
    templateEngine.addDialect(new Java8TimeDialect());
    return templateEngine;
  }

  private ITemplateResolver templateResolver() {
    ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
    templateResolver.setPrefix("/WEB-INF/templates/");
    templateResolver.setSuffix(".html");
    templateResolver.setCharacterEncoding("UTF-8");
    templateResolver.setCacheable(true);
    templateResolver.setCacheTTLMs(TimeUnit.MINUTES.toMillis(30));
    templateResolver.setTemplateMode(TemplateMode.HTML);
    return templateResolver;
  }

}

Register this CustomThymeleafRenderer with your Javalin application.

JavalinRenderer.register(new CustomThymeleafRenderer(), ".html");

With this customization, it is not necessary to specify the path of the template file, so the handler implementation is as follows.

// ctx.render("/WEB-INF/templates/hello_world.html", model);
ctx.render("hello_world.html", model);

By the way, the renderer registered by default is as follows.

JavalinRenderer


object JavalinRenderer {

  init {
    register(JavalinVelocity, ".vm", ".vtl")
    register(JavalinFreemarker, ".ftl")
    register(JavalinMustache, ".mustache")
    register(JavalinJtwig, ".jtwig", ".twig", ".html.twig")
    register(JavalinPebble, ".peb", ".pebble")
    register(JavalinThymeleaf, ".html", ".tl", ".thyme", ".thymeleaf")
    register(JavalinCommonmark, ".md", ".markdown")
  }

  // ...abridgement...

}

Use the database

I used MySQL 8.0 for the database, jOOQ for the ORM, and HikariCP for the connection pool.

Database preparation

Create the sample_db database and the test_user user for use in your Javalin application as a user with administrator privileges.

CREATE DATABASE IF NOT EXISTS sample_db
  CHARACTER SET = utf8mb4
  COLLATE = utf8mb4_general_ci
;

CREATE USER IF NOT EXISTS 'test_user'@'localhost'
  IDENTIFIED BY 'test_user'
  PASSWORD EXPIRE NEVER
;

GRANT ALL ON sample_db.* TO 'test_user'@'localhost';

Then create the ʻuser table with the test_user` user.

DROP TABLE IF EXISTS user;
CREATE TABLE user (
  id BIGINT AUTO_INCREMENT                    COMMENT 'User ID',
  nick_name VARCHAR(60) NOT NULL              COMMENT 'nickname',
  sex CHAR(1) NOT NULL                        COMMENT 'Gender M:Male F:Female',
  prefecture_id TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Prefecture 0:Unknown, 1:Hokkaido- 8:Kyushu-Okinawa',
  email VARCHAR(120)                          COMMENT 'mail address',
  memo TEXT                                   COMMENT 'Remarks column',
  create_at DATETIME NOT NULL DEFAULT NOW(),
  update_at DATETIME NOT NULL DEFAULT NOW(),
  PRIMARY KEY (id)
)
ENGINE = INNODB
DEFAULT CHARSET = UTF8MB4
COMMENT = 'User table';

CREATE INDEX idx_sex on user (sex) USING BTREE;
CREATE INDEX idx_pref on user (prefecture_id) USING BTREE;

Add dependency

Edit pom.xml and add the following dependencies.

pom.xml


<dependency>
    <groupId>org.jooq</groupId>
    <artifactId>jooq</artifactId>
    <version>3.11.11</version>
</dependency>
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>3.3.1</version>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.17</version>
    <scope>runtime</scope>
</dependency>

Automatic code generation

jOOQ has a function to automatically generate model (entity) code from a database schema called jooq-codegen-maven plugin.

<plugin>
    <groupId>org.jooq</groupId>
    <artifactId>jooq-codegen-maven</artifactId>
    <version>3.11.11</version>
    <executions>
        <execution>
            <id>jooq-codegen</id>
            <phase>generate-sources</phase>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.17</version>
        </dependency>
    </dependencies>
    <configuration>
        <jdbc>
            <url>jdbc:mysql://localhost:3306</url>
            <user>test_user</user>
            <password>test_user</password>
        </jdbc>
        <generator>
            <database>
                <name>org.jooq.meta.mysql.MySQLDatabase</name>
                <includes>.*</includes>
                <inputSchema>sample_db</inputSchema>
                <!-- Configure type overrides for generated fields, attributes, sequences, parameters. -->
                <forcedTypes>
                    <forcedType>
                        <!--Specify the Java type of your custom type. This corresponds to the Converter's <U> type. -->
                        <userType>com.example.demo.converter.Prefecture</userType>
                        <!-- A converter implementation for the {@link #getUserType(). -->
                        <converter>com.example.demo.converter.PrefectureConverter</converter>
                        <!-- regex to match the column name -->
                        <expression>PREFECTURE_ID</expression>
                        <types>.*</types>
                    </forcedType>
                    <forcedType>
                        <userType>com.example.demo.converter.Sex</userType>
                        <converter>com.example.demo.converter.SexConverter</converter>
                        <expression>SEX</expression>
                        <types>.*</types>
                    </forcedType>
                </forcedTypes>
            </database>
            <target>
                <packageName>com.example.demo.model</packageName>
                <directory>src/main/java</directory>
            </target>
            <generate>
                <!-- A flag indicating whether Java 8's java.time types should be used by the source code generator, rather than JDBC's java.sql types. -->
                <javaTimeTypes>true</javaTimeTypes>
                <!-- Generate index information -->
                <indexes>true</indexes>
                <!-- Primary key / foreign key relations should be generated and used. This is a prerequisite for various advanced features -->
                <relations>true</relations>
                <!-- Generate deprecated code for backwards compatibility -->
                <deprecated>false</deprecated>
                <!-- Generate the {@link javax.annotation.Generated} annotation to indicate jOOQ version used for source code -->
                <generatedAnnotation>false</generatedAnnotation>
                <!-- Generate Sequence classes -->
                <sequences>true</sequences>
                <!-- Generate Key classes -->
                <keys>true</keys>
                <!-- Generate Table classes -->
                <tables>true</tables>
                <!-- Generate TableRecord classes -->
                <records>true</records>
                <!-- Generate POJOs -->
                <pojos>true</pojos>
                <!-- Generate basic equals() and hashCode() methods in POJOs -->
                <pojosEqualsAndHashCode>true</pojosEqualsAndHashCode>
                <!-- Generate basic toString() methods in POJOs -->
                <pojosToString>true</pojosToString>
                <!-- Generate serializable POJOs -->
                <serializablePojos>true</serializablePojos>
                <!-- Generated interfaces to be implemented by records and/or POJOs -->
                <interfaces>false</interfaces>
                <!-- Turn off generation of all global object references -->
                <globalObjectReferences>true</globalObjectReferences>
                <!-- Turn off generation of global catalog references -->
                <globalCatalogReferences>true</globalCatalogReferences>
                <!-- Turn off generation of global schema references -->
                <globalSchemaReferences>true</globalSchemaReferences>
                <!-- Turn off generation of global table references -->
                <globalTableReferences>true</globalTableReferences>
                <!-- Turn off generation of global sequence references -->
                <globalSequenceReferences>true</globalSequenceReferences>
                <!-- Turn off generation of global UDT references -->
                <globalUDTReferences>true</globalUDTReferences>
                <!-- Turn off generation of global routine references -->
                <globalRoutineReferences>true</globalRoutineReferences>
                <!-- Turn off generation of global queue references -->
                <globalQueueReferences>true</globalQueueReferences>
                <!-- Turn off generation of global database link references -->
                <globalLinkReferences>true</globalLinkReferences>
                <!-- Turn off generation of global key references -->
                <globalKeyReferences>true</globalKeyReferences>
                <!-- Generate fluent setters in records, POJOs, interfaces -->
                <fluentSetters>true</fluentSetters>
            </generate>
        </generator>
    </configuration>
</plugin>

Generate code with the following mvn command. (Also generated by mvn package)

> mvn jooq-codegen:generate

The code is output in the com.example.demo.model package. In this example, the following code was generated.

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /java
                     |
                     `--- com.example.demo.model
                     |      |
                     |      `--- DefaultCatalog.java
                     |      `--- Indexes.java
                     |      `--- Keys.java
                     |      `--- SampleDb.java
                     |      `--- Tables.java
                     |
                     `--- com.example.demo.model.tables
                     |      |
                     |      `--- User.java                          // (1)
                     |
                     `--- com.example.demo.model.tables.records
                     |      |
                     |      `--- UserRecord.java                    // (2)
                     |
                     `--- com.example.demo.model.tables.pojos
                            |
                            `--- User.java                          // (3)

Javalin application side implementation

Connection properties file

Connection information is managed in the property file.

datasource.properties


datasource.jdbcUrl = jdbc:mysql://localhost:3306/sample_db
datasource.userName = test_user
datasource.password = test_user

DbPropertyLoader


import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.Properties;

public class DbPropertyLoader {
  private final Properties prop;

  private static final String JDBC_URL = "datasource.jdbcUrl";
  private static final String USER_NAME = "datasource.userName";
  private static final String PASSWORD = "datasource.password";

  public DbPropertyLoader(String fileName) {
    prop = loadProperties(fileName);
  }

  public String getJdbcUrl() {
    return prop.getProperty(JDBC_URL);
  }

  public String getUserName() {
    return prop.getProperty(USER_NAME);
  }

  public String getPassword() {
    return prop.getProperty(PASSWORD);
  }

  private Properties loadProperties(String fileName) {
    try (InputStream stream = getClass().getClassLoader().getResourceAsStream(fileName)) {
      if (stream == null) {
        throw new IOException("Not Found : " + fileName);
      }
      Properties prop = new Properties();
      prop.load(stream);
      return prop;
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }

}

Creating a DSLContext

To access the database with jOOQ, you need an instance of a class called DSLContext for jOOQ. I implemented a singleton holder class that holds an instance of DSLContext as follows. The DSLContext is thread safe.

DSLContext is thread safety?

DslConfigure


import org.jooq.Configuration;
import org.jooq.ConnectionProvider;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.TransactionProvider;
import org.jooq.conf.Settings;
import org.jooq.impl.DefaultConfiguration;

import javax.sql.DataSource;
import java.util.Optional;

public interface DslConfigure {
  DSLContext getDslContext();

  DataSource getDataSource();
  SQLDialect getSqlDialect();
  Settings getSettings();

  // optional settings
  Optional<ConnectionProvider> getConnectionProvider();
  Optional<TransactionProvider> getTransactionProvider();

  default Configuration configuration() {
    Configuration config = new DefaultConfiguration();
    config
        .set(getSqlDialect())
        .set(getSettings());
    getConnectionProvider().ifPresentOrElse(cp -> {
      config.set(cp);
      getTransactionProvider().ifPresent(tp -> {
        config.set(tp);
      });
    }, () -> {
      config.set(getDataSource());
    });
    return config;
  }
}

DslContextHolder


import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.jooq.ConnectionProvider;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.TransactionProvider;
import org.jooq.conf.Settings;
import org.jooq.conf.StatementType;
import org.jooq.impl.DSL;
import org.jooq.impl.DataSourceConnectionProvider;
import org.jooq.impl.DefaultTransactionProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.sql.DataSource;
import java.io.Serializable;
import java.util.Optional;

public class DslContextHolder implements DslConfigure, Serializable {
  private static final long serialVersionUID = -8276859195238889686L;
  private static final Logger LOG = LoggerFactory.getLogger(DslContextHolder.class);

  private static DslContextHolder instance;

  private final DSLContext dslContext;
  private final DataSource dataSource;
  private final Settings settings;
  private final ConnectionProvider connectionProvider;
  private final TransactionProvider transactionProvider;

  private DslContextHolder() {
    dataSource = createDataSource();
    settings = createSettings();
    connectionProvider = new DataSourceConnectionProvider(dataSource);
    transactionProvider = new DefaultTransactionProvider(connectionProvider, true);
    // transactionProvider = new ThreadLocalTransactionProvider(connectionProvider, true);
    dslContext = DSL.using(configuration());
  }

  public static DslContextHolder getInstance() {
    if (instance == null) {
      synchronized (DslContextHolder.class) {
        if (instance == null) {
          instance = new DslContextHolder();
          LOG.debug("DSL Context create : {}", instance.getDslContext().toString());
        }
      }
    }
    return instance;
  }

  public static void destroy() {
    if (instance != null) {
      synchronized (DslContextHolder.class) {
        if (instance != null) {
          LOG.debug("DSL Context destroy : {}", instance.getDslContext().toString());
          instance.dslContext.close();
          instance = null;
        }
      }
    }
  }

  DataSource createDataSource() {
    DbPropertyLoader prop = new DbPropertyLoader("datasource.properties");
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl(prop.getJdbcUrl());
    config.setUsername(prop.getUserName());
    config.setPassword(prop.getPassword());
    config.setPoolName("hikari-cp");
    config.setAutoCommit(false);
    config.setConnectionTestQuery("select 1");
    config.setMaximumPoolSize(10);
    DataSource dataSource = new HikariDataSource(config);
    return dataSource;
  }

  Settings createSettings() {
    return new Settings()
        .withStatementType(StatementType.STATIC_STATEMENT)
        .withQueryTimeout(10)
        .withMaxRows(1000)
        .withFetchSize(20)
        .withExecuteLogging(true);
  }

  @Override
  public DSLContext getDslContext() {
    return this.dslContext;
  }

  @Override
  public DataSource getDataSource() {
    return this.dataSource;
  }

  @Override
  public SQLDialect getSqlDialect() {
    return SQLDialect.MYSQL_8_0;
  }

  @Override
  public Settings getSettings() {
    return this.settings;
  }

  @Override
  public Optional<ConnectionProvider> getConnectionProvider() {
    //
    return Optional.ofNullable(this.connectionProvider);
  }

  @Override
  public Optional<TransactionProvider> getTransactionProvider() {
    //
    return Optional.ofNullable(this.transactionProvider);
  }

}

Service implementation example

This is an implementation example of a service class that accesses a database using jOOQ's DSLContext and automatically generated code (entity class).

UserService


import com.example.demo.model.tables.pojos.User;

import java.util.List;
import java.util.Optional;

public interface UserService {
  Optional<User> findById(Long id);
  List<User> findByNickName(String nickName);
  List<User> findAll();
  User save(User user);
  void remove(Long id);
}

UserServiceImpl


import com.example.demo.AppServiceException;
import com.example.demo.model.tables.pojos.User;
import com.example.demo.model.tables.records.UserRecord;
import com.example.demo.service.UserService;
import org.jooq.DSLContext;
import org.jooq.Result;
import org.jooq.impl.DSL;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import static com.example.demo.model.Tables.USER;

public class UserServiceImpl implements UserService {
  private static final Logger LOG = LoggerFactory.getLogger(UserServiceImpl.class);
  private final DSLContext dsl;

  public UserServiceImpl(DSLContext dsl) {
    this.dsl = dsl;
  }

  @Override
  public Optional<User> findById(Long id) {
    LOG.debug("find by Id : {}", id);
    UserRecord record = dsl
        .selectFrom(USER)
        .where(USER.ID.eq(id))
        .fetchOne();
    if (record != null) {
      return Optional.of(record.into(User.class));
    }
    return Optional.empty();
  }

  @Override
  public List<User> findByNickName(String nickName) {
    LOG.debug("find by nickName : {}", nickName);
    Result<UserRecord> result = dsl
        .selectFrom(USER)
        .where(USER.NICK_NAME.contains(nickName))
        .orderBy(USER.NICK_NAME.asc())
        .fetch();
    return result.into(User.class);
  }

  @Override
  public List<User> findAll() {
    LOG.debug("findAll");
    Result<UserRecord> result = dsl
        .selectFrom(USER)
        .orderBy(USER.ID.desc())
        .fetch();
    return result.into(User.class);
  }

  @Override
  public User save(User user) {
    LOG.debug("save : {}", user);
    UserRecord result = dsl.transactionResult(conf -> {
      if (user.getId() == null || user.getId().equals(0L)) {
        return DSL.using(conf)
            .insertInto(USER,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(
                user.getNickName(),
                user.getSex(),
                user.getPrefectureId(),
                user.getEmail(),
                user.getMemo())
            .returning()
            .fetchOne();
      } else {
        return DSL.using(conf)
            .update(USER)
            .set(USER.NICK_NAME, user.getNickName())
            .set(USER.SEX, user.getSex())
            .set(USER.PREFECTURE_ID, user.getPrefectureId())
            .set(USER.EMAIL, user.getEmail())
            .set(USER.MEMO, user.getMemo())
            .set(USER.UPDATE_AT, LocalDateTime.now())
            .where(USER.ID.eq(user.getId()))
            .returning()
            .fetchOne();
      }
    });
    LOG.debug("save result : {}", result);
    if (result == null) {
      throw new AppServiceException("[save] Not Found saved user record.");
    }
    return result.into(User.class);
  }

  @Override
  public void remove(Long id) {
    LOG.debug("remove by id : {}", id);
    int result = dsl.transactionResult(conf -> {
      int count = DSL.using(conf)
          .selectCount()
          .from(USER)
          .where(USER.ID.eq(id))
          .execute();
      if (count != 1) {
        return count;
      }
      return DSL.using(conf)
          .deleteFrom(USER)
          .where(USER.ID.eq(id))
          .execute();
    });
    LOG.debug("remove result : {}", result);
    if (result == 0) {
      throw new AppServiceException("[remove] Not Found delete user record.");
    }
  }

}

Controller implementation example

This is an implementation example of a controller that uses the above services.

UserController


import com.example.demo.model.tables.pojos.User;
import com.example.demo.service.UserService;
import io.javalin.Javalin;
import io.javalin.apibuilder.ApiBuilder;
import io.javalin.http.Context;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;

public class UserController {
  private static final Logger LOG = LoggerFactory.getLogger(UserController.class);
  private final UserService userService;

  public UserController(UserService userService) {
    this.userService = userService;
  }

  void findById(@NotNull Context ctx) {
    Long id = ctx.pathParam("id", Long.class).get();
    userService.findById(id).ifPresentOrElse(user -> {
      ctx.json(user);
    },() -> {
      ctx.status(404);
    });
  }

  void findAll(@NotNull Context ctx) {
    List<User> users = userService.findAll();
    ctx.json(users);
  }

  void store(@NotNull Context ctx) {
    User user = ctx.bodyAsClass(User.class);
    User storedUser = userService.save(user);
    ctx.json(storedUser);
  }

  void remove(@NotNull Context ctx) {
    Long id = ctx.pathParam("id", Long.class).get();
    userService.remove(id);
    ctx.status(200);
  }

  public void bindEndpoints(@NotNull Javalin app) {
    app.routes(() -> {
      ApiBuilder.path("api/user", () -> {
        // GET /api/user
        ApiBuilder.get(this::findAll);
        // GET /api/user/:id
        ApiBuilder.get(":id", this::findById);
        // POST /api/user
        ApiBuilder.post(this::store);
        // DELETE /api/user/:id
        ApiBuilder.delete(":id", this::remove);
      });
    });
  }

}

The handler was bound at the Javalin application launch point.

public static void main(String... args) {
  Javalin app = Javalin
    .create(config -> {
      // configuration
    })
    .start(7000);

  // ...abridgement...

  //Generate DSLContext
  final DSLContext dsl = DslContextHolder.getInstance().getDslContext();

  //Bind the handler to your application
  new UserController(new UserServiceImpl(dsl)).bindEndpoints(app);

  // ...abridgement...
}

Test code

I used JUnit 5 for the testing framework and AssertJ for the assertions.

Add dependency

Edit pom.xml and add the following dependencies.

pom.xml


<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.5.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.13.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <!--<version>1.4.199</version>-->
    <version>1.4.193</version>
    <scope>test</scope>
</dependency>

Service class test

The service class test is implemented as a unit test. Use H2 for databases of external resources on which the service depends. Prepare the test environment by creating a schema before starting the test, loading test data for each test method, and deleting the schema after the test is completed.

Connection properties file for testing

test-datasource.properties


#datasource.driver = org.h2.Driver
datasource.jdbcUrl = jdbc:h2:mem:sample_db;MODE=MySQL;DB_CLOSE_DELAY=-1;
#datasource.jdbcUrl = jdbc:h2:tcp://localhost:9092/./sample_db;MODE=MySQL;DATABASE_TO_UPPER=false
datasource.userName = sa
datasource.password =

Creating a test DSLContext

Implement the code that creates the DSLContext for H2.

TestDslContextHolder


import com.example.demo.config.DbPropertyLoader;
import com.example.demo.config.DslConfigure;
import org.h2.jdbcx.JdbcDataSource;
import org.jooq.ConnectionProvider;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.TransactionProvider;
import org.jooq.conf.Settings;
import org.jooq.conf.StatementType;
import org.jooq.impl.DSL;
import org.jooq.impl.DataSourceConnectionProvider;
import org.jooq.impl.DefaultTransactionProvider;

import javax.sql.DataSource;
import java.io.Serializable;
import java.util.Optional;

public class TestDslContextHolder implements DslConfigure, Serializable {
  private static final long serialVersionUID = -5728144007357037196L;

  private static TestDslContextHolder instance;

  private final DSLContext dslContext;
  private final DataSource dataSource;
  private final Settings settings;
  private final ConnectionProvider connectionProvider;
  private final TransactionProvider transactionProvider;

  public TestDslContextHolder() {
    dataSource = createDataSource();
    settings = createSettings();
    connectionProvider = new DataSourceConnectionProvider(dataSource);
    transactionProvider = new DefaultTransactionProvider(connectionProvider, true);
    // transactionProvider = new ThreadLocalTransactionProvider(connectionProvider, true);
    dslContext = DSL.using(configuration());
  }

  public static TestDslContextHolder getInstance() {
    if (instance == null) {
      synchronized (TestDslContextHolder.class) {
        if (instance == null) {
          instance = new TestDslContextHolder();
        }
      }
    }
    return instance;
  }

  public static void destroy() {
    if (instance != null) {
      synchronized (TestDslContextHolder.class) {
        if (instance != null) {
          instance.dslContext.close();
          instance = null;
        }
      }
    }
  }

  DataSource createDataSource() {
    DbPropertyLoader prop = new DbPropertyLoader("test-datasource.properties");
    JdbcDataSource dataSource = new JdbcDataSource();
    dataSource.setURL(prop.getJdbcUrl());
    dataSource.setUser(prop.getUserName());
    dataSource.setPassword(prop.getPassword());
    return dataSource;
  }

  Settings createSettings() {
    return new Settings()
        .withStatementType(StatementType.STATIC_STATEMENT)
        .withQueryTimeout(10)
        .withMaxRows(1000)
        .withFetchSize(20)
        .withExecuteLogging(true)
        .withDebugInfoOnStackTrace(true)
        .withRenderFormatted(true);
  }

  @Override
  public DSLContext getDslContext() {
    return this.dslContext;
  }

  @Override
  public DataSource getDataSource() {
    return this.dataSource;
  }

  @Override
  public SQLDialect getSqlDialect() {
    return SQLDialect.H2;
  }

  @Override
  public Settings getSettings() {
    return this.settings;
  }

  @Override
  public Optional<ConnectionProvider> getConnectionProvider() {
    //
    return Optional.ofNullable(this.connectionProvider);
  }

  @Override
  public Optional<TransactionProvider> getTransactionProvider() {
    //
    return Optional.ofNullable(this.transactionProvider);
  }

}

Service test class This code is a pattern that uses in-memory H2. (The pattern of using H2 in server mode will be described later.)

TestService


import org.jooq.DSLContext;
import org.jooq.Queries;
import org.jooq.Query;
import org.jooq.Schema;
import org.jooq.Table;
import org.jooq.exception.DataAccessException;
import org.jooq.impl.DSL;

import java.util.List;

public interface TestService {

  DSLContext getDSLContext();
  void setDSLContext(DSLContext dsl);

  default void createSchema(Schema schema) {
    Queries queries = getDSLContext().ddl(schema);
    for (Query query : queries.queries()) {
      getDSLContext().execute(query);
    }
  }

  default void dropSchema(Schema schema) {
    List<Table<?>> tables = schema.getTables();
    for (Table<?> table : tables) {
      getDSLContext().dropTableIfExists(table).execute();
    }
    getDSLContext().dropSchemaIfExists(schema).execute();
  }

  default void cleanUp(List<Table<?>> tables) {
    getDSLContext().transaction(conf -> {
      for (Table table : tables) {
        DSL.using(conf).deleteFrom(table).execute();
      }
    });
  }

  default void testDataRollback() {
    throw new DataAccessException("rollback");
  }

  default void isFailed(RuntimeException e) {
    if (!e.getMessage().equals("rollback")) {
      System.err.println(e);
      throw e;
    }
  }

}

UserServiceImplTests


import com.example.demo.TestDslContextHolder;
import com.example.demo.converter.Prefecture;
import com.example.demo.converter.Sex;
import com.example.demo.model.tables.pojos.User;
import com.example.demo.model.tables.records.UserRecord;
import com.example.demo.service.TestService;
import com.example.demo.service.UserService;
import org.assertj.core.groups.Tuple;
import org.jooq.DSLContext;
import org.jooq.exception.DataAccessException;
import org.jooq.impl.DSL;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static com.example.demo.model.SampleDb.SAMPLE_DB;
import static com.example.demo.model.Tables.*;

@TestInstance(value = TestInstance.Lifecycle.PER_CLASS)
public class UserServiceImplTests implements TestService {

  private DSLContext dsl;

  @Override
  public DSLContext getDSLContext() {
    return dsl;
  }

  @Override
  public void setDSLContext(DSLContext dsl) {
    this.dsl = dsl;
  }

  @BeforeAll
  public void setupSchema() {
    System.out.println("setup class");
    setDSLContext(TestDslContextHolder.getInstance().getDslContext());
    createSchema(SAMPLE_DB);
  }

  @AfterAll
  public void tearDownSchema() {
    System.out.println("teardown class");
    dropSchema(SAMPLE_DB);
    TestDslContextHolder.destroy();
  }

  @BeforeEach
  public void setup() {
    System.out.println("setup");
  }

  @AfterEach
  public void tearDown() {
    System.out.println("teardown");
    cleanUp(List.of(USER));
  }

  @Test
  public void findById() {
    System.out.println("findById");
    try {
      dsl.transaction(conf -> {
        // setup test data
        DSL.using(conf)
            .insertInto(USER,
                USER.ID,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(1L, "nick name AAA", Sex.FEMALE, Prefecture.HOKKAIDO, "[email protected]", "memo aaa")
            .values(2L, "nick name BBB", Sex.MALE, Prefecture.TOHOKU, "[email protected]", null)
            .values(3L, "nick name CCC", Sex.FEMALE, Prefecture.KANTOU, "[email protected]", "memo ccc")
            .execute();

        // exercise
        UserService sut = new UserServiceImpl(DSL.using(conf));
        Optional<User> actual = sut.findById(1L);

        // verify
        assertThat(actual.isPresent()).isTrue();
        actual.ifPresent(user -> {
          assertThat(user)
              .extracting(
                  "id",
                  "nickName",
                  "sex",
                  "prefectureId",
                  "email",
                  "memo")
              .contains(
                  1L,
                  "nick name AAA",
                  Sex.FEMALE,
                  Prefecture.HOKKAIDO,
                  "[email protected]",
                  "memo aaa");
        });

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

  @Test
  public void findByNickName() {
    System.out.println("findByNickName");
    try {
      dsl.transaction(conf -> {
        // setup test data
        DSL.using(conf)
            .insertInto(USER,
                USER.ID,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(1L, "nick name AAA", Sex.FEMALE, Prefecture.HOKKAIDO, "[email protected]", "memo aaa")
            .values(2L, "nick name BBB", Sex.MALE, Prefecture.TOHOKU, "[email protected]", null)
            .values(3L, "nick name CCC", Sex.FEMALE, Prefecture.KANTOU, "[email protected]", "memo ccc")
            .execute();

        // exercise
        UserService sut = new UserServiceImpl(DSL.using(conf));
        List<User> actual = sut.findByNickName("BBB");

        // verify
        assertThat(actual).hasSize(1);
        assertThat(actual)
            .extracting(
                "id",
                "nickName",
                "sex",
                "prefectureId",
                "email",
                "memo")
            .contains(Tuple.tuple(2L, "nick name BBB", Sex.MALE, Prefecture.TOHOKU, "[email protected]", null));

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

  @Test
  public void save() {
    try {
      dsl.transaction(conf -> {
        // exercise
        User testData = new User(null, "nick name DDD", Sex.MALE, Prefecture.KANTOU, "[email protected]", null, null, null);
        UserService sut = new UserServiceImpl(DSL.using(conf));
        User expected = sut.save(testData);

        // verify
        Optional<User> actual = sut.findById(expected.getId());
        assertThat(actual.isPresent()).isTrue();
        actual.ifPresent(user -> {
          assertThat(user)
              .extracting(
                  "id",
                  "nickName",
                  "sex",
                  "prefectureId",
                  "email",
                  "memo")
              .contains(
                  expected.getId(),
                  expected.getNickName(),
                  expected.getSex(),
                  expected.getPrefectureId(),
                  expected.getEmail(),
                  expected.getMemo());
        });

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

  @Test
  public void remove() {
    try {
      dsl.transaction(conf -> {
        // setup
        Long userId = 1L;
        DSL.using(conf)
            .insertInto(USER,
                USER.ID,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(
                userId,
                "nick name EEE",
                Sex.MALE,
                Prefecture.CHUBU,
                "[email protected]",
                "memo eee")
            .execute();

        // exercise
        UserService sut = new UserServiceImpl(DSL.using(conf));
        sut.remove(userId);

        // verify
        UserRecord actual = DSL.using(conf)
            .selectFrom(USER)
            .where(USER.ID.eq(userId))
            .fetchOne();
        assertThat(actual).isNull();

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

}

Use H2 in server mode

In server mode, schemas, tables, and test data are created by executing SQL scripts placed in the following locations. Running the test creates a database file for the H2 database as a sample_db.mv.db file.

[project_root]
 |
 `--- /h2
        |
        `--- /sql
        |      |
        |      `--- 01_schema.sql     //Create a schema
        |      `--- 02_table.sql      //Create a table
        |      `--- 03_data.sql       //Insert data
        |      `--- 04_table.sql      //Drop the table
        |      `--- 05_schema.sql     //Delete the schema
        |
        `--- sample_db.mv.db

TestH2Server


import com.example.demo.config.DbPropertyLoader;
import org.h2.tools.RunScript;
import org.h2.tools.Server;

import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;

public class TestH2Server implements AutoCloseable {

  private final Server server;

  private final String jdbcUrl;
  private final String userName;
  private final String password;

  public TestH2Server() {
    DbPropertyLoader prop = new DbPropertyLoader("test-datasource.properties");
    jdbcUrl = prop.getJdbcUrl();
    userName = prop.getUserName();
    password = prop.getPassword();
    String[] params = new String[] {"-tcp", "-tcpPort", "9092", "-baseDir", "./h2"};
    try {
      server = Server.createTcpServer(params);
    } catch (SQLException e) {
      System.err.println(e.getMessage());
      throw new RuntimeException(e);
    }
  }

  public void start() {
    System.out.println("server start");
    try {
      server.start();
      status();
      Runtime.getRuntime().addShutdownHook(new Thread(() -> shutdown()));
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }

  public void runScript(List<String> scripts) {
    try {
      for (String script : scripts) {
        System.out.println("run script : " + script);
        RunScript.execute(jdbcUrl, userName, password, script, StandardCharsets.UTF_8, false);
      }
    } catch (SQLException e) {
      System.err.println("run script : " + e.getMessage());
      throw new RuntimeException(e);
    }
  }

  public void shutdown() {
    if (server.isRunning(true)) {
      System.out.println("server shutdown");
      server.shutdown();
    }
  }

  public void status() {
    System.out.println(server.getStatus());
  }

  // for test
  public static void main(String ... args) {
    try (TestH2Server h2 = new TestH2Server()) {
      h2.start();
      h2.status();
      h2.runScript(List.of("./h2/sql/01_schema.sql", "./h2/sql/02_table.sql", "./h2/sql/03_data.sql"));
      h2.test();
      h2.runScript(List.of("./h2/sql/04_table.sql", "./h2/sql/05_schema.sql"));
    }
    System.exit(0);
  }

  public void test() {
    System.out.println("test");
    try (Connection conn = DriverManager.getConnection(jdbcUrl, userName, password)) {
      PreparedStatement ps = conn.prepareStatement("select * from sample_db.user");
      ResultSet rs = ps.executeQuery();
      while (rs.next()) {
        System.out.println("id : " + rs.getLong("id"));
        System.out.println("nick_name : " + rs.getString("nick_name"));
        System.out.println("sex : " + rs.getString("sex"));
        System.out.println("prefecture_id : " + rs.getInt("prefecture_id"));
        System.out.println("email : " + rs.getString("email"));
        System.out.println("memo : " + rs.getString("memo"));
        System.out.println("createAt : " + rs.getTimestamp("create_at"));
        System.out.println("updateAt : " + rs.getTimestamp("update_at"));
      }
    } catch (SQLException e) {
      System.err.println("test error : " + e.getMessage());
      throw new RuntimeException(e);
    }
  }

  @Override
  public void close() {
    shutdown();
  }

}

Modify the service test class to use the H2 server. The parts to be modified are the setupSchema and tearDownSchema methods.

@TestInstance(value = TestInstance.Lifecycle.PER_CLASS)
public class UserServiceImplTests implements TestService {

  private DSLContext dsl;
  private TestH2Server h2;

  private List<String> initScripts = List.of(
      "./h2/sql/01_schema.sql",
      "./h2/sql/02_table.sql"
  );

  private List<String> clearScripts = List.of(
      "./h2/sql/04_table.sql",
      "./h2/sql/05_schema.sql"
  );

  @Override
  public DSLContext getDSLContext() {
    return dsl;
  }

  @Override
  public void setDSLContext(DSLContext dsl) {
    this.dsl = dsl;
  }

  @BeforeAll
  public void setupSchema() {
    System.out.println("setup class");
    h2 = new TestH2Server();
    h2.start();
    h2.runScript(initScripts);
    setDSLContext(TestDslContextHolder.getInstance().getDslContext());
  }

  @AfterAll
  public void tearDownSchema() {
    System.out.println("teardown class");
    TestDslContextHolder.destroy();
    h2.runScript(clearScripts);
    h2.shutdown();
  }

  //...abridgement...

}

Controller class test

The controller class test is implemented as an integration test. The database uses the same MySQL as in the real world, so you need access to the MySQL server when running the test.

Controller test class

import com.example.demo.config.DslContextHolder;
import com.example.demo.config.JacksonConfig;
import com.example.demo.converter.Prefecture;
import com.example.demo.converter.Sex;
import com.example.demo.model.tables.pojos.User;
import com.example.demo.service.impl.UserServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.javalin.Javalin;
import io.javalin.plugin.json.JavalinJackson;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

import static org.assertj.core.api.Assertions.assertThat;

@TestInstance(value = TestInstance.Lifecycle.PER_CLASS)
public class UserControllerTests {

  Javalin app;
  int port;
  ObjectMapper objectMapper;

  @BeforeAll
  public void setup() {
    port = 7000;
    app = Javalin.create().start(port);
    objectMapper = JacksonConfig.getObjectMapper();
    JavalinJackson.configure(objectMapper);
  }

  @AfterAll
  public void tearDown() {
    app.stop();
  }

  @Test
  public void findById() throws Exception {
    // setup
    new UserController(new UserServiceImpl(DslContextHolder.getInstance().getDslContext()))
        .bindEndpoints(app);

    // exercise
    Long userId = 1L;
    String testTargetUrl = String.format("http://localhost:%d/api/user/%d", port, userId);

    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(testTargetUrl))
        .header("Accept", "application/json")
        .GET()
        .build();
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

    // verify
    assertThat(response.statusCode()).isEqualTo(200);
    User actual = objectMapper.readValue(response.body(), User.class);
    assertThat(actual)
        .extracting(
            "id",
            "nickName",
            "sex",
            "prefectureId",
            "email",
            "memo")
        .contains(
            1L,
            "test user 1",
            Sex.MALE,
            Prefecture.HOKKAIDO,
            "[email protected]",
            "memo 1"
        );
  }

}

Recommended Posts

A note when examining Javalin
A note when the heroku command becomes unavailable
A note about Java GC
A note about the scope
A private note about AtomicReference
A note when I was addicted to converting Ubuntu on WSL1 to WSL2
A note about RocksDB's Column Families
Precautions when writing a program: Part 3
Thinking when introducing a new library
Build a web application with Javalin
A note of someone who stumbled when trying to create a Rails project