Tools Used
- Quarkus CLI
- 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,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