Build a REST API from the ground up with Quarkus 3

Last Updated: 8/28/2024

This blog post serves as an aggregation of how to build a REST API.

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,rest-jackson,oidc,smallrye-health,micrometer-registry-prometheus \
    com.stephennimmo:quarkus-customer-api:0.0.1-SNAPSHOT
cd quarkus-customer-api
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.

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

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.stephennimmo.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.stephennimmo.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 Customer record.

package com.stephennimmo.customer;

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

public record Customer(
        Long customerId,
        @NotEmpty(message = "{Customer.firstName.required}") String firstName,
        String middleName,
        @NotEmpty(message = "{Customer.lastName.required}") String lastName,
        String suffix,
        @Email(message = "{Customer.email.invalid}") String email,
        String phone
) {
}

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

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

And the Mapper.

package com.stephennimmo.customer;

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

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

And now the CustomerService with CRUD functionality

package com.stephennimmo.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 openapi extension

quarkus ext add smallrye-openapi

Create the Role interface for the role constants

package com.stephennimmo.customer;

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

Create the CustomerV1 record for the schema

package com.stephennimmo.customer;

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

public record CustomerV1(
        Long customerId,
        @NotEmpty(message = "{Customer.firstName.required}") String firstName,
        String middleName,
        @NotEmpty(message = "{Customer.lastName.required}") String lastName,
        String suffix,
        @Email(message = "{Customer.email.invalid}") String email,
        String phone
) {
}

Add the mapping method to the mapper.

CustomerV1 toView(Customer customer);

Customer toDomain(CustomerV1 customerV1);

Now the Customer resource class

package com.stephennimmo.customer;

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("/api/v1/customers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "customer", description = "Customer Operations")
public class CustomerResourceV1 {

    private final CustomerService customerService;
    private final CustomerMapper customerMapper;

    public CustomerResourceV1(CustomerService customerService, CustomerMapper customerMapper) {
        this.customerService = customerService;
        this.customerMapper = customerMapper;
    }

    @GET
    @APIResponse(responseCode = "200", description = "Get All Customers",
            content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = CustomerV1.class))
    )
    @RolesAllowed(Role.CUSTOMER_READ)
    public Response get() {
        return Response.ok(customerService.findAll().stream().map(customerMapper::toView)).build();
    }

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

    @POST
    @APIResponse(responseCode = "201", description = "Customer Created",
            content = @Content(schema = @Schema(implementation = CustomerV1.class))
    )
    @APIResponse(responseCode = "400", description = "Invalid Customer")
    @RolesAllowed(Role.CUSTOMER_WRITE)
    public Response post(@NotNull @Valid CustomerV1 customerV1, @Context UriInfo uriInfo) {
        Customer created = customerService.create(customerMapper.toDomain(customerV1));
        URI uri = uriInfo.getAbsolutePathBuilder().path(Long.toString(created.customerId())).build();
        return Response.created(uri).entity(customerMapper.toView(created)).build();
    }

    @PUT
    @Path("/{customerId}")
    @APIResponse(responseCode = "204", description = "Customer updated")
    @APIResponse(responseCode = "400", description = "Invalid Customer")
    @APIResponse(responseCode = "404", description = "No Customer found for customerId provided")
    @RolesAllowed(Role.CUSTOMER_WRITE)
    public Response put(@Parameter(name = "customerId", required = true) @PathParam("customerId") Long customerId, @Valid CustomerV1 customerV1) {
        if (!Objects.equals(customerId, customerV1.customerId())) {
            throw new WebApplicationException("Path variable customerId does not match Customer.customerId", Response.Status.BAD_REQUEST);
        }
        customerService.update(customerMapper.toDomain(customerV1));
        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")
    @RolesAllowed(Role.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 test dependencies

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-security</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

Test

package com.stephennimmo.customer;

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

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

@QuarkusTest
public class CustomerResourceV1Test {

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

    @Test
    public void getById() {
        CustomerV1 customer = createCustomer();
        CustomerV1 saved = given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post("/api/v1/customers")
                .then()
                .statusCode(Response.Status.CREATED.getStatusCode())
                .extract().as(CustomerV1.class);
        CustomerV1 got = given()
                .when()
                .get("/api/v1/customers/{customerId}", saved.customerId())
                .then()
                .statusCode(Response.Status.OK.getStatusCode())
                .extract().as(CustomerV1.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() {
        CustomerV1 customer = createCustomer();
        CustomerV1 saved = given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post("/api/v1/customers")
                .then()
                .statusCode(Response.Status.CREATED.getStatusCode())
                .extract().as(CustomerV1.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() {
        CustomerV1 customer = createCustomer();
        CustomerV1 saved = given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post("/api/v1/customers")
                .then()
                .statusCode(Response.Status.CREATED.getStatusCode())
                .extract().as(CustomerV1.class);
        CustomerV1 updated = new CustomerV1(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() {
        CustomerV1 customer = createCustomer();
        CustomerV1 saved = given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post("/api/v1/customers")
                .then()
                .statusCode(Response.Status.CREATED.getStatusCode())
                .extract().as(CustomerV1.class);
        CustomerV1 updated = new CustomerV1(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 CustomerV1 createCustomer() {
        return new CustomerV1(null, RandomStringUtils.randomAlphabetic(10), null,
                RandomStringUtils.randomAlphabetic(10), null,
                RandomStringUtils.randomAlphabetic(10) + "@example.com", RandomStringUtils.randomNumeric(10));
    }

}

Configuration

Three files

quarkus:
  banner:
    enabled: false
  hibernate-orm:
    database:
      generation: none
  http:
    auth:
      permission:
        default:
          paths: "/*"
          policy: "deny"
        api:
          paths: "/api/*"
          policy: "permit"
        q:
          paths: "/q/*"
          policy: "permit"
  swagger-ui:
    always-include: true
    operations-sorter: method
  smallrye-openapi:
    info-title: customer-api
    info-version: 1.0.0
quarkus:
  log:
    level: INFO
    category:
      "com.stephennimmo":
        level: DEBUG
  hibernate-orm:
    log:
      sql: true
  flyway:
    migrate-at-start: true
    locations: db/migration,db/testdata
quarkus:
  log:
    level: INFO
    category:
      "com.stephennimmo":
        level: DEBUG
  hibernate-orm:
    log:
      sql: true
  flyway:
    migrate-at-start: true
    locations: db/migration,db/testdata
quarkus:
  log:
    level: INFO
    category:
      "com.stephennimmo":
        level: INFO
  hibernate-orm:
    log:
      sql: false
  flyway:
    migrate-at-start: true
    locations: db/migration
quarkus dev --clean