Access a database with MyBatis

Learn how to access a database with MyBatis using Micronaut.

Authors: Iván López, Sergio del Amo

Micronaut Version: 1.1.0

1 Getting Started

In this guide, we are going to write a Micronaut application that exposes some REST endpoints and store data in a database using MyBatis.

1.1 What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • JDK 1.8 or greater installed with JAVA_HOME configured appropriately

1.2 Solution

We recommend you to follow the instructions in the next sections and create the app step by step. However, you can go right to the completed example.

or

Then, cd into the complete folder which you will find in the root project of the downloaded/cloned project.

2 Writing the App

If you are using Java or Kotlin and IntelliJ IDEA make sure you have enabled annotation processing.

annotationprocessorsintellij

2.1 Configure Data Source and JPA

Data Source configuration

Add the next snippet to build.gradle to include the necessary dependencies:

build.gradle
compile 'org.mybatis:mybatis:3.4.6' (1)
compile "io.micronaut.configuration:micronaut-jdbc-hikari" (2)
runtime "com.h2database:h2:1.4.196" (3)
1 Add MyBatis dependency.
2 Configures SQL DataSource instances using Hikari Connection Pool.
3 Add dependency to in-memory H2 Database.

Define the data source in src/main/resources/application.yml.

src/main/resources/application.yml
datasources:
    default:
        url: jdbc:h2:mem:default;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
        username: sa
        password: ""
        driverClassName: org.h2.Driver

MyBatis configuration

As there is no out-of-the-box support yet in Micronaut for MyBatis it is necessary to wire manually SqlSessionFactory.

Create the following @Factory file src/main/java/example/micronaut/MybatisFactory.java:

src/main/java/example/micronaut/MybatisFactory.java
package example.micronaut;

import io.micronaut.context.annotation.Bean;
import io.micronaut.context.annotation.Factory;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.TransactionFactory;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;

import javax.sql.DataSource;

@Factory (1)
public class MybatisFactory {

    private final DataSource dataSource; (2)

    public MybatisFactory(DataSource dataSource) {
        this.dataSource = dataSource; (2)
    }

    @Bean (3)
    SqlSessionFactory sqlSessionFactory() {
        TransactionFactory transactionFactory = new JdbcTransactionFactory();

        Environment environment = new Environment("dev", transactionFactory, dataSource); (4)
        Configuration configuration = new Configuration(environment);
        configuration.addMappers("example.micronaut"); (5)

        return new SqlSessionFactoryBuilder().build(configuration); (6)
    }

}
1 Annotate the class with @Factory.
2 Constructor injection for Micronaut’s dataSource.
3 Define a @Bean of type SqlSessionFactory.
4 Use the dataSource to create a new MyBatis environment.
5 Define the package to scan for mappers.
6 Create a new SqlSessionFactory bean.

2.2 Domain

Create the domain entities:

src/main/java/example/micronaut/domain/Genre.java
package example.micronaut.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;

import javax.validation.constraints.NotNull;
import java.util.HashSet;
import java.util.Set;

public class Genre {

    public Genre() {
    }

    public Genre(@NotNull String name) {
        this.name = name;
    }

    private Long id;

    @NotNull
    private String name;

    @JsonIgnore
    private Set<Book> books = new HashSet<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Set<Book> getBooks() {
        return books;
    }

    public void setBooks(Set<Book> books) {
        this.books = books;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("Genre{");
        sb.append("id=");
        sb.append(id);
        sb.append(", name='");
        sb.append(name);
        sb.append("'}");
        return sb.toString();
    }
}

2.3 Repository Access

Create an interface to define the operations to access the database and use MyBatis' annotations to map the methods to sql queries:

src/main/java/example/micronaut/genre/GenreMapper.java
package example.micronaut.genre;

import example.micronaut.domain.Genre;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Positive;
import javax.validation.constraints.PositiveOrZero;
import java.util.List;

public interface GenreMapper {

    @Select("select * from genre where id=#{id}")
    Genre findById(long id);

    @Insert("insert into genre(name) values(#{name})")
    @Options(useGeneratedKeys = true)
    void save(Genre genre);

    @Delete("delete from genre where id=#{id}")
    void deleteById(long id);

    @Update("update genre set name=#{name} where id=#{id}")
    void update(@Param("id") long id, @Param("name") String name);

    @Select("select * from genre")
    List<Genre> findAll();

    @Select("select * from genre order by ${sort} ${order}")
    List<Genre> findAllBySortAndOrder(@NotNull @Pattern(regexp = "id|name") String sort,
                                             @NotNull @Pattern(regexp = "asc|ASC|desc|DESC") String order);

    @Select("select * from genre order by ${sort} ${order} limit ${offset}, ${max}")
    List<Genre> findAllByOffsetAndMaxAndSortAndOrder(@NotNull @PositiveOrZero Integer offset,
                                                            @Positive @NotNull Integer max,
                                                            @NotNull @Pattern(regexp = "id|name") String sort,
                                                            @NotNull @Pattern(regexp = "asc|ASC|desc|DESC") String order);

    @Select("select * from genre limit ${offset}, ${max}")
    List<Genre> findAllByOffsetAndMax(@NotNull @PositiveOrZero Integer offset, @Positive @NotNull Integer max);

}

And the implementation:

src/main/java/example/micronaut/genre/GenreMapperImpl.java
package example.micronaut.genre;

import example.micronaut.domain.Genre;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;

import javax.inject.Singleton;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Positive;
import javax.validation.constraints.PositiveOrZero;
import java.util.List;

@Singleton (1)
public class GenreMapperImpl implements GenreMapper {

    private final SqlSessionFactory sqlSessionFactory; (2)

    public GenreMapperImpl(SqlSessionFactory sqlSessionFactory) {
        this.sqlSessionFactory = sqlSessionFactory; (2)
    }

    @Override
    public Genre findById(long id) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) { (3)
            return getGenreMapper(sqlSession).findById(id); (5)
        }
    }

    private GenreMapper getGenreMapper(SqlSession sqlSession) {
        return sqlSession.getMapper(GenreMapper.class); (4)
    }


    @Override
    public void save(Genre genre) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            getGenreMapper(sqlSession).save(genre);
            sqlSession.commit(); (6)
        }
    }

    @Override
    public void deleteById(long id) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            getGenreMapper(sqlSession).deleteById(id);
            sqlSession.commit();
        }
    }

    @Override
    public void update(long id, String name) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            getGenreMapper(sqlSession).update(id, name);
            sqlSession.commit();
        }
    }

    @Override
    public List<Genre> findAll() {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            return getGenreMapper(sqlSession).findAll();
        }
    }

    @Override
    public List<Genre> findAllBySortAndOrder(@NotNull @Pattern(regexp = "id|name") String sort,
                                             @NotNull @Pattern(regexp = "asc|ASC|desc|DESC") String order) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            return getGenreMapper(sqlSession).findAllBySortAndOrder(sort, order);
        }
    }

    @Override
    public List<Genre> findAllByOffsetAndMaxAndSortAndOrder(@NotNull @PositiveOrZero Integer offset,
                                                            @Positive @NotNull Integer max,
                                                            @NotNull @Pattern(regexp = "id|name") String sort,
                                                            @NotNull @Pattern(regexp = "asc|ASC|desc|DESC") String order) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            return getGenreMapper(sqlSession).findAllByOffsetAndMaxAndSortAndOrder(offset, max, sort, order);
        }
    }

    @Override
    public List<Genre> findAllByOffsetAndMax(@NotNull @PositiveOrZero Integer offset,
                                             @Positive @NotNull Integer max) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            return getGenreMapper(sqlSession).findAllByOffsetAndMax(offset, max);
        }
    }
}
1 Use javax.inject.Singleton to designate a class a a singleton.
2 Inject easily the SqlSessionFactory bean created by the @Factory.
3 Use try-with-resources to automatically close the sql session.
4 Get MyBatis mapper implementation for the interface.
5 Execute the desired method using the mapper. This will trigger the sql query.
6 In a database write access, commit the transaction.

Create an interface to define the high level operations exposed to the application:

src/main/java/example/micronaut/genre/GenreRepository.java
package example.micronaut.genre;

import example.micronaut.ListingArguments;
import example.micronaut.domain.Genre;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional;

public interface GenreRepository {

    Optional<Genre> findById(@NotNull Long id);

    Genre save(@NotBlank String name);

    void deleteById(@NotNull Long id);

    List<Genre> findAll(@NotNull ListingArguments args);

    int update(@NotNull Long id, @NotBlank String name);
}

And the implementation using GenreMapper:

src/main/java/example/micronaut/genre/GenreRepositoryImpl.java
package example.micronaut.genre;

import example.micronaut.ListingArguments;
import example.micronaut.domain.Genre;
import io.micronaut.validation.Validated;

import javax.inject.Singleton;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

@Singleton (1)
@Validated
public class GenreRepositoryImpl implements GenreRepository {

    private final GenreMapper genreMapper;

    public GenreRepositoryImpl(GenreMapper genreMapper) {
        this.genreMapper = genreMapper;
    }

    @Override
    public Optional<Genre> findById(@NotNull Long id) {
        return Optional.ofNullable(genreMapper.findById(id));
    }

    @Override
    public Genre save(@NotBlank String name) {
        Genre genre = new Genre(name);
        genreMapper.save(genre);
        return genre;
    }

    @Override
    public void deleteById(@NotNull Long id) {
        findById(id).ifPresent(genre -> genreMapper.deleteById(id));
    }

    private final static List<String> VALID_PROPERTY_NAMES = Arrays.asList("id", "name");

    public List<Genre> findAll(@NotNull ListingArguments args) {

        if (args.getMax().isPresent() && args.getSort().isPresent() && args.getOffset().isPresent() && args.getSort().isPresent()) {
            return genreMapper.findAllByOffsetAndMaxAndSortAndOrder(args.getOffset().get(),
                    args.getMax().get(),
                    args.getSort().get(),
                    args.getOrder().get());
        }
        if (args.getMax().isPresent() && args.getOffset().isPresent() && (!args.getSort().isPresent() || !args.getOrder().isPresent())) {
            return genreMapper.findAllByOffsetAndMax(args.getOffset().get(),
                    args.getMax().get());
        }
        if ((!args.getMax().isPresent() || !args.getOffset().isPresent()) && args.getSort().isPresent() && args.getOrder().isPresent()) {
            return genreMapper.findAllBySortAndOrder(args.getSort().get(),
                    args.getOrder().get());
        }
        return genreMapper.findAll();
    }

    @Override
    public int update(@NotNull Long id, @NotBlank String name) {
        genreMapper.update(id, name);
        return -1;
    }
}

2.4 Controller

Micronaut’s validation is built on with the standard framework – JSR 380, also known as Bean Validation 2.0.

Hibernate Validator is a reference implementation of the validation API.

Add the next snippet to build.gradle

build.gradle
compile "io.micronaut.configuration:micronaut-hibernate-validator"

Create two classes to encapsulate Save and Update operations:

src/main/java/example/micronaut/genre/GenreSaveCommand.java
package example.micronaut.genre;

import javax.validation.constraints.NotBlank;
import java.util.Objects;

public class GenreSaveCommand {

    @NotBlank
    private String name;

    public GenreSaveCommand() {}

    public GenreSaveCommand(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
src/main/java/example/micronaut/genre/GenreUpdateCommand.java
package example.micronaut.genre;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

public class GenreUpdateCommand {
    @NotNull
    private Long id;

    @NotBlank
    private String name;

    public GenreUpdateCommand() {}

    public GenreUpdateCommand(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Create a POJO to encapsulate Sorting and Pagination:

src/main/java/example/micronaut/ListingArguments.java
package example.micronaut;

import io.micronaut.http.uri.UriBuilder;

import javax.annotation.Nullable;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Positive;
import javax.validation.constraints.PositiveOrZero;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class ListingArguments {
    @PositiveOrZero
    private Integer offset = 0;

    @Nullable
    @Positive
    private Integer max;

    @Nullable
    @Pattern(regexp = "id|name")
    private String sort;

    @Pattern(regexp = "asc|ASC|desc|DESC")
    @Nullable
    private String order;

    public ListingArguments() {

    }

    public Optional<Integer> getOffset() {
        if (offset == null) {
            return Optional.empty();
        }
        return Optional.of(offset);
    }

    public void setOffset(@Nullable Integer offset) {
        this.offset = offset;
    }

    public Optional<Integer> getMax() {
        if (max == null) {
            return Optional.empty();
        }
        return Optional.of(max);
    }

    public void setMax(@Nullable Integer max) {
        this.max = max;
    }

    public Optional<String> getSort() {
        if (sort == null) {
            return Optional.empty();
        }
        return Optional.of(sort);
    }

    public void setSort(@Nullable String sort) {
        this.sort = sort;
    }

    public Optional<String> getOrder() {
        if (order == null) {
            return Optional.empty();
        }
        return Optional.of(order);
    }

    public void setOrder(@Nullable String order) {
        this.order = order;
    }

    public static Builder builder() {
        return new Builder();
    }

    public URI of(UriBuilder uriBuilder) {
        if (max != null) {
            uriBuilder.queryParam("max", max);
        }
        if (order != null) {
            uriBuilder.queryParam("order", order);
        }
        if (offset != null) {
            uriBuilder.queryParam("offset", offset);
        }
        if (sort != null) {
            uriBuilder.queryParam("sort", sort);
        }
        return uriBuilder.build();
    }

    public static final class Builder {
        private ListingArguments args = new ListingArguments();

        private Builder() {

        }

        public Builder max(int max) {
            args.setMax(max);
            return this;
        }

        public Builder sort(String sort) {
            args.setSort(sort);
            return this;
        }

        public Builder order(String order) {
            args.setOrder(order);
            return this;
        }

        public Builder offset(int offset) {
            args.setOffset(offset);
            return this;
        }

        public ListingArguments build() {
            return this.args;
        }
    }

}

Create a ConfigurationProperties object to encapsulate the configuration of the default max value.

src/main/java/example/micronaut/ApplicationConfiguration.java
package example.micronaut;

import javax.validation.constraints.NotNull;

public interface ApplicationConfiguration {

    @NotNull Integer getMax();
}
src/main/java/example/micronaut/ApplicationConfigurationProperties.java
package example.micronaut;

import io.micronaut.context.annotation.ConfigurationProperties;

@ConfigurationProperties("application") (1)
public class ApplicationConfigurationProperties implements ApplicationConfiguration {

    protected final Integer DEFAULT_MAX = 10;

    private Integer max = DEFAULT_MAX;

    @Override
    public Integer getMax() {
        return max;
    }

    public void setMax(Integer max) {
        if (max != null) {
            this.max = max;
        }
    }
}

Create GenreController, a controller which exposes a resource with the common CRUD operations:

src/main/java/example/micronaut/GenreController.java
package example.micronaut;

import example.micronaut.domain.Genre;
import example.micronaut.genre.GenreRepository;
import example.micronaut.genre.GenreSaveCommand;
import example.micronaut.genre.GenreUpdateCommand;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Delete;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.Put;
import io.micronaut.validation.Validated;

import javax.validation.Valid;
import java.net.URI;
import java.util.List;

@Validated (1)
@Controller("/genres") (2)
public class GenreController {

    protected final GenreRepository genreRepository;

    public GenreController(GenreRepository genreRepository) { (3)
        this.genreRepository = genreRepository;
    }

    @Get("/{id}") (4)
    public Genre show(Long id) {
        return genreRepository
                .findById(id)
                .orElse(null); (5)
    }

    @Put("/") (6)
    public HttpResponse update(@Body @Valid GenreUpdateCommand command) { (7)
        int numberOfEntitiesUpdated = genreRepository.update(command.getId(), command.getName());

        return HttpResponse
                .noContent()
                .header(HttpHeaders.LOCATION, location(command.getId()).getPath()); (8)
    }

    @Get(value = "/list{?args*}") (9)
    public List<Genre> list(@Valid ListingArguments args) {
        return genreRepository.findAll(args);
    }

    @Post("/") (10)
    public HttpResponse<Genre> save(@Body @Valid GenreSaveCommand cmd) {
        Genre genre = genreRepository.save(cmd.getName());

        return HttpResponse
                .created(genre)
                .headers(headers -> headers.location(location(genre.getId())));
    }

    @Delete("/{id}") (11)
    public HttpResponse delete(Long id) {
        genreRepository.deleteById(id);
        return HttpResponse.noContent();
    }

    protected URI location(Long id) {
        return URI.create("/genres/" + id);
    }

    protected URI location(Genre genre) {
        return location(genre.getId());
    }
}
1 Add @Validated annotation at the class level to any class that requires validation.
2 The class is defined as a controller with the @Controller annotation mapped to the path /genres.
3 Constructor injection.
4 Maps a GET request to /genres/{id} which attempts to show a genre. This illustrates the use of a URL path variable.
5 Returning null when the genre doesn’t exist makes Micronaut to response with 404 (not found).
6 Maps a PUT request to /genres which attempts to update a genre.
7 Add @Valid to any method parameter which requires validation. Use a POJO supplied as a JSON payload in the request to populate command.
8 It is easy to add custom headers to the response.
9 Maps a GET request to /genres which returns a list of genres. This mapping illustrates optional URL parameters.
10 Maps a POST request to /genres which attempts to save a genre.
11 Maps a DELETE request to /genres/{id} which attempts to remove a genre. This illustrates the use of a URL path variable.

2.5 DB Schema

Now that Mybatis is setup we need a way to create the database schema. For that we will use Micronaut integration with Flyway.

Add the next snippet to build.gradle to include the necessary dependencies:

build.gradle
compile 'io.micronaut.configuration:micronaut-flyway'

Configure the database migrations directory for Flyway in application.yml.

src/main/resources/application.yml
flyway:
    datasources:
        default:
            locations: classpath:databasemigrations

Create the file V1__schema.sql with the database schema creation:

src/main/resources/databasemigrations/V1__schema.sql
DROP TABLE IF EXISTS GENRE;
DROP TABLE IF EXISTS BOOK;

CREATE TABLE GENRE (
  id    BIGINT SERIAL PRIMARY KEY NOT NULL,
  name VARCHAR(255)              NOT NULL UNIQUE
);

CREATE TABLE BOOK (
  id    BIGINT SERIAL PRIMARY KEY NOT NULL,
  name VARCHAR(255)              NOT NULL,
  isbn VARCHAR(255)              NOT NULL,
  genre_id BIGINT,
    constraint FKM1T3YVW5I7OLWDF32CWUUL7TA
    foreign key (GENRE_ID) references GENRE
);

During application startup Flyway will execute the sql file and create the schema needed for the application.

2.6 Tests

Create a Junit test which verifies the CRUD operations:

src/test/java/example/micronaut/GenreControllerTest.java
package example.micronaut;

import example.micronaut.domain.Genre;
import example.micronaut.genre.GenreSaveCommand;
import example.micronaut.genre.GenreUpdateCommand;
import io.micronaut.core.type.Argument;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.RxHttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.http.uri.UriBuilder;
import io.micronaut.http.uri.UriTemplate;
import io.micronaut.test.annotation.MicronautTest;
import org.junit.jupiter.api.Test;

import javax.inject.Inject;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

@MicronautTest (1)
public class GenreControllerTest {

    @Inject
    @Client("/")
    public RxHttpClient rxHttpClient; (2)

    BlockingHttpClient getClient() {
        return rxHttpClient.toBlocking();
    }

    @Test
    public void supplyAnInvalidOrderTriggersValidationFailure() {
        assertThrows(HttpClientResponseException.class, () -> {
            List<Genre> genres = getClient().retrieve(HttpRequest.GET("/genres/list?order=foo"), Argument.of(List.class, Genre.class));

            assertNotNull(genres);
            assertEquals(0, genres.size());
        });
    }

    @Test
    public void testFindNonExistingGenreReturns404() {
        assertThrows(HttpClientResponseException.class, () -> {
            Genre genre = getClient().retrieve(HttpRequest.GET("/genres/99"), Argument.of(Genre.class));

            assertNull(genre);
        });
    }

    private HttpResponse saveGenre(String genre) {
        HttpRequest request = HttpRequest.POST("/genres", new GenreSaveCommand(genre)); (3)
        return getClient().exchange(request);
    }

    @Test
    public void testGenreCrudOperations() {
        List<Long> genreIds = new ArrayList<>();
        HttpResponse response = saveGenre("DevOps");
        genreIds.add(entityId(response));
        assertEquals(HttpStatus.CREATED, response.getStatus());

        response = saveGenre("Microservices"); (3)
        assertEquals(HttpStatus.CREATED, response.getStatus());

        Long id = entityId(response);
        genreIds.add(id);
        Genre genre = show(id);
        assertEquals("Microservices", genre.getName());

        response = update(id, "Micro-services");
        assertEquals(HttpStatus.NO_CONTENT, response.getStatus());

        genre = show(id);
        assertEquals("Micro-services", genre.getName());

        List<Genre> genres = listGenres(ListingArguments.builder().build());
        assertEquals(2, genres.size());

        genres = listGenres(ListingArguments.builder().max(1).build());
        assertEquals(1, genres.size());
        assertEquals("DevOps", genres.get(0).getName());

        genres = listGenres(ListingArguments.builder().max(1).order("desc").sort("name").build());
        assertEquals(1, genres.size());
        assertEquals("Micro-services", genres.get(0).getName());

        genres = listGenres(ListingArguments.builder().max(1).offset(10).build());
        assertEquals(0, genres.size());

        // cleanup:
        for (Long genreId : genreIds) {
            response = delete(genreId);
            assertEquals(HttpStatus.NO_CONTENT, response.getStatus());
        }
    }

    private List<Genre> listGenres(ListingArguments args) {
        URI uri = args.of(UriBuilder.of("/genres/list"));
        HttpRequest request = HttpRequest.GET(uri);
        return getClient().retrieve(request, Argument.of(List.class, Genre.class));
    }

    private Genre show(Long id) {
        String uri = UriTemplate.of("/genres/{id}").expand(Collections.singletonMap("id", id));
        HttpRequest request = HttpRequest.GET(uri);
        return getClient().retrieve(request, Genre.class);
    }

    private HttpResponse update(Long id, String name) {
        HttpRequest request = HttpRequest.PUT("/genres", new GenreUpdateCommand(id, name));
        return getClient().exchange(request);
    }

    private HttpResponse delete(Long id) {
        HttpRequest request = HttpRequest.DELETE("/genres/" + id);
        return getClient().exchange(request);
    }

    protected Long entityId(HttpResponse response) {
        String path = "/genres/";
        String value = response.header(HttpHeaders.LOCATION);
        if (value == null) {
            return null;
        }
        int index = value.indexOf(path);
        if (index != -1) {
            return Long.valueOf(value.substring(index + path.length()));
        }
        return null;
    }
}
1 Use Micronaut testing integration with JUnit 5.
2 Inject a RxHttpClient.
3 Creating HTTP Requests is easy thanks to Micronaut’s fluid API.
4 If you care just about the object in the response use retrieve.
5 Sometimes, receiving just the object is not enough and you need information about the response. In this case, instead of retrieve you should use the exchange method.

Run the tests:

$ ./gradlew test

> Task :complete:test


14:55:10.582 [Test worker] INFO  i.m.context.env.DefaultEnvironment - Established active environments: [test]
14:55:12.407 [Test worker] INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
14:55:12.692 [Test worker] INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
14:55:12.875 [Test worker] INFO  o.f.c.i.license.VersionPrinter - Flyway Community Edition 5.2.1 by Boxfuse
14:55:12.915 [Test worker] INFO  o.f.c.i.database.DatabaseFactory - Database: jdbc:h2:mem:default (H2 1.4)
14:55:13.052 [Test worker] INFO  o.f.core.internal.command.DbValidate - Successfully validated 1 migration (execution time 00:00.025s)
14:55:13.071 [Test worker] INFO  o.f.c.i.s.JdbcTableSchemaHistory - Creating Schema History table: "PUBLIC"."flyway_schema_history"
14:55:13.112 [Test worker] INFO  o.f.core.internal.command.DbMigrate - Current version of schema "PUBLIC": << Empty Schema >>
14:55:13.118 [Test worker] INFO  o.f.core.internal.command.DbMigrate - Migrating schema "PUBLIC" to version 1 - schema
14:55:13.157 [Test worker] INFO  o.f.core.internal.command.DbMigrate - Successfully applied 1 migration to schema "PUBLIC" (execution time 00:00.087s)

You can check that Flyway runs the migration and creates the schema.

3 Running the app

To run the application use the ./gradlew run command which will start the application on port 8080.

We can use curl to check that everything works as expected:

$ curl http://localhost:8080/genres/list
[]

$ curl -X POST -d '{"name":"Sci-fi"}' -H "Content-Type: application/json" http://localhost:8080/genres
{"id":1,"name":"Sci-fi"}

$ curl -X POST -d '{"name":"Science"}' -H "Content-Type: application/json" http://localhost:8080/genres
{"id":2,"name":"Science"}

$ curl http://localhost:8080/genres/list
[{"id":1,"name":"Sci-fi"},{"id":2,"name":"Science"}]

$ curl -X DELETE http://localhost:8080/genres/1

$ curl http://localhost:8080/genres/list
[{"id":2,"name":"Science"}]

4 Next Steps

Read more about Configurations for Data Access section and Flyway support in the Micronaut documentation.