Building a Customer API using Quarkus From the Ground Up

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 and Entity 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 the save and update 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 a log 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 a GET, but when I have a POST, 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.

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

Leave a Reply

Your email address will not be published. Required fields are marked *