Featured image for: Building an API using Quarkus from the ground up.

Building a production-ready application has a ton of moving parts. Most of the time, developers create a new project using some sort of tool, like Maven archetypes, and then go from tutorial to tutorial piecing together everything that is needed for their application. This article tries to bring all the parts together and provide a single full reference to the work that needs to be done for a Quarkus application. Youu can find all the examples from this article on GitHub.

Tech stack for the Quarkus example

Here's a quick list of the technologies we will be using:

Project scope and getting started

Like any project, we need to start by defining our scope. The goal of this project is to build a customer API. We want to support the basic CRUD (create, read, update, and delete) functionality exposed via a REST API, saving the data to a relational database and publishing data changes to a messaging topic for external asynchronous consumption. The scope for this article is to get an API up and running with functional integration tests.

The following commands create your initial project and start the project in Quarkus dev mode. This provides a quick validation that the project has been successfully created and is ready to work.

$ mvn io.quarkus:quarkus-maven-plugin:1.13.3.Final:create \
    -DprojectGroupId=dev.rhenergy.customer \
    -DprojectArtifactId=customer-api \
    -DclassName="dev.rhenergy.customer.CustomerResource" \
    -Dpath="/api/customers"
$ cd customer-api
$ ./mvnw clean quarkus:dev

Open the project in your IDE of choice and let's get started.

Note: I have purposely omitted boilerplate code for this article: getters, setters, hashCode, equals and toString, most notably. Additionally, we want to focus a moment on a subtle consideration in our development effort: Keep one eye on your imports. Don't just start importing things. As I add imports, I consciously attempt to limit my exposure to third-party libraries, focusing on staying in the abstraction layers such as the MicroProfile abstractions. Remember that every library you import is now your responsibility to care for and feed.

Architecture layers: Resource, service, repository

I like to stick with a traditional Resource Service Repository layering pattern, shown in Figure 1. In this pattern, the Repository class returns an Entity object, which is tightly coupled to the underlying database structure. The Service class accepts and returns Domain objects and the Resource layer simply manages the REST concerns, possibly handling additional data transformations from the Domain object to a specific View object.

The architecture for the customer project is broken into the Resource, Service, and Repository layers.
Figure 1: Architecture for the customer project.
Figure 1: Architecture for the customer project.

I also like to put everything that's related in the same package. In the past, I split out packages into the architectural layers, as follows:

dev.rhenergy.customer.repository
dev.rhenergy.customer.repository.entity
dev.rhenergy.customer.resource
dev.rhenergy.customer.service

But as my microservices got much more focused on a single domain, I now just throw it all in the dev.rhenergy.customer package, as shown in Figure 2.

A screenshot of the Customer classes in a single domain package.
Figure 2: The full stack of classes all reside in the same domain package.

Quarkus application dependencies

We'll start our coding with some changes to the pom.xml file.

Yaml properties

The first thing we change is the way properties are managed. I like YAML better than properties files, so I add the following to pom.xml:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-config-yaml</artifactId>
</dependency>

Then, rename the application.propertiesfile to application.yaml.

Database: Flyway and Panache

Data interactions are going to be managed by the Quarkus Panache extension. We are also going to version our database schema using Flyway, which has a Quarkus extension. To get started, let's add the extensions to the pom.xml file:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-flyway</artifactId>
</dependency>

Flyway

Using Flyway, we can quickly put together our first table, the customer table.

Note: I am taking some liberties with this application. Should the email address be required? Should the phone number be required? Maybe, maybe not.

We will place our first Flyway SQL file in the normal deployment location, src/main/resources/db/migration/V1__customer_table_create.sql:

CREATE TABLE customer (
    customer_id     SERIAL PRIMARY KEY,
    first_name      VARCHAR(100) NOT NULL,
    middle_name     VARCHAR(100),
    last_name       VARCHAR(100) NOT NULL,
    suffix          VARCHAR(100),
    email           VARCHAR(100),
    phone           VARCHAR(100)
);

Note: There is much discussion about how schema changes should be rolled out in production. For now, we are simply going to let the embedded Flyway library migrate the changes within the application startup. If your application requires more advanced rollouts, such as blue-green or canary, you will need to split the pipelines to have the schema changes roll out independently and be backward compatible.

JPA with Panache

Based on the table created with Flyway, we will create our first Entity object. I use the Repository pattern because I like the extra Repository interface there. The following goes in a file named src/main/java/dev/rhenergy/customer/CustomerEntity.java:

@Entity(name = "Customer")
@Table(name = "customer")
public class CustomerEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "customer_id")
    private Integer customerId;

    @Column(name = "first_name")
    @NotEmpty
    private String firstName;

    @Column(name = "middle_name")
    private String middleName;

    @Column(name = "last_name")
    @NotEmpty
    private String lastName;

    @Column(name = "suffix")
    private String suffix;

    @Column(name = "email")
    @Email
    private String email;

    @Column(name = "phone")
    private String phone;

    ...

}

Some notes about the entities:

  • I like to name all my Java Persistence API (JPA) entity classes with a suffix Entity. They serve a purpose: to map back to the database tables. I always provide a layer of indirection between Domain objects and Entity objects because when it's missing, I've lost more time than the time I've spent creating and managing the data copying processes.
  • Because of the way the JPA creates the target object names, you have to explicitly put in the @Entity annotation so your HQL queries don't have reference CustomerEntity.
  • I like to explicitly name both the table and the columns with the @Table and @Column annotations. Why? I've lost more time when a code refactor inadvertently breaks the assumed named contracts than the time it costs me to write a few extra annotations.
  • My database column names are snake_case and the entity's class variables are camelCase.

The repository interface follows:

@ApplicationScoped
public class CustomerRepository implements PanacheRepositoryBase<CustomerEntity, Integer> {
}

It looks simple, but it's got power in all the right places. The file is named CustomerRepository.java. The PanacheRepositoryBase interface fills out all the code shown in Figure 3.

When we implement the PanacheRepositoryBase interface, it adds method prototypes to the CustomerEntity class.
Figure 3: Code filled in by the PanacheRepositoryBase interface.
Figure 3: Code filled in by the PanacheRepositoryBase interface.

Domain, then service, then resource

The customer domain object for this first version of the application is very simple. The following goes in a file named Customer.java:

public class Customer {

    private Integer customerId;

    @NotEmpty
    private String firstName;

    private String middleName;

    @NotEmpty
    private String lastName;

    private String suffix;

    @Email
    private String email;

    private String phone;

}

Entity-to-domain object mapping

We need to create mappings between the domain object and the entity object. For these purposes, we will use MapStruct. It requires us to add the actual dependency and to enhance the compiler plugin with configuration:

<mapstruct.version>1.4.2.Final</mapstruct.version>
...
<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>${mapstruct.version}</version>
</dependency>
...
<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>${compiler-plugin.version}</version>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>org.mapstruct</groupId>
                <artifactId>mapstruct-processor</artifactId>
                <version>${mapstruct.version}</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

The mapper itself is very simple because it's basically a one-to-one mapping between the objects. In the following code, from a file named CustomerMapper.java, note the additional componentModel = "cdi" definition. This allows the mappers to get injected:

@Mapper(componentModel = "cdi")
public interface CustomerMapper {

    CustomerEntity toEntity(Customer domain);

    Customer toDomain(CustomerEntity entity);

}

Exception handling

I usually create a single exception, extending RuntimeException, and then use that for all my exceptions based on custom logic and for wrapping checked exceptions.  Because only a single exception can be thrown, if I need to customize the response later on in any way, I don't have to write tons of mappers. The following goes in a file named ServiceException.java:

public class ServiceException extends RuntimeException

    public ServiceException(String message) {
        super(message);
    }

}

Build the Service class

We can now build out the Service class to handle the CRUD. The following is in a file named CustomerService.java:

@ApplicationScoped
public class CustomerService {

    private final CustomerRepository customerRepository;
    private final CustomerMapper customerMapper;
    private final Logger logger;

    public CustomerService(CustomerRepository customerRepository, CustomerMapper customerMapper, Logger logger) {
        this.customerRepository = customerRepository;
        this.customerMapper = customerMapper;
        this.logger = logger;
    }

    public List<Customer> findAll(){
        return customerRepository.findAll().stream()
                .map(customerMapper::toDomain)
                .collect(Collectors.toList());
    }

    public Optional<Customer> findById(Integer customerId) {
        return customerRepository.findByIdOptional(customerId).map(customerMapper::toDomain);
    }

    @Transactional
    public Customer save(Customer customer) {
        CustomerEntity entity = customerMapper.toEntity(customer);
        customerRepository.persist(entity);
        return customerMapper.toDomain(entity);
    }

    @Transactional
    public Customer update(Customer customer) {
        if (customer.getCustomerId() == null) {
            throw new ServiceException("Customer does not have a customerId");
        }
        Optional<CustomerEntity> optional = customerRepository.findByIdOptional(customer.getCustomerId());
        if (optional.isEmpty()) {
            throw new ServiceException(String.format("No Customer found for customerId[%s]", customer.getCustomerId()));
        }
        CustomerEntity entity = optional.get();
        entity.setFirstName(customer.getFirstName());
        entity.setMiddleName(customer.getMiddleName());
        entity.setLastName(customer.getLastName());
        entity.setSuffix(customer.getSuffix());
        entity.setEmail(customer.getEmail());
        entity.setPhone(customer.getPhone());
        customerRepository.persist(entity);
        return customerMapper.toDomain(entity);
    }

}

Notice the @Transactional annotations for the save and update methods. This annotation as it stands is the default behavior, which is to create a new customer record or use an existing one. You will also notice the injected Logger in the constructor as well. The Logger injection is a handy way to include a Simple Logging Facade for Java (SLF4J) logger in your classes without have to cut and paste the same code. The LoggerProducer looks like this:

@Singleton
public class LoggerProducer {

    @Produces
    Logger createLogger(InjectionPoint injectionPoint) {
        return LoggerFactory.getLogger(injectionPoint.getMember().getDeclaringClass().getName());
    }

}

Build the Resource class

Now let's build out the Resource. To start us off, we use the OpenAPI spec to create a conforming REST API. Let's grab the Quarkus extension for that and put it in our pom.xml file:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>

You can also add extensions via the Quarkus Maven plugin using the following command. I usually can't remember the names, so I just cut and paste from an example, but it's an option:

./mvnw quarkus:add-extension -Dextensions="quarkus-smallrye-openapi"

Because we are going to be serializing objects back and forth using JSON, we also need to add the extension to handle the JSON serialization and deserialization:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-resteasy-jsonb</artifactId>
</dependency>

Object validation

We also use the Hibernate Bean Validation framework. This allows you to place @Valid annotations on the method arguments to trigger the beans' javax.validation.contraints annotations, such as @NotEmpty and @Email:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-hibernate-validator</artifactId>
</dependency>

Resource class

Here's the Resource class, with some footnotes. The code goes into the CustomerResource.java file.

@Path("/api/customers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CustomerResource {

    private final CustomerService customerService;
    private final Logger logger;

    public CustomerResource(CustomerService customerService, Logger logger) {
        this.customerService = customerService;
        this.logger = logger;
    }

    @GET
    @APIResponses(
            value = {
                    @APIResponse(
                            responseCode = "200",
                            description = "Get All Customers",
                            content = @Content(mediaType = "application/json",
                                    schema = @Schema(type = SchemaType.ARRAY, implementation = Customer.class)))
            }
    )
    public Response get() {
        return Response.ok(customerService.findAll()).build();
    }

    @GET
    @Path("/{customerId}")
    @APIResponses(
            value = {
                    @APIResponse(
                            responseCode = "200",
                            description = "Get Customer by customerId",
                            content = @Content(mediaType = "application/json",
                                    schema = @Schema(type = SchemaType.OBJECT, implementation = Customer.class))),
                    @APIResponse(
                            responseCode = "404",
                            description = "No Customer found for customerId provided",
                            content = @Content(mediaType = "application/json")),
            }
    )
    public Response getById(@PathParam("customerId") Integer customerId) {
        Optional<Customer> optional = customerService.findById(customerId);
        return !optional.isEmpty() ? Response.ok(optional.get()).build() : Response.status(Response.Status.NOT_FOUND).build();
    }

    @POST
    @APIResponses(
            value = {
                    @APIResponse(
                            responseCode = "201",
                            description = "Customer Created",
                            content = @Content(mediaType = "application/json",
                                    schema = @Schema(type = SchemaType.OBJECT, implementation = Customer.class))),
                    @APIResponse(
                            responseCode = "400",
                            description = "Customer already exists for customerId",
                            content = @Content(mediaType = "application/json")),
            }
    )
    public Response post(@Valid Customer customer) {
        final Customer saved = customerService.save(customer);
        return Response.status(Response.Status.CREATED).entity(saved).build();
    }

    @PUT
    @APIResponses(
            value = {
                    @APIResponse(
                            responseCode = "200",
                            description = "Customer updated",
                            content = @Content(mediaType = "application/json",
                                    schema = @Schema(type = SchemaType.OBJECT, implementation = Customer.class))),
                    @APIResponse(
                            responseCode = "404",
                            description = "No Customer found for customerId provided",
                            content = @Content(mediaType = "application/json")),
            }
    )
    public Response put(@Valid Customer customer) {
        final Customer saved = customerService.update(customer);
        return Response.ok(saved).build();
    }

}

Some notes about the code:

  • The @Produces and @Consumes annotations can be at the class level, rather than the method level, reducing duplication.
  • The @APIResponses definitions are a way to inline the Swagger documentation directly in the code. The annotations generate some noise, but reduce the need to maintain the implementation class separately from the Swagger definition.
  • Notice that all the methods actually return a Response object. I find it much easier to manage the Response data, such as the HTTP status code. If you return an object itself, the framework will automatically wrap it in a 200 response code. This is fine on a GET, but when I have a POST, I want to see that pretty 201 response code.
  • If you use the Response class, the @ApiResponses serve as the documentation of the actual payload returned in the body of the response. The OpenAPI extension autogenerates all the Swagger APIs and Swagger UI for you automatically.

Additional OpenAPI documentation

We can configure the OpenAPI Swagger API and UI in the application.yaml file as follows:

mp
  openapi:
    extensions:
      smallrye:
        info:
          title: Customer API
          version: 0.0.1
          description: API for retrieving customers
          contact:
            email: techsupport@rhenergy.dev
            name: Customer API Support
            url: http://rhenergy.github.io/customer-api
          license:
            name: Apache 2.0
            url: http://www.apache.org/licenses/LICENSE-2.0.html

Testing the application

Now we have the full stack in place. Let's get to the tests. First add the AssertJ library to the pom.xml file. Fluent assertions allow for more condensed test assertions and reduces the amount of repetitive code:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>

Now on to the test, in a file named CustomerResourceTest.java:

@QuarkusTest
public class CustomerResourceTest {

    @Test
    public void getAll() {
        given()
                .when().get("/api/customers")
                .then()
                .statusCode(200);
    }

    @Test
    public void getById() {
        Customer customer = createCustomer();
        Customer saved = given()
                .contentType(ContentType.JSON)
                .accept(ContentType.JSON)
                .body(customer)
                .post("/api/customers")
                .then()
                .statusCode(201)
                .extract().as(Customer.class);
        Customer got = given()
                .when().get("/api/customers/{customerId}", saved.getCustomerId())
                .then()
                .statusCode(200)
                .extract().as(Customer.class);
        assertThat(saved).isEqualTo(got);
    }

    @Test
    public void post() {
        Customer customer = createCustomer();
        Customer saved = given()
                .contentType(ContentType.JSON)
                .accept(ContentType.JSON)
                .body(customer)
                .post("/api/customers")
                .then()
                .statusCode(201)
                .extract().as(Customer.class);
        assertThat(saved.getCustomerId()).isNotNull();
    }

    @Test
    public void postFailNoFirstName() {
        Customer customer = createCustomer();
        customer.setFirstName(null);
        given()
                .contentType(ContentType.JSON)
                .accept(ContentType.JSON)
                .body(customer)
                .post("/api/customers")
                .then()
                .statusCode(400);
    }

    @Test
    public void put() {
        Customer customer = createCustomer();
        Customer saved = given()
                .contentType(ContentType.JSON)
                .accept(ContentType.JSON)
                .body(customer)
                .post("/api/customers")
                .then()
                .statusCode(201)
                .extract().as(Customer.class);
        saved.setFirstName("Updated");
        Customer updated = given()
                .contentType(ContentType.JSON)
                .accept(ContentType.JSON)
                .body(saved)
                .put("/api/customers")
                .then()
                .statusCode(200)
                .extract().as(Customer.class);
        assertThat(updated.getFirstName()).isEqualTo("Updated");
    }

    @Test
    public void putFailNoLastName() {
        Customer customer = createCustomer();
        Customer saved = given()
                .contentType(ContentType.JSON)
                .accept(ContentType.JSON)
                .body(customer)
                .post("/api/customers")
                .then()
                .statusCode(201)
                .extract().as(Customer.class);
        saved.setLastName(null);
        given()
                .contentType(ContentType.JSON)
                .accept(ContentType.JSON)
                .body(saved)
                .put("/api/customers")
                .then()
                .statusCode(400);
    }

    private Customer createCustomer() {
        Customer customer = new Customer();
        customer.setFirstName(RandomStringUtils.randomAlphabetic(10));
        customer.setMiddleName(RandomStringUtils.randomAlphabetic(10));
        customer.setLastName(RandomStringUtils.randomAlphabetic(10));
        customer.setEmail(RandomStringUtils.randomAlphabetic(10) + "@rhenergy.dev");
        customer.setPhone(RandomStringUtils.randomNumeric(10));
        return customer;
    }

}

By no means do I consider this set of tests complete, but it does a decent job with the vanilla use cases. When you try to run the test, you'll quickly realize that we haven't wired up the needed database. We'll address that problem next.

Integration testing with Testcontainers

The 1.13 release of Quarkus includes a new set of tools known as devservices. With devservices, the Quarkus app automatically spins up a new Testcontainer for the appropriate database based on the inclusion of a specific quarkus-jdbc extension in the project. This wires up all the infrastructure needed to run a PostgreSQL container in the background when the JUnit tests start. But there's a missing piece: the application configuration. The following is a subset of the full application.yaml file, showing just the part related to the database:

quarkus:
  banner:
    enabled: false
  datasource:
    db-kind: postgresql
    devservices:
      image-name: postgres:13
  hibernate-orm:
    database:
      generation: none

"%dev":
  quarkus:
    log:
      level: INFO
      category:
        "dev.rhenergy":
          level: DEBUG
    hibernate-orm:
      log:
        sql: true
    flyway:
      migrate-at-start: true
      locations: db/migration,db/testdata

"%test":
  quarkus:
    log:
      level: INFO
      category:
        "dev.rhenergy":
          level: DEBUG
    hibernate-orm:
      log:
        sql: true
    flyway:
      migrate-at-start: true
      locations: db/migration,db/testdata

The only adjustment made to the devservices is to specify the use of a particular Postgres container.

Now run your tests:

./mvnw clean test

The tests compile and start a fully running application. The tests are running against the actual HTTP endpoints.

Summary

Hopefully, this article gives you a good idea of the scope of building a new REST API from scratch. Future articles will go through the complete Quarkus development life cycle and show the full deployment to the production environment, with everything that entails. Happy coding.

Last updated: January 12, 2024