Build a REST API from the ground up with Quarkus 3.0

Tools Used

The API

GET https://examplecompany.com/api/v1/customers

GET https://examplecompany.com/api/v1/customers/{customerId}

POST https://examplecompany.com/api/v1/customers

PUT https://examplecompany.com/api/v1/customers/{customerId}

DELETE https://examplecompany.com/api/v1/customers/{customerId}

Security Roles

CUSTOMER_READ

CUSTOMER_WRITE

Project Initialization

We are going to start at the command line again, however we are going to be using the Quarkus CLI this time.

quarkus create app --wrapper --no-code \
    -x config-yaml,resteasy-reactive-jackson \
    com.redhat.api:customer-api:0.0.1-SNAPSHOT

Once it’s complete, cd into the created project and initialize the git repository

git init
git add .
git commit -m "project init"

Now we are ready to get started. Open the project in your favorite IDE and let’s get started.

Core Application Creation

package com.redhat.api;

import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;

@ApplicationPath("/api")
public class CustomerApiApplication extends Application {
}
package com.redhat.api;

public interface Roles {
    String CUSTOMER_READ = "CUSTOMER_READ";
    String CUSTOMER_WRITE = "CUSTOMER_WRITE";
}

Repository Layer

We are still using the Quarkus Panache extension to help us with our database persistence. Let’s get those extensions installed using our trusty Quarkus CLI.

quarkus ext add hibernate-validator,hibernate-orm-panache,jdbc-postgresql,flyway
I would love to use the Hibernate Reactive extensions instead, however they do not support Flyway as Flyway requires a blocking JDBC connection. For most applications, the ability to easily manage database schema changes provides more value than the performance boost related to reactive persistence. 

Flyway

Flyway is now modular and requires the DB specific jar. Add this to your pom.

<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>

The tables need to be built for our application. This is going to be a simple schema with one customer table. Create the sql file and paste in the following:

CREATE TABLE customer
(
    customer_id BIGSERIAL 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;

Validation Messages

System.error=An unexpected error has occurred. Please contact support.
# Customer
Customer.firstName.required=Customer's first name is required
Customer.lastName.required=Customer's last name is required
Customer.email.invalid=Customer's email address is invalid

Panache Framework

Now we are going to add the entity class representing our table to be used as part of the Panache framework.

package com.redhat.api.customer;

import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;

import java.util.Objects;

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

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

    @Column(name = "first_name")
    @NotEmpty(message = "{Customer.firstName.required}")
    public String firstName;

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

    @Column(name = "last_name")
    @NotEmpty(message = "{Customer.lastName.required}")
    public String lastName;

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

    @Column(name = "email")
    @Email(message = "{Customer.email.invalid}")
    public String email;

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

    # equals, hashCode and toString removed for brevity

}

The entities are built so now it’s time for the repository classes.

I choose to use the Repository pattern over the ActiveRecord pattern for Panache. Simply because of the casting issue with the return value of the ActiveRecord classes, which end up like List<io.quarkus.hibernate.orm.panache.PanacheEntityBase> rather than List<Entity>.

Here’s the repository class.

package com.redhat.api.customer;

import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;

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

Service Layer

Now let’s add the Team record.

package com.redhat.api.customer;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;

public record Customer(Long customerId, @NotEmpty String firstName, String middleName, @NotEmpty String lastName,
                       String suffix, @Email String email, String phone) {
}

Now let’s create the TeamService class with the basic CRUD functionality. However to get started, we need to build out the domain object, as well as some data mapping capabilities with MapStruct. Let’s add MapStruct to the pom.

...
<properties>
    <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
</properties>
...
<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
    </dependency>
</dependencies>
...
<build>
    <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>${compiler-plugin.version}</version>
        <configuration>
            <compilerArgs>
                <arg>-parameters</arg>
            </compilerArgs>
            <annotationProcessorPaths>
                <path>
                    <groupId>org.mapstruct</groupId>
                    <artifactId>mapstruct-processor</artifactId>
                    <version>${org.mapstruct.version}</version>
                </path>
            </annotationProcessorPaths>
        </configuration>
    </plugin>
</build>

And the Mapper.

package com.redhat.api.customer;

import org.mapstruct.Mapper;
import org.mapstruct.MappingConstants;

@Mapper(componentModel = MappingConstants.ComponentModel.CDI)
public interface CustomerMapper {
    
    Customer toDomain(CustomerEntity entity);
    
    CustomerEntity toEntity(Customer domain);
    
}

And now the CustomerService with CRUD functionality

package com.redhat.api.customer;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@ApplicationScoped
public class CustomerService {

    private final CustomerRepository customerRepository;
    private final CustomerMapper customerMapper;

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

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

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

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

    @Transactional
    public Customer update(@Valid Customer customer) {
        CustomerEntity entity = this.customerRepository.findById(customer.customerId());
        entity.firstName = customer.firstName();
        entity.middleName = customer.middleName();
        entity.lastName = customer.lastName();
        entity.suffix = customer.suffix();
        entity.email = customer.email();
        entity.phone = customer.phone();
        this.customerRepository.persist(entity);
        return this.customerMapper.toDomain(entity);
    }

    @Transactional
    public void delete(long customerId) {
        this.customerRepository.deleteById(customerId);
    }

}

Resource Layer

Let’s add the opanapi extension

quarkus ext add smallrye-openapi

Now the Customer resource class

package com.redhat.api.customer;

import com.redhat.api.CustomerApiApplication;
import jakarta.annotation.security.RolesAllowed;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;

import java.net.URI;
import java.util.Objects;

@Path("/v1/customers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "customer", description = "Customer Operations")
public class CustomerResource {

    private final CustomerService customerService;

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

    @GET
    @APIResponse(
            responseCode = "200",
            description = "Get All Customers",
            content = @Content(
                    mediaType = MediaType.APPLICATION_JSON,
                    schema = @Schema(type = SchemaType.ARRAY, implementation = Customer.class)
            )
    )
    @APIResponse(
            responseCode = "401",
            description = "Unauthorized",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @RolesAllowed({CustomerApiApplication.Roles.CUSTOMER_READ})
    public Response get() {
        return Response.ok(customerService.findAll()).build();
    }

    @GET
    @Path("/{customerId}")
    @APIResponse(
            responseCode = "200",
            description = "Get Customer by customerId",
            content = @Content(
                    mediaType = MediaType.APPLICATION_JSON,
                    schema = @Schema(type = SchemaType.OBJECT, implementation = Customer.class)
            )
    )
    @APIResponse(
            responseCode = "404",
            description = "Customer does not exist for customerId",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @APIResponse(
            responseCode = "401",
            description = "Unauthorized",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @RolesAllowed({CustomerApiApplication.Roles.CUSTOMER_READ})
    public Response getById(@Parameter(name = "customerId", required = true) @PathParam("customerId") Long customerId) {
        return customerService.findById(customerId)
                .map(customer -> Response.ok(customer).build())
                .orElse(Response.status(Response.Status.NOT_FOUND).build());
    }

    @POST
    @APIResponse(
            responseCode = "201",
            description = "Customer Created",
            content = @Content(
                    mediaType = MediaType.APPLICATION_JSON,
                    schema = @Schema(type = SchemaType.OBJECT, implementation = Customer.class)
            )
    )
    @APIResponse(
            responseCode = "400",
            description = "Invalid Customer",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @APIResponse(
            responseCode = "400",
            description = "Customer already exists for customerId",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @APIResponse(
            responseCode = "401",
            description = "Unauthorized",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @RolesAllowed({CustomerApiApplication.Roles.CUSTOMER_WRITE})
    public Response post(@NotNull @Valid Customer customer, @Context UriInfo uriInfo) {
        Customer created = customerService.create(customer);
        URI uri = uriInfo.getAbsolutePathBuilder().path(Long.toString(created.customerId())).build();
        return Response.created(uri).entity(created).build();
    }

    @PUT
    @Path("/{customerId}")
    @APIResponse(
            responseCode = "204",
            description = "Customer updated",
            content = @Content(
                    mediaType = MediaType.APPLICATION_JSON,
                    schema = @Schema(type = SchemaType.OBJECT, implementation = Customer.class)
            )
    )
    @APIResponse(
            responseCode = "400",
            description = "Invalid Customer",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @APIResponse(
            responseCode = "400",
            description = "Customer object does not have customerId",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @APIResponse(
            responseCode = "400",
            description = "Path variable customerId does not match Customer.customerId",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @APIResponse(
            responseCode = "404",
            description = "No Customer found for customerId provided",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @APIResponse(
            responseCode = "401",
            description = "Unauthorized",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @RolesAllowed({CustomerApiApplication.Roles.CUSTOMER_WRITE})
    public Response put(@Parameter(name = "customerId", required = true) @PathParam("customerId") Long customerId, @NotNull @Valid Customer customer) {
        if (!Objects.equals(customerId, customer.customerId())) {
            throw new WebApplicationException("Path variable customerId does not match Customer.customerId", Response.Status.BAD_REQUEST);
        }
        customerService.update(customer);
        return Response.status(Response.Status.NO_CONTENT).build();
    }

    @DELETE
    @Path("/{customerId}")
    @APIResponse(
            responseCode = "204",
            description = "Customer deleted"
    )
    @APIResponse(
            responseCode = "404",
            description = "No Customer found for customerId provided",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @APIResponse(
            responseCode = "401",
            description = "Unauthorized",
            content = @Content(mediaType = MediaType.APPLICATION_JSON)
    )
    @RolesAllowed(CustomerApiApplication.Roles.CUSTOMER_WRITE)
    public Response delete(@Parameter(name = "customerId", required = true) @PathParam("customerId") Long customerId) {
        if (customerService.findById(customerId).isEmpty()) {
            throw new WebApplicationException(String.format("No Customer found for customerId[%s]", customerId), Response.Status.NOT_FOUND);
        }
        customerService.delete(customerId);
        return Response.status(Response.Status.NO_CONTENT).build();
    }

}

Tests

Add Dependency

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

Test

package com.redhat.api;

import com.redhat.api.customer.Customer;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import jakarta.ws.rs.core.Response;
import org.apache.commons.lang3.RandomStringUtils;
import org.junit.jupiter.api.Test;

import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;

@QuarkusTest
public class CustomerResourceTest {

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

    @Test
    public void getById() {
        Customer customer = createCustomer();
        Customer saved = given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post("/api/v1/customers")
                .then()
                .statusCode(Response.Status.CREATED.getStatusCode())
                .extract().as(Customer.class);
        Customer got = given()
                .when()
                .get("/api/v1/customers/{customerId}", saved.customerId())
                .then()
                .statusCode(Response.Status.OK.getStatusCode())
                .extract().as(Customer.class);
        assertThat(saved).isEqualTo(got);
    }

    @Test
    public void getByIdNotFound() {
        given()
                .when()
                .get("/api/v1/customers/{customerId}", 987654321)
                .then()
                .statusCode(Response.Status.NOT_FOUND.getStatusCode());
    }

    @Test
    public void post() {
        Customer customer = createCustomer();
        Customer saved = given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post("/api/v1/customers")
                .then()
                .statusCode(Response.Status.CREATED.getStatusCode())
                .extract().as(Customer.class);
        assertThat(saved.customerId()).isNotNull();
    }

    @Test
    public void postFailNoFirstName() {
        Customer customer = new Customer(null, null, null, RandomStringUtils.randomAlphabetic(10),
                null, null, null);
        given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post("/api/v1/customers")
                .then()
                .statusCode(Response.Status.BAD_REQUEST.getStatusCode());
    }

    @Test
    public void put() {
        Customer customer = createCustomer();
        Customer saved = given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post("/api/v1/customers")
                .then()
                .statusCode(Response.Status.CREATED.getStatusCode())
                .extract().as(Customer.class);
        Customer updated = new Customer(saved.customerId(), saved.firstName(), saved.middleName(), saved.lastName(),
                saved.suffix(), saved.email(), saved.phone());
        given()
                .contentType(ContentType.JSON)
                .body(updated)
                .put("/api/v1/customers/{customerId}", updated.customerId())
                .then()
                .statusCode(Response.Status.NO_CONTENT.getStatusCode());
    }

    @Test
    public void putFailNoLastName() {
        Customer customer = createCustomer();
        Customer saved = given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post("/api/v1/customers")
                .then()
                .statusCode(Response.Status.CREATED.getStatusCode())
                .extract().as(Customer.class);
        Customer updated = new Customer(saved.customerId(), saved.firstName(), saved.middleName(), null,
                saved.suffix(), saved.email(), saved.phone());
        given()
                .contentType(ContentType.JSON)
                .body(updated)
                .put("/api/v1/customers/{customerId}", updated.customerId())
                .then()
                .statusCode(Response.Status.BAD_REQUEST.getStatusCode());
    }

    private Customer createCustomer() {
        return new Customer(null, RandomStringUtils.randomAlphabetic(10), null,
                RandomStringUtils.randomAlphabetic(10),    null,
                RandomStringUtils.randomAlphabetic(10) + "@rhenergy.dev", RandomStringUtils.randomNumeric(10));
    }

}

Configuration

Three files

quarkus:
  banner:
    enabled: false
  hibernate-orm:
    database:
      generation: none
  swagger-ui:
    always-include: true
    operations-sorter: method

mp:
  openapi:
    extensions:
      smallrye:
        info:
          title: Customer API
          version: 0.0.1
          description: API for retrieving customers
          contact:
            email: techsupport@redhat.com
            name: Customer API Support
            url: https://github.com/quarkus-ground-up/customer-api
          license:
            name: Apache 2.0
            url: http://www.apache.org/licenses/LICENSE-2.0.html
quarkus:
  log:
    level: INFO
    category:
      "com.redhat":
        level: DEBUG
  hibernate-orm:
    log:
      sql: true
  flyway:
    migrate-at-start: true
    locations: db/migration,db/testdata
quarkus:
  log:
    level: INFO
    category:
      "com.redhat":
        level: DEBUG
  hibernate-orm:
    log:
      sql: true
  flyway:
    migrate-at-start: true
    locations: db/migration,db/testdata
quarkus:
  log:
    level: INFO
    category:
      "com.redhat":
        level: DEBUG
  hibernate-orm:
    log:
      sql: true
  flyway:
    migrate-at-start: true
    locations: db/migration
quarkus test

Additional Extensions