Last Updated: 8/28/2024
This blog post serves as an aggregation of how to build a REST API.
Tools Used
- Quarkus CLI (3.13.3)
- Podman
- An IDE (Intellij IDEA, VSCode)
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.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