Motivation
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 all the moving parts needed for their application. This blog post is an attempt to bring all the parts together and have a single full reference to all of the work that needs to be done.
GitHub Repo: https://github.com/stephennimmo/quarkus-ground-up
Scope and Getting Started
Like any project, we need to start with some scope. We are going to build a Customer API. We are going to want to support the basic CRUD functionality exposed via a REST API, saving the data to a relational database and publishing data changes out to a messaging topic for external asynchronous consumption. Please take note, this will be the first of many coming blog posts as we add additional features and show the full development lifecycle. The scope for this writeup will be to get an API up and running with functional integration tests.
mvn io.quarkus:quarkus-maven-plugin:2.0.0.Final:create \
-DprojectGroupId=dev.rhenergy.customer \
-DprojectArtifactId=customer-api \
-DclassName="dev.rhenergy.customer.CustomerResource" \
-Dpath="/api/customers"
cd customer-api
./mvnw clean quarkus:dev
These commands will create your initial project and start the project in Quarkus dev mode. This provides you quick validation the project creation is successful and ready for work.
Open the project in your IDE and let’s get started.
Architecture Layers
I traditionally like to stick with the Resource
/Service
/Repository
layering pattern. 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 related in the same package. I used to do things like this and split out packages into the architectural layers.
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.
MicroProfile and JEE Imports
As we are developing, you might not pick up on a subtle piece of the effort. Keep one eye on your imports. Don’t just blindly 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 and feed.
Let’s Start Coding
Yaml Properties
First thing we update is to change the way properties are managed. I like yaml better than properties files. The first pom.xml change is to add this support.
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-config-yaml</artifactId>
</dependency>
Then go rename the application.properties
to application.yaml
Lombok
Lombok has a love hate relationship in the Java community. I am a big fan, so we are going to use it on this project. Let’s get it added as a dependency. (Yes, I recognize the dichotomy of this action as it pertains to the previous section. Make conscious choices….)
The version is separated out as a property and should be appended to the bottom of the existing properties in the pom file. We will use this later.
<lombok.version>1.18.20</lombok.version>
...
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
Flyway, PostgreSQL and Panache
The data interactions are going to be managed using the Quarkus Panache extension. We are also going to version our database schema using Flyway, which has a Quarkus extension as well. To get started, let’s add the extensions to the pom.xml
<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 certain aspects of this application. Should the email be required? Should the phone be required? Maybe, maybe not.
We will place our first flyway sql file in the normal deployment location.
CREATE TABLE customer
(
customer_id SERIAL PRIMARY KEY,
first_name TEXT NOT NULL,
middle_name TEXT,
last_name TEXT NOT NULL,
suffix TEXT,
email TEXT,
phone TEXT
);
ALTER SEQUENCE customer_customer_id_seq RESTART 1000;
- NOTE: There is much discussion around how schema changes should be rolled out in production. For now, we are simply going to let the embedded flyway library just migrate the changes within the application startup. Obviously, if your application requires more advanced rollouts such as blue/green or canary, there will need to be an effort to split the pipelines to have the schema changes roll out independently and be backwards compatible. I will follow up with a dedicated blog post just covering these strategies.
JPA with Panache
Based on the table above, we will create our first Entity
object. I use the Repository pattern because I like the extra Repository
interface there.
@Entity(name = "Customer")
@Table(name = "customer")
@Data
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;
}
Entity Notes:
- I like to name all my JPA entity classes with the Suffix of Entity. They serve a purpose which is mapping back to the database tables. I always provide a layer of indirection between
Domain
objects andEntity
objects because I’ve been bitten more times by not having them in comparison to the time spent creating and managing the data copying processes. - Because of the naming thing, you have to explicitly put the
@Entity
annotation in there so your HQL queries don’t have to be “CustomerEntity”. - I like to explicitly name both the table and the columns with the
@Table
and@Column
annotations. Why? I’ve been bitten more times with a code refactor inadvertently breaking the assumed named contracts than it costs me to write a few extra annotations.- Also, my database columns are snake_case and the entity’s class variables are camelCase.
- The first showing of a Lombok annotation –
@Data
. That autogenerates the getters, setters, toString and hashCode for the class. Nice!
And here’s the repository interface. It looks simple but it’s got power in all the right places.
@ApplicationScoped
public class CustomerRepository implements PanacheRepositoryBase<CustomerEntity, Integer> {
}
Domain, then Service, then Resource
The Customer domain object for this first version will be very simple.
@Data
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 will need to do some mappings between the domain object to the entity object. For these purposes, we will add in MapStruct. First the actual dependency, then the compiler plugin will need to be enhanced with some configuration. Because the MapStruct generates code AND Lombok generates code, we need to have them both in the configuration to ensure they both do their jobs. Lombok needs to generate the getters/setters first, which will then be used in the Mapper generations. This is where the Lombok version property comes in.
<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>
<parameters>${maven.compiler.parameters}</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<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. Note the additional componentModel = “cdi”. 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 custom logic based exceptions or for wrapping checked exceptions.
public class ServiceException extends RuntimeException {
public ServiceException(String message) {
super(message);
}
}
Now, the Service…
We can then build out the Service
class to handle the CRUD.
@ApplicationScoped
@AllArgsConstructor
public class CustomerService {
private CustomerRepository customerRepository;
private CustomerMapper customerMapper;
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 thesave
andupdate
methods. This annotation as it stands is the default behavior, which is to create a new one or use an existing.
The Resource
Now let’s build out the Resource
. To start us off, we are going to be using the OpenAPI spec to conform our REST API. Let’s grab the Quarkus extension for that and put it in our pom.
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
Side Note: You can also add extensions via the Quarkus Maven plugin using the following command. I usually can’t remember the names so I still 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 de/serialization.
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-jsonb</artifactId>
</dependency>
Object Validation
We are also going to be using the hibernate bean validation framework. This allows you to place @Valid
annotations on the method arguments to trigger the beans’s javax.validation.contraints
annotations like @NotEmpty
and @Email
.
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
Resource
Here’s the resource class, with some key footnotes. WE WILL BE REVISITING THIS CLASS LATER, AS IT’S VERY PRELIMINARY.
@Path("/api/customers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Slf4j
@AllArgsConstructor
public class CustomerResource {
private CustomerService customerService;
@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();
}
}
- The
@Produces
and @Consumes
can be at the class level, rather than the method level, reducing duplication. - The
@Slf4j
annotation is a Lombok thing which autoinjects a logger for us to use as alog
object. - The
@AllArgsConstructor
is also a Lombok thing and it autogenerates a constructor for all of the class variables. This doubles not only as a way to inject mocks in our later tests, but also is autodetected by CDI to do the injections. - The
@APIResponses
definitions are a way to inline the swagger documentation directly in the code. It generates some noise but it also reduces the need to maintain the implementation class separated from the swagger definition. - Notice all the methods actually return the
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. 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, then the@ApiResponses
serve as the documentation on the actual payload returning in the body of the response. The OpenAPI extension will autogenerate all the swagger and the swagger UI for you automatically.
- If you use the
OpenAPI Additional Documentation
The OpenAPI swagger and UI can be configured in the application.yaml. Here’s the config.
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
So now we have the full stack in place, let’s get to the tests. Let’s add the AssertJ library to the pom.xml. Fluent assertions FTW.
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
Now on to the test.
@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;
}
}
Now let’s add the rest of the quarkus configuration required for the tests.
quarkus:
banner:
enabled: false
datasource:
db-kind: postgresql
hibernate-orm:
database:
generation: none
"%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
Now run your tests. The tests actually compile and start a fully running application and the tests are running against the actual HTTP endpoints.
./mvnw clean test
Summary
Hopefully this writeup gives you a good idea on the scope of building a new REST API from scratch. In future blog posts, we will start the full development lifecycle and show the full deployment to the production environment and everything that entails.
Happy Coding.
One comment