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.
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.
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.properties
file 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 betweenDomain
objects andEntity
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 referenceCustomerEntity
. - 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.
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 theResponse
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 aGET
, but when I have aPOST
, 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