From d2e36e035b2939c0a3be958de3436a336a8e9bf2 Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Sun, 8 Feb 2026 23:04:49 +0000 Subject: [PATCH 01/11] feat: Implement InventoryResource providing a REST API for inventory management with CRUD operations. --- .../redhat/cloudnative/InventoryResource.java | 132 +++++++++++++++++- 1 file changed, 128 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/redhat/cloudnative/InventoryResource.java b/src/main/java/com/redhat/cloudnative/InventoryResource.java index ad2f09c..95b98ee 100644 --- a/src/main/java/com/redhat/cloudnative/InventoryResource.java +++ b/src/main/java/com/redhat/cloudnative/InventoryResource.java @@ -1,22 +1,146 @@ package com.redhat.cloudnative; import javax.enterprise.context.ApplicationScoped; +import javax.transaction.Transactional; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; import javax.ws.rs.GET; +import javax.ws.rs.PATCH; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.net.URI; +import java.util.List; @Path("/api/inventory") @ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) public class InventoryResource { + @GET + public List listAll(@QueryParam("page") Integer page, @QueryParam("size") Integer size) { + if (page != null && size != null) { + return Inventory.findAll() + .page(page, size) + .list(); + } + return Inventory.listAll(); + } + + @GET + @Path("/count") + @Produces(MediaType.TEXT_PLAIN) + public Long count() { + return Inventory.count(); + } @GET @Path("/{itemId}") - @Produces(MediaType.APPLICATION_JSON) - public Inventory getAvailability(@PathParam("itemId") long itemId) { + public Response getAvailability(@PathParam("itemId") Long itemId) { + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Inventory item not found with id: " + itemId + "\"}") + .build(); + } + return Response.ok(inventory).build(); + } + + @POST + @Transactional + public Response create(Inventory inventory) { + if (inventory == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Inventory item cannot be null\"}") + .build(); + } + if (inventory.quantity < 0) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Quantity cannot be negative\"}") + .build(); + } + inventory.persist(); + return Response.created(URI.create("/api/inventory/" + inventory.id)) + .entity(inventory) + .build(); + } + + @PUT + @Path("/{itemId}") + @Transactional + public Response update(@PathParam("itemId") Long itemId, Inventory updatedInventory) { + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Inventory item not found with id: " + itemId + "\"}") + .build(); + } + if (updatedInventory == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Inventory item cannot be null\"}") + .build(); + } + if (updatedInventory.quantity < 0) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Quantity cannot be negative\"}") + .build(); + } + inventory.quantity = updatedInventory.quantity; + inventory.persist(); + return Response.ok(inventory).build(); + } + + @PATCH + @Path("/{itemId}/quantity") + @Transactional + public Response updateQuantity(@PathParam("itemId") Long itemId, @QueryParam("quantity") Integer quantity) { + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Inventory item not found with id: " + itemId + "\"}") + .build(); + } + if (quantity == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Quantity parameter is required\"}") + .build(); + } + if (quantity < 0) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Quantity cannot be negative\"}") + .build(); + } + inventory.quantity = quantity; + inventory.persist(); + return Response.ok(inventory).build(); + } + + @DELETE + @Path("/{itemId}") + @Transactional + public Response delete(@PathParam("itemId") Long itemId) { Inventory inventory = Inventory.findById(itemId); - return inventory; + if (inventory == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Inventory item not found with id: " + itemId + "\"}") + .build(); + } + inventory.delete(); + return Response.noContent().build(); + } + + @DELETE + @Transactional + public Response deleteAll() { + long deleted = Inventory.deleteAll(); + return Response.ok() + .entity("{\"deleted\": " + deleted + "}") + .build(); } -} +} \ No newline at end of file From c41626222cb7a6ffedb6d44f861cd5f8a4536607 Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Sun, 8 Feb 2026 23:19:42 +0000 Subject: [PATCH 02/11] feat: Error Handling Implementation --- .../com/redhat/cloudnative/ErrorResponse.java | 99 +++++++++++++++++++ .../InvalidInventoryException.java | 8 ++ .../InvalidInventoryExceptionMapper.java | 29 ++++++ .../InventoryNotFoundException.java | 15 +++ .../InventoryNotFoundExceptionMapper.java | 29 ++++++ .../redhat/cloudnative/InventoryResource.java | 73 +++++--------- 6 files changed, 206 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/redhat/cloudnative/ErrorResponse.java create mode 100644 src/main/java/com/redhat/cloudnative/InvalidInventoryException.java create mode 100644 src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java create mode 100644 src/main/java/com/redhat/cloudnative/InventoryNotFoundException.java create mode 100644 src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java diff --git a/src/main/java/com/redhat/cloudnative/ErrorResponse.java b/src/main/java/com/redhat/cloudnative/ErrorResponse.java new file mode 100644 index 0000000..92965c5 --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/ErrorResponse.java @@ -0,0 +1,99 @@ +package com.redhat.cloudnative; + +import java.time.Instant; + +public class ErrorResponse { + + private int status; + private String error; + private String message; + private String path; + private Instant timestamp; + + public ErrorResponse() { + this.timestamp = Instant.now(); + } + + public ErrorResponse(int status, String error, String message, String path) { + this.status = status; + this.error = error; + this.message = message; + this.path = path; + this.timestamp = Instant.now(); + } + + public int getStatus() { + return status; + } + + public void setStatus(int status) { + this.status = status; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private int status; + private String error; + private String message; + private String path; + + public Builder status(int status) { + this.status = status; + return this; + } + + public Builder error(String error) { + this.error = error; + return this; + } + + public Builder message(String message) { + this.message = message; + return this; + } + + public Builder path(String path) { + this.path = path; + return this; + } + + public ErrorResponse build() { + return new ErrorResponse(status, error, message, path); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InvalidInventoryException.java b/src/main/java/com/redhat/cloudnative/InvalidInventoryException.java new file mode 100644 index 0000000..70b83eb --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InvalidInventoryException.java @@ -0,0 +1,8 @@ +package com.redhat.cloudnative; + +public class InvalidInventoryException extends RuntimeException { + + public InvalidInventoryException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java b/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java new file mode 100644 index 0000000..837d12e --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java @@ -0,0 +1,29 @@ +package com.redhat.cloudnative; + +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +@Provider +public class InvalidInventoryExceptionMapper implements ExceptionMapper { + + @Context + UriInfo uriInfo; + + @Override + public Response toResponse(InvalidInventoryException exception) { + ErrorResponse errorResponse = ErrorResponse.builder() + .status(Response.Status.BAD_REQUEST.getStatusCode()) + .error("Bad Request") + .message(exception.getMessage()) + .path(uriInfo.getRequestUri().getPath()) + .build(); + + return Response.status(Response.Status.BAD_REQUEST) + .entity(errorResponse) + .type(javax.ws.rs.core.MediaType.APPLICATION_JSON) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InventoryNotFoundException.java b/src/main/java/com/redhat/cloudnative/InventoryNotFoundException.java new file mode 100644 index 0000000..ff60adc --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InventoryNotFoundException.java @@ -0,0 +1,15 @@ +package com.redhat.cloudnative; + +public class InventoryNotFoundException extends RuntimeException { + + private final Long itemId; + + public InventoryNotFoundException(Long itemId) { + super("Inventory item not found with id: " + itemId); + this.itemId = itemId; + } + + public Long getItemId() { + return itemId; + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java b/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java new file mode 100644 index 0000000..7916449 --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java @@ -0,0 +1,29 @@ +package com.redhat.cloudnative; + +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +@Provider +public class InventoryNotFoundExceptionMapper implements ExceptionMapper { + + @Context + UriInfo uriInfo; + + @Override + public Response toResponse(InventoryNotFoundException exception) { + ErrorResponse errorResponse = ErrorResponse.builder() + .status(Response.Status.NOT_FOUND.getStatusCode()) + .error("Not Found") + .message(exception.getMessage()) + .path(uriInfo.getRequestUri().getPath()) + .build(); + + return Response.status(Response.Status.NOT_FOUND) + .entity(errorResponse) + .type(javax.ws.rs.core.MediaType.APPLICATION_JSON) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InventoryResource.java b/src/main/java/com/redhat/cloudnative/InventoryResource.java index 95b98ee..c96b38d 100644 --- a/src/main/java/com/redhat/cloudnative/InventoryResource.java +++ b/src/main/java/com/redhat/cloudnative/InventoryResource.java @@ -42,29 +42,18 @@ public Long count() { @GET @Path("/{itemId}") - public Response getAvailability(@PathParam("itemId") Long itemId) { + public Inventory getAvailability(@PathParam("itemId") Long itemId) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { - return Response.status(Response.Status.NOT_FOUND) - .entity("{\"error\": \"Inventory item not found with id: " + itemId + "\"}") - .build(); + throw new InventoryNotFoundException(itemId); } - return Response.ok(inventory).build(); + return inventory; } @POST @Transactional public Response create(Inventory inventory) { - if (inventory == null) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("{\"error\": \"Inventory item cannot be null\"}") - .build(); - } - if (inventory.quantity < 0) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("{\"error\": \"Quantity cannot be negative\"}") - .build(); - } + validateInventory(inventory); inventory.persist(); return Response.created(URI.create("/api/inventory/" + inventory.id)) .entity(inventory) @@ -74,51 +63,34 @@ public Response create(Inventory inventory) { @PUT @Path("/{itemId}") @Transactional - public Response update(@PathParam("itemId") Long itemId, Inventory updatedInventory) { + public Inventory update(@PathParam("itemId") Long itemId, Inventory updatedInventory) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { - return Response.status(Response.Status.NOT_FOUND) - .entity("{\"error\": \"Inventory item not found with id: " + itemId + "\"}") - .build(); - } - if (updatedInventory == null) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("{\"error\": \"Inventory item cannot be null\"}") - .build(); - } - if (updatedInventory.quantity < 0) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("{\"error\": \"Quantity cannot be negative\"}") - .build(); + throw new InventoryNotFoundException(itemId); } + validateInventory(updatedInventory); inventory.quantity = updatedInventory.quantity; inventory.persist(); - return Response.ok(inventory).build(); + return inventory; } @PATCH @Path("/{itemId}/quantity") @Transactional - public Response updateQuantity(@PathParam("itemId") Long itemId, @QueryParam("quantity") Integer quantity) { - Inventory inventory = Inventory.findById(itemId); - if (inventory == null) { - return Response.status(Response.Status.NOT_FOUND) - .entity("{\"error\": \"Inventory item not found with id: " + itemId + "\"}") - .build(); - } + public Inventory updateQuantity(@PathParam("itemId") Long itemId, @QueryParam("quantity") Integer quantity) { if (quantity == null) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("{\"error\": \"Quantity parameter is required\"}") - .build(); + throw new InvalidInventoryException("Quantity parameter is required"); } if (quantity < 0) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("{\"error\": \"Quantity cannot be negative\"}") - .build(); + throw new InvalidInventoryException("Quantity cannot be negative"); + } + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + throw new InventoryNotFoundException(itemId); } inventory.quantity = quantity; inventory.persist(); - return Response.ok(inventory).build(); + return inventory; } @DELETE @@ -127,9 +99,7 @@ public Response updateQuantity(@PathParam("itemId") Long itemId, @QueryParam("qu public Response delete(@PathParam("itemId") Long itemId) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { - return Response.status(Response.Status.NOT_FOUND) - .entity("{\"error\": \"Inventory item not found with id: " + itemId + "\"}") - .build(); + throw new InventoryNotFoundException(itemId); } inventory.delete(); return Response.noContent().build(); @@ -143,4 +113,13 @@ public Response deleteAll() { .entity("{\"deleted\": " + deleted + "}") .build(); } + + private void validateInventory(Inventory inventory) { + if (inventory == null) { + throw new InvalidInventoryException("Inventory item cannot be null"); + } + if (inventory.quantity < 0) { + throw new InvalidInventoryException("Quantity cannot be negative"); + } + } } \ No newline at end of file From 3a3bcab37b04f1659df30dff48d925de3f00f296 Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Sun, 8 Feb 2026 23:40:51 +0000 Subject: [PATCH 03/11] feat: Implement inventory management API and upgrade Quarkus to 3.8.4 with reactive REST. --- .mvn/wrapper/maven-wrapper.properties | 8 +- pom.xml | 85 ++++++++++--------- .../InvalidInventoryExceptionMapper.java | 12 +-- .../com/redhat/cloudnative/Inventory.java | 18 ++-- .../InventoryNotFoundExceptionMapper.java | 12 +-- .../redhat/cloudnative/InventoryResource.java | 45 ++++------ .../NativeInventoryResourceIT.java | 4 +- 7 files changed, 89 insertions(+), 95 deletions(-) diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 665b40e..891394d 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -5,14 +5,14 @@ # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at -# +# # http://www.apache.org/licenses/LICENSE-2.0 -# +# # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.2/apache-maven-3.6.2-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar \ No newline at end of file diff --git a/pom.xml b/pom.xml index 3cf5266..d23f8c9 100644 --- a/pom.xml +++ b/pom.xml @@ -7,67 +7,71 @@ 1.0.0-SNAPSHOT - 2.1.4.Final + 3.8.4 quarkus-bom io.quarkus - 2.1.4.Final - uber-jar - 3.8.1 - 3.0.0-M5 + 3.8.4 + uber-jar + 3.11.0 + 3.2.5 UTF-8 - 11 - 11 - true - 4.12.0 + 17 + 17 + true + 6.12.0 - - - io.quarkus - quarkus-bom - ${quarkus.platform.version} - pom - import - + + + io.quarkus + quarkus-bom + ${quarkus.platform.version} + pom + import + - io.quarkus - quarkus-resteasy + io.quarkus + quarkus-resteasy-reactive - io.quarkus - quarkus-junit5 - test + io.quarkus + quarkus-resteasy-reactive-jackson - io.rest-assured - rest-assured - test + io.quarkus + quarkus-junit5 + test - io.quarkus - quarkus-resteasy-jsonb + io.rest-assured + rest-assured + test - io.quarkus - quarkus-hibernate-orm-panache + io.quarkus + quarkus-hibernate-orm-panache - io.quarkus - quarkus-jdbc-h2 - - - io.fabric8 - tekton-client - ${tekton-client.version} + io.quarkus + quarkus-jdbc-h2 - - io.quarkus - quarkus-smallrye-health + + io.fabric8 + tekton-client + ${tekton-client.version} + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-hibernate-validator @@ -102,7 +106,6 @@ - native @@ -151,4 +154,4 @@ - + \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java b/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java index 837d12e..def5786 100644 --- a/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java +++ b/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java @@ -1,10 +1,10 @@ package com.redhat.cloudnative; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriInfo; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; @Provider public class InvalidInventoryExceptionMapper implements ExceptionMapper { @@ -23,7 +23,7 @@ public Response toResponse(InvalidInventoryException exception) { return Response.status(Response.Status.BAD_REQUEST) .entity(errorResponse) - .type(javax.ws.rs.core.MediaType.APPLICATION_JSON) + .type(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/Inventory.java b/src/main/java/com/redhat/cloudnative/Inventory.java index 3080822..4afad30 100644 --- a/src/main/java/com/redhat/cloudnative/Inventory.java +++ b/src/main/java/com/redhat/cloudnative/Inventory.java @@ -1,22 +1,22 @@ package com.redhat.cloudnative; -import javax.persistence.Entity; -import javax.persistence.Table; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Column; +import jakarta.validation.constraints.Min; import io.quarkus.hibernate.orm.panache.PanacheEntity; -import javax.persistence.Column; - -@Entity -@Table(name = "INVENTORY") -public class Inventory extends PanacheEntity{ +@Entity +@Table(name = "INVENTORY") +public class Inventory extends PanacheEntity { @Column + @Min(value = 0, message = "Quantity cannot be negative") public int quantity; - @Override public String toString() { return "Inventory [Id='" + id + '\'' + ", quantity=" + quantity + ']'; } -} +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java b/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java index 7916449..d7cdcb7 100644 --- a/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java +++ b/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java @@ -1,10 +1,10 @@ package com.redhat.cloudnative; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriInfo; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; @Provider public class InventoryNotFoundExceptionMapper implements ExceptionMapper { @@ -23,7 +23,7 @@ public Response toResponse(InventoryNotFoundException exception) { return Response.status(Response.Status.NOT_FOUND) .entity(errorResponse) - .type(javax.ws.rs.core.MediaType.APPLICATION_JSON) + .type(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InventoryResource.java b/src/main/java/com/redhat/cloudnative/InventoryResource.java index c96b38d..8227a05 100644 --- a/src/main/java/com/redhat/cloudnative/InventoryResource.java +++ b/src/main/java/com/redhat/cloudnative/InventoryResource.java @@ -1,19 +1,21 @@ package com.redhat.cloudnative; -import javax.enterprise.context.ApplicationScoped; -import javax.transaction.Transactional; -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; -import javax.ws.rs.GET; -import javax.ws.rs.PATCH; -import javax.ws.rs.POST; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + import java.net.URI; import java.util.List; @@ -52,8 +54,7 @@ public Inventory getAvailability(@PathParam("itemId") Long itemId) { @POST @Transactional - public Response create(Inventory inventory) { - validateInventory(inventory); + public Response create(@Valid Inventory inventory) { inventory.persist(); return Response.created(URI.create("/api/inventory/" + inventory.id)) .entity(inventory) @@ -63,12 +64,11 @@ public Response create(Inventory inventory) { @PUT @Path("/{itemId}") @Transactional - public Inventory update(@PathParam("itemId") Long itemId, Inventory updatedInventory) { + public Inventory update(@PathParam("itemId") Long itemId, @Valid Inventory updatedInventory) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { throw new InventoryNotFoundException(itemId); } - validateInventory(updatedInventory); inventory.quantity = updatedInventory.quantity; inventory.persist(); return inventory; @@ -113,13 +113,4 @@ public Response deleteAll() { .entity("{\"deleted\": " + deleted + "}") .build(); } - - private void validateInventory(Inventory inventory) { - if (inventory == null) { - throw new InvalidInventoryException("Inventory item cannot be null"); - } - if (inventory.quantity < 0) { - throw new InvalidInventoryException("Quantity cannot be negative"); - } - } } \ No newline at end of file diff --git a/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java b/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java index 0259ad2..48e49d1 100644 --- a/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java +++ b/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java @@ -1,8 +1,8 @@ package com.redhat.cloudnative; -import io.quarkus.test.junit.NativeImageTest; +import io.quarkus.test.junit.QuarkusIntegrationTest; -@NativeImageTest +@QuarkusIntegrationTest public class NativeInventoryResourceIT extends InventoryResourceTest { // Execute the same tests but in native mode. From 2f8691a17d3299a0eeebd5aed2f3536d19a9a3b1 Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Sun, 8 Feb 2026 23:56:05 +0000 Subject: [PATCH 04/11] test: Add comprehensive tests for the InventoryResource covering CRUD operations, pagination, error handling, and content types. --- .../cloudnative/InventoryResourceTest.java | 286 +++++++++++++++++- 1 file changed, 276 insertions(+), 10 deletions(-) diff --git a/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java b/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java index 107c0fb..17b248b 100644 --- a/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java +++ b/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java @@ -1,21 +1,287 @@ package com.redhat.cloudnative; import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; import org.junit.jupiter.api.Test; import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.*; @QuarkusTest public class InventoryResourceTest { - @Test - public void testHelloEndpoint() { - given() - .when().get("/api/inventory/329299") - .then() - .statusCode(200) - .body(is("{\"id\":329299,\"quantity\":35}")); - } + // ==================== GET /api/inventory Tests ==================== -} + @Test + public void testListAllInventory() { + given() + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size()", is(8)); + } + + @Test + public void testListInventoryWithPagination() { + given() + .queryParam("page", 0) + .queryParam("size", 3) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size()", is(3)); + } + + @Test + public void testListInventoryWithPaginationSecondPage() { + given() + .queryParam("page", 1) + .queryParam("size", 3) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size()", is(3)); + } + + // ==================== GET /api/inventory/count Tests ==================== + + @Test + public void testCountInventory() { + given() + .when().get("/api/inventory/count") + .then() + .statusCode(200) + .body(is("8")); + } + + // ==================== GET /api/inventory/{id} Tests ==================== + + @Test + public void testGetInventoryById() { + given() + .when().get("/api/inventory/329299") + .then() + .statusCode(200) + .body("id", is(329299)) + .body("quantity", is(35)); + } + + @Test + public void testGetInventoryByIdNotFound() { + given() + .when().get("/api/inventory/999999") + .then() + .statusCode(404) + .body("status", is(404)) + .body("error", is("Not Found")) + .body("message", containsString("not found")); + } + + @Test + public void testGetInventoryByIdExistingItem() { + given() + .when().get("/api/inventory/100000") + .then() + .statusCode(200) + .body("id", is(100000)) + .body("quantity", is(0)); + } + + // ==================== POST /api/inventory Tests ==================== + + @Test + public void testCreateInventory() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 100}") + .when().post("/api/inventory") + .then() + .statusCode(201) + .body("quantity", is(100)) + .header("Location", containsString("/api/inventory/")); + } + + @Test + public void testCreateInventoryWithZeroQuantity() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 0}") + .when().post("/api/inventory") + .then() + .statusCode(201) + .body("quantity", is(0)); + } + + @Test + public void testCreateInventoryWithNegativeQuantity() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": -10}") + .when().post("/api/inventory") + .then() + .statusCode(400); + } + + // ==================== PUT /api/inventory/{id} Tests ==================== + + @Test + public void testUpdateInventory() { + // Use a different ID (165614) to avoid interfering with other tests + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 999}") + .when().put("/api/inventory/165614") + .then() + .statusCode(200) + .body("id", is(165614)) + .body("quantity", is(999)); + } + + @Test + public void testUpdateInventoryNotFound() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 100}") + .when().put("/api/inventory/999999") + .then() + .statusCode(404) + .body("status", is(404)); + } + + @Test + public void testUpdateInventoryWithNegativeQuantity() { + // Use a different ID (165954) to avoid interfering with other tests + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": -5}") + .when().put("/api/inventory/165954") + .then() + .statusCode(400); + } + + // ==================== PATCH /api/inventory/{id}/quantity Tests + // ==================== + + @Test + public void testUpdateQuantity() { + given() + .queryParam("quantity", 500) + .when().patch("/api/inventory/329199/quantity") + .then() + .statusCode(200) + .body("id", is(329199)) + .body("quantity", is(500)); + } + + @Test + public void testUpdateQuantityMissingParameter() { + given() + .when().patch("/api/inventory/329199/quantity") + .then() + .statusCode(400) + .body("status", is(400)) + .body("error", is("Bad Request")) + .body("message", containsString("required")); + } + + @Test + public void testUpdateQuantityNegative() { + given() + .queryParam("quantity", -1) + .when().patch("/api/inventory/329199/quantity") + .then() + .statusCode(400) + .body("status", is(400)) + .body("message", containsString("negative")); + } + + @Test + public void testUpdateQuantityNotFound() { + given() + .queryParam("quantity", 100) + .when().patch("/api/inventory/999999/quantity") + .then() + .statusCode(404); + } + + // ==================== DELETE /api/inventory/{id} Tests ==================== + + @Test + public void testDeleteInventory() { + // First create an item to delete + int createdId = given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 50}") + .when().post("/api/inventory") + .then() + .statusCode(201) + .extract().path("id"); + + // Then delete it + given() + .when().delete("/api/inventory/" + createdId) + .then() + .statusCode(204); + + // Verify it's deleted + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(404); + } + + @Test + public void testDeleteInventoryNotFound() { + given() + .when().delete("/api/inventory/999999") + .then() + .statusCode(404); + } + + // ==================== Content-Type and Headers Tests ==================== + + @Test + public void testGetInventoryReturnsJson() { + given() + .when().get("/api/inventory/329299") + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + public void testCreateInventoryWithWrongContentType() { + given() + .contentType(ContentType.TEXT) + .body("{\"quantity\": 100}") + .when().post("/api/inventory") + .then() + .statusCode(415); // Unsupported Media Type + } + + // ==================== Health Check Tests ==================== + + @Test + public void testHealthEndpoint() { + given() + .when().get("/q/health") + .then() + .statusCode(200); + } + + @Test + public void testHealthReadyEndpoint() { + given() + .when().get("/q/health/ready") + .then() + .statusCode(200); + } + + @Test + public void testHealthLiveEndpoint() { + given() + .when().get("/q/health/live") + .then() + .statusCode(200); + } +} \ No newline at end of file From 262ae078e6d7274f87a4770958422ae4039891e7 Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Sun, 8 Feb 2026 23:59:55 +0000 Subject: [PATCH 05/11] Added IT tests --- .../NativeInventoryResourceIT.java | 216 +++++++++++++++++- 1 file changed, 215 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java b/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java index 48e49d1..a8fef40 100644 --- a/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java +++ b/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java @@ -1,9 +1,223 @@ package com.redhat.cloudnative; import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.*; + +/** + * Integration tests that run against the native executable or container. + * These tests verify the full application stack works correctly in + * production-like mode. + * + * To run native integration tests: + * 1. Build the native executable: ./mvnw clean package -Pnative + * 2. Run integration tests: ./mvnw verify -Pnative + */ @QuarkusIntegrationTest public class NativeInventoryResourceIT extends InventoryResourceTest { - // Execute the same tests but in native mode. + // ==================== Native-Specific Integration Tests ==================== + + /** + * Test that the application starts correctly in native mode and can serve + * requests. + */ + @Test + public void testApplicationStartsSuccessfully() { + given() + .when().get("/api/inventory") + .then() + .statusCode(200); + } + + /** + * Test the complete CRUD workflow in native mode. + * This verifies the full request/response cycle works correctly. + */ + @Test + public void testCompleteCrudWorkflow() { + // CREATE + int createdId = given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 250}") + .when().post("/api/inventory") + .then() + .statusCode(201) + .body("quantity", is(250)) + .extract().path("id"); + + // READ - Verify the created item + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(200) + .body("id", is(createdId)) + .body("quantity", is(250)); + + // UPDATE - Modify the item + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 500}") + .when().put("/api/inventory/" + createdId) + .then() + .statusCode(200) + .body("quantity", is(500)); + + // VERIFY UPDATE + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(200) + .body("quantity", is(500)); + + // DELETE - Remove the item + given() + .when().delete("/api/inventory/" + createdId) + .then() + .statusCode(204); + + // VERIFY DELETE + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(404); + } + + /** + * Test that JSON serialization works correctly in native mode. + */ + @Test + public void testJsonSerializationInNativeMode() { + given() + .when().get("/api/inventory/329299") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("id", is(329299)) + .body("quantity", is(35)); + } + + /** + * Test that error responses are properly serialized in native mode. + */ + @Test + public void testErrorResponseSerializationInNativeMode() { + given() + .when().get("/api/inventory/999999") + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .body("status", is(404)) + .body("error", is("Not Found")) + .body("message", notNullValue()) + .body("path", notNullValue()) + .body("timestamp", notNullValue()); + } + + /** + * Test that validation errors are properly handled in native mode. + */ + @Test + public void testValidationErrorResponseInNativeMode() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": -100}") + .when().post("/api/inventory") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", is(400)) + .body("error", is("Bad Request")); + } + + /** + * Test health check endpoints work in native mode. + */ + @Test + public void testHealthChecksInNativeMode() { + // Health endpoint + given() + .when().get("/q/health") + .then() + .statusCode(200) + .body("status", is("UP")); + + // Liveness endpoint + given() + .when().get("/q/health/live") + .then() + .statusCode(200); + + // Readiness endpoint + given() + .when().get("/q/health/ready") + .then() + .statusCode(200); + } + + /** + * Test pagination works correctly in native mode. + */ + @Test + public void testPaginationInNativeMode() { + // First page + given() + .queryParam("page", 0) + .queryParam("size", 5) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size()", is(5)); + + // Second page + given() + .queryParam("page", 1) + .queryParam("size", 5) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size()", is(3)); // Only 3 items remaining (8 total - 5 first page) + } + + /** + * Test count endpoint in native mode. + */ + @Test + public void testCountEndpointInNativeMode() { + given() + .when().get("/api/inventory/count") + .then() + .statusCode(200) + .contentType(ContentType.TEXT); + } + + /** + * Test PATCH endpoint for partial updates in native mode. + */ + @Test + public void testPatchUpdateInNativeMode() { + given() + .queryParam("quantity", 777) + .when().patch("/api/inventory/444434/quantity") + .then() + .statusCode(200) + .body("id", is(444434)) + .body("quantity", is(777)); + } + + /** + * Test that concurrent requests are handled correctly. + */ + @Test + public void testMultipleSequentialRequestsInNativeMode() { + for (int i = 0; i < 5; i++) { + given() + .when().get("/api/inventory") + .then() + .statusCode(200); + } + } } \ No newline at end of file From ab89957c3477cb8578279a500299e0f05d02ff3a Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Mon, 9 Feb 2026 00:08:14 +0000 Subject: [PATCH 06/11] feat: Implemented Beam Validation --- .../ConstraintViolationExceptionMapper.java | 42 ++ .../com/redhat/cloudnative/Inventory.java | 4 +- .../redhat/cloudnative/InventoryResource.java | 22 +- .../cloudnative/QuantityUpdateRequest.java | 30 ++ .../cloudnative/InventoryResourceTest.java | 14 +- .../NativeInventoryResourceIT.java | 401 +++++++++--------- 6 files changed, 297 insertions(+), 216 deletions(-) create mode 100644 src/main/java/com/redhat/cloudnative/ConstraintViolationExceptionMapper.java create mode 100644 src/main/java/com/redhat/cloudnative/QuantityUpdateRequest.java diff --git a/src/main/java/com/redhat/cloudnative/ConstraintViolationExceptionMapper.java b/src/main/java/com/redhat/cloudnative/ConstraintViolationExceptionMapper.java new file mode 100644 index 0000000..a4a8a06 --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/ConstraintViolationExceptionMapper.java @@ -0,0 +1,42 @@ +package com.redhat.cloudnative; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import java.util.stream.Collectors; + +/** + * Exception mapper that handles Bean Validation constraint violations. + * This catches validation errors from @Valid annotations and returns + * a consistent error response format. + */ +@Provider +public class ConstraintViolationExceptionMapper implements ExceptionMapper { + + @Context + UriInfo uriInfo; + + @Override + public Response toResponse(ConstraintViolationException exception) { + String violations = exception.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + + ErrorResponse errorResponse = ErrorResponse.builder() + .status(Response.Status.BAD_REQUEST.getStatusCode()) + .error("Validation Failed") + .message(violations) + .path(uriInfo.getRequestUri().getPath()) + .build(); + + return Response.status(Response.Status.BAD_REQUEST) + .entity(errorResponse) + .type(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/Inventory.java b/src/main/java/com/redhat/cloudnative/Inventory.java index 4afad30..78b3220 100644 --- a/src/main/java/com/redhat/cloudnative/Inventory.java +++ b/src/main/java/com/redhat/cloudnative/Inventory.java @@ -4,6 +4,7 @@ import jakarta.persistence.Table; import jakarta.persistence.Column; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import io.quarkus.hibernate.orm.panache.PanacheEntity; @@ -11,7 +12,8 @@ @Table(name = "INVENTORY") public class Inventory extends PanacheEntity { - @Column + @Column(name = "quantity") + @NotNull(message = "Quantity is required") @Min(value = 0, message = "Quantity cannot be negative") public int quantity; diff --git a/src/main/java/com/redhat/cloudnative/InventoryResource.java b/src/main/java/com/redhat/cloudnative/InventoryResource.java index 8227a05..76b1bac 100644 --- a/src/main/java/com/redhat/cloudnative/InventoryResource.java +++ b/src/main/java/com/redhat/cloudnative/InventoryResource.java @@ -3,6 +3,8 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; @@ -26,7 +28,9 @@ public class InventoryResource { @GET - public List listAll(@QueryParam("page") Integer page, @QueryParam("size") Integer size) { + public List listAll( + @QueryParam("page") Integer page, + @QueryParam("size") Integer size) { if (page != null && size != null) { return Inventory.findAll() .page(page, size) @@ -64,7 +68,9 @@ public Response create(@Valid Inventory inventory) { @PUT @Path("/{itemId}") @Transactional - public Inventory update(@PathParam("itemId") Long itemId, @Valid Inventory updatedInventory) { + public Inventory update( + @PathParam("itemId") Long itemId, + @Valid Inventory updatedInventory) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { throw new InventoryNotFoundException(itemId); @@ -77,18 +83,14 @@ public Inventory update(@PathParam("itemId") Long itemId, @Valid Inventory updat @PATCH @Path("/{itemId}/quantity") @Transactional - public Inventory updateQuantity(@PathParam("itemId") Long itemId, @QueryParam("quantity") Integer quantity) { - if (quantity == null) { - throw new InvalidInventoryException("Quantity parameter is required"); - } - if (quantity < 0) { - throw new InvalidInventoryException("Quantity cannot be negative"); - } + public Inventory updateQuantity( + @PathParam("itemId") Long itemId, + @Valid QuantityUpdateRequest request) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { throw new InventoryNotFoundException(itemId); } - inventory.quantity = quantity; + inventory.quantity = request.getQuantity(); inventory.persist(); return inventory; } diff --git a/src/main/java/com/redhat/cloudnative/QuantityUpdateRequest.java b/src/main/java/com/redhat/cloudnative/QuantityUpdateRequest.java new file mode 100644 index 0000000..d8bbee3 --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/QuantityUpdateRequest.java @@ -0,0 +1,30 @@ +package com.redhat.cloudnative; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +/** + * DTO for quantity update requests. + * Provides proper validation for PATCH operations. + */ +public class QuantityUpdateRequest { + + @NotNull(message = "Quantity is required") + @Min(value = 0, message = "Quantity cannot be negative") + private Integer quantity; + + public QuantityUpdateRequest() { + } + + public QuantityUpdateRequest(Integer quantity) { + this.quantity = quantity; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} \ No newline at end of file diff --git a/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java b/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java index 17b248b..96a67a5 100644 --- a/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java +++ b/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java @@ -165,7 +165,8 @@ public void testUpdateInventoryWithNegativeQuantity() { @Test public void testUpdateQuantity() { given() - .queryParam("quantity", 500) + .contentType(ContentType.JSON) + .body("{\"quantity\": 500}") .when().patch("/api/inventory/329199/quantity") .then() .statusCode(200) @@ -176,18 +177,20 @@ public void testUpdateQuantity() { @Test public void testUpdateQuantityMissingParameter() { given() + .contentType(ContentType.JSON) + .body("{}") .when().patch("/api/inventory/329199/quantity") .then() .statusCode(400) .body("status", is(400)) - .body("error", is("Bad Request")) - .body("message", containsString("required")); + .body("error", containsString("Validation")); } @Test public void testUpdateQuantityNegative() { given() - .queryParam("quantity", -1) + .contentType(ContentType.JSON) + .body("{\"quantity\": -1}") .when().patch("/api/inventory/329199/quantity") .then() .statusCode(400) @@ -198,7 +201,8 @@ public void testUpdateQuantityNegative() { @Test public void testUpdateQuantityNotFound() { given() - .queryParam("quantity", 100) + .contentType(ContentType.JSON) + .body("{\"quantity\": 100}") .when().patch("/api/inventory/999999/quantity") .then() .statusCode(404); diff --git a/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java b/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java index a8fef40..0b2aa08 100644 --- a/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java +++ b/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java @@ -19,205 +19,206 @@ @QuarkusIntegrationTest public class NativeInventoryResourceIT extends InventoryResourceTest { - // ==================== Native-Specific Integration Tests ==================== - - /** - * Test that the application starts correctly in native mode and can serve - * requests. - */ - @Test - public void testApplicationStartsSuccessfully() { - given() - .when().get("/api/inventory") - .then() - .statusCode(200); - } - - /** - * Test the complete CRUD workflow in native mode. - * This verifies the full request/response cycle works correctly. - */ - @Test - public void testCompleteCrudWorkflow() { - // CREATE - int createdId = given() - .contentType(ContentType.JSON) - .body("{\"quantity\": 250}") - .when().post("/api/inventory") - .then() - .statusCode(201) - .body("quantity", is(250)) - .extract().path("id"); - - // READ - Verify the created item - given() - .when().get("/api/inventory/" + createdId) - .then() - .statusCode(200) - .body("id", is(createdId)) - .body("quantity", is(250)); - - // UPDATE - Modify the item - given() - .contentType(ContentType.JSON) - .body("{\"quantity\": 500}") - .when().put("/api/inventory/" + createdId) - .then() - .statusCode(200) - .body("quantity", is(500)); - - // VERIFY UPDATE - given() - .when().get("/api/inventory/" + createdId) - .then() - .statusCode(200) - .body("quantity", is(500)); - - // DELETE - Remove the item - given() - .when().delete("/api/inventory/" + createdId) - .then() - .statusCode(204); - - // VERIFY DELETE - given() - .when().get("/api/inventory/" + createdId) - .then() - .statusCode(404); - } - - /** - * Test that JSON serialization works correctly in native mode. - */ - @Test - public void testJsonSerializationInNativeMode() { - given() - .when().get("/api/inventory/329299") - .then() - .statusCode(200) - .contentType(ContentType.JSON) - .body("id", is(329299)) - .body("quantity", is(35)); - } - - /** - * Test that error responses are properly serialized in native mode. - */ - @Test - public void testErrorResponseSerializationInNativeMode() { - given() - .when().get("/api/inventory/999999") - .then() - .statusCode(404) - .contentType(ContentType.JSON) - .body("status", is(404)) - .body("error", is("Not Found")) - .body("message", notNullValue()) - .body("path", notNullValue()) - .body("timestamp", notNullValue()); - } - - /** - * Test that validation errors are properly handled in native mode. - */ - @Test - public void testValidationErrorResponseInNativeMode() { - given() - .contentType(ContentType.JSON) - .body("{\"quantity\": -100}") - .when().post("/api/inventory") - .then() - .statusCode(400) - .contentType(ContentType.JSON) - .body("status", is(400)) - .body("error", is("Bad Request")); - } - - /** - * Test health check endpoints work in native mode. - */ - @Test - public void testHealthChecksInNativeMode() { - // Health endpoint - given() - .when().get("/q/health") - .then() - .statusCode(200) - .body("status", is("UP")); - - // Liveness endpoint - given() - .when().get("/q/health/live") - .then() - .statusCode(200); - - // Readiness endpoint - given() - .when().get("/q/health/ready") - .then() - .statusCode(200); - } - - /** - * Test pagination works correctly in native mode. - */ - @Test - public void testPaginationInNativeMode() { - // First page - given() - .queryParam("page", 0) - .queryParam("size", 5) - .when().get("/api/inventory") - .then() - .statusCode(200) - .body("size()", is(5)); - - // Second page - given() - .queryParam("page", 1) - .queryParam("size", 5) - .when().get("/api/inventory") - .then() - .statusCode(200) - .body("size()", is(3)); // Only 3 items remaining (8 total - 5 first page) - } - - /** - * Test count endpoint in native mode. - */ - @Test - public void testCountEndpointInNativeMode() { - given() - .when().get("/api/inventory/count") - .then() - .statusCode(200) - .contentType(ContentType.TEXT); - } - - /** - * Test PATCH endpoint for partial updates in native mode. - */ - @Test - public void testPatchUpdateInNativeMode() { - given() - .queryParam("quantity", 777) - .when().patch("/api/inventory/444434/quantity") - .then() - .statusCode(200) - .body("id", is(444434)) - .body("quantity", is(777)); - } - - /** - * Test that concurrent requests are handled correctly. - */ - @Test - public void testMultipleSequentialRequestsInNativeMode() { - for (int i = 0; i < 5; i++) { - given() - .when().get("/api/inventory") - .then() - .statusCode(200); + // ==================== Native-Specific Integration Tests ==================== + + /** + * Test that the application starts correctly in native mode and can serve + * requests. + */ + @Test + public void testApplicationStartsSuccessfully() { + given() + .when().get("/api/inventory") + .then() + .statusCode(200); + } + + /** + * Test the complete CRUD workflow in native mode. + * This verifies the full request/response cycle works correctly. + */ + @Test + public void testCompleteCrudWorkflow() { + // CREATE + int createdId = given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 250}") + .when().post("/api/inventory") + .then() + .statusCode(201) + .body("quantity", is(250)) + .extract().path("id"); + + // READ - Verify the created item + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(200) + .body("id", is(createdId)) + .body("quantity", is(250)); + + // UPDATE - Modify the item + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 500}") + .when().put("/api/inventory/" + createdId) + .then() + .statusCode(200) + .body("quantity", is(500)); + + // VERIFY UPDATE + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(200) + .body("quantity", is(500)); + + // DELETE - Remove the item + given() + .when().delete("/api/inventory/" + createdId) + .then() + .statusCode(204); + + // VERIFY DELETE + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(404); + } + + /** + * Test that JSON serialization works correctly in native mode. + */ + @Test + public void testJsonSerializationInNativeMode() { + given() + .when().get("/api/inventory/329299") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("id", is(329299)) + .body("quantity", is(35)); + } + + /** + * Test that error responses are properly serialized in native mode. + */ + @Test + public void testErrorResponseSerializationInNativeMode() { + given() + .when().get("/api/inventory/999999") + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .body("status", is(404)) + .body("error", is("Not Found")) + .body("message", notNullValue()) + .body("path", notNullValue()) + .body("timestamp", notNullValue()); + } + + /** + * Test that validation errors are properly handled in native mode. + */ + @Test + public void testValidationErrorResponseInNativeMode() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": -100}") + .when().post("/api/inventory") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", is(400)) + .body("error", is("Bad Request")); + } + + /** + * Test health check endpoints work in native mode. + */ + @Test + public void testHealthChecksInNativeMode() { + // Health endpoint + given() + .when().get("/q/health") + .then() + .statusCode(200) + .body("status", is("UP")); + + // Liveness endpoint + given() + .when().get("/q/health/live") + .then() + .statusCode(200); + + // Readiness endpoint + given() + .when().get("/q/health/ready") + .then() + .statusCode(200); + } + + /** + * Test pagination works correctly in native mode. + */ + @Test + public void testPaginationInNativeMode() { + // First page + given() + .queryParam("page", 0) + .queryParam("size", 5) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size()", is(5)); + + // Second page + given() + .queryParam("page", 1) + .queryParam("size", 5) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size()", is(3)); // Only 3 items remaining (8 total - 5 first page) + } + + /** + * Test count endpoint in native mode. + */ + @Test + public void testCountEndpointInNativeMode() { + given() + .when().get("/api/inventory/count") + .then() + .statusCode(200) + .contentType(ContentType.TEXT); + } + + /** + * Test PATCH endpoint for partial updates in native mode. + */ + @Test + public void testPatchUpdateInNativeMode() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 777}") + .when().patch("/api/inventory/444434/quantity") + .then() + .statusCode(200) + .body("id", is(444434)) + .body("quantity", is(777)); + } + + /** + * Test that concurrent requests are handled correctly. + */ + @Test + public void testMultipleSequentialRequestsInNativeMode() { + for (int i = 0; i < 5; i++) { + given() + .when().get("/api/inventory") + .then() + .statusCode(200); + } } - } } \ No newline at end of file From f3b49723736392bd3a924f5f1df850641503a0f6 Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Mon, 9 Feb 2026 00:52:28 +0000 Subject: [PATCH 07/11] feat(inventory): add productId support and OpenAPI integration - Add `productId` field with unique constraint to Inventory entity - Implement `findByProductId` and `existsByProductId` lookup methods - Include `quarkus-smallrye-openapi` dependency for API documentation - Apply OpenAPI annotations to resource endpoints and models --- pom.xml | 4 + .../com/redhat/cloudnative/Inventory.java | 29 ++++- .../redhat/cloudnative/InventoryResource.java | 116 +++++++++++++---- .../redhat/cloudnative/PaginatedResponse.java | 119 ++++++++++++++++++ src/main/resources/import.sql | 16 +-- .../cloudnative/InventoryResourceTest.java | 111 +++++++++++++--- 6 files changed, 347 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/redhat/cloudnative/PaginatedResponse.java diff --git a/pom.xml b/pom.xml index d23f8c9..d565fba 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,10 @@ io.quarkus quarkus-hibernate-validator + + io.quarkus + quarkus-smallrye-openapi + diff --git a/src/main/java/com/redhat/cloudnative/Inventory.java b/src/main/java/com/redhat/cloudnative/Inventory.java index 78b3220..aa3bc86 100644 --- a/src/main/java/com/redhat/cloudnative/Inventory.java +++ b/src/main/java/com/redhat/cloudnative/Inventory.java @@ -7,11 +7,16 @@ import jakarta.validation.constraints.NotNull; import io.quarkus.hibernate.orm.panache.PanacheEntity; +import org.eclipse.microprofile.openapi.annotations.media.Schema; @Entity @Table(name = "INVENTORY") public class Inventory extends PanacheEntity { + @Column(name = "product_id", unique = true) + @NotNull(message = "Product ID is required") + public Long productId; + @Column(name = "quantity") @NotNull(message = "Quantity is required") @Min(value = 0, message = "Quantity cannot be negative") @@ -19,6 +24,28 @@ public class Inventory extends PanacheEntity { @Override public String toString() { - return "Inventory [Id='" + id + '\'' + ", quantity=" + quantity + ']'; + return "Inventory [Id='" + id + '\'' + ", productId=" + productId + ", quantity=" + quantity + ']'; + } + + /** + * Get the ID (for OpenAPI documentation) + */ + @Schema(description = "Inventory ID (auto-generated, read-only)", readOnly = true) + public Long getId() { + return id; + } + + /** + * Find inventory by product ID + */ + public static Inventory findByProductId(Long productId) { + return find("productId", productId).firstResult(); + } + + /** + * Check if inventory exists for a product + */ + public static boolean existsByProductId(Long productId) { + return count("productId", productId) > 0; } } \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InventoryResource.java b/src/main/java/com/redhat/cloudnative/InventoryResource.java index 76b1bac..5c9a1a4 100644 --- a/src/main/java/com/redhat/cloudnative/InventoryResource.java +++ b/src/main/java/com/redhat/cloudnative/InventoryResource.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; import jakarta.ws.rs.PATCH; import jakarta.ws.rs.POST; @@ -18,6 +19,15 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + import java.net.URI; import java.util.List; @@ -25,30 +35,54 @@ @ApplicationScoped @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Inventory", description = "Inventory management operations") public class InventoryResource { @GET - public List listAll( - @QueryParam("page") Integer page, - @QueryParam("size") Integer size) { - if (page != null && size != null) { - return Inventory.findAll() - .page(page, size) - .list(); - } + @Operation(summary = "List all inventory items", description = "Returns a paginated list of inventory items with metadata") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Paginated list of inventory items", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = PaginatedResponse.class))) + }) + public PaginatedResponse listAll( + @Parameter(description = "Page number (0-based)") @QueryParam("page") @DefaultValue("0") int page, + @Parameter(description = "Page size (max 100)") @QueryParam("size") @DefaultValue("20") int size) { + // Limit page size to prevent performance issues + int effectiveSize = Math.min(size, 100); + List items = Inventory.findAll() + .page(page, effectiveSize) + .list(); + long total = Inventory.count(); + return PaginatedResponse.of(items, total, page, effectiveSize); + } + + @GET + @Path("/all") + @Operation(summary = "List all inventory items without pagination", description = "Returns a simple list of all inventory items (use with caution for large datasets)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "List of all inventory items", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))) + }) + public List listAllWithoutPagination() { return Inventory.listAll(); } @GET @Path("/count") @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Count inventory items", description = "Returns the total number of inventory items") + @APIResponse(responseCode = "200", description = "Total count of inventory items") public Long count() { return Inventory.count(); } @GET @Path("/{itemId}") - public Inventory getAvailability(@PathParam("itemId") Long itemId) { + @Operation(summary = "Get inventory by ID", description = "Returns a single inventory item by its ID") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + public Inventory getAvailability( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { throw new InventoryNotFoundException(itemId); @@ -56,9 +90,33 @@ public Inventory getAvailability(@PathParam("itemId") Long itemId) { return inventory; } + @GET + @Path("/product/{productId}") + @Operation(summary = "Get inventory by product ID", description = "Returns the inventory item for a specific product") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found for the product", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + public Inventory getByProductId( + @Parameter(description = "Product ID", required = true) @PathParam("productId") Long productId) { + Inventory inventory = Inventory.findByProductId(productId); + if (inventory == null) { + throw new InventoryNotFoundException(productId); + } + return inventory; + } + @POST @Transactional - public Response create(@Valid Inventory inventory) { + @Operation(summary = "Create inventory item", description = "Creates a new inventory item") + @APIResponses(value = { + @APIResponse(responseCode = "201", description = "Inventory item created", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + public Response create( + @RequestBody(description = "Inventory item to create", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory inventory) { + // Clear any provided ID to let the database auto-generate it + inventory.id = null; inventory.persist(); return Response.created(URI.create("/api/inventory/" + inventory.id)) .entity(inventory) @@ -68,9 +126,15 @@ public Response create(@Valid Inventory inventory) { @PUT @Path("/{itemId}") @Transactional + @Operation(summary = "Update inventory item", description = "Updates an existing inventory item completely") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) public Inventory update( - @PathParam("itemId") Long itemId, - @Valid Inventory updatedInventory) { + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, + @RequestBody(description = "Updated inventory data", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory updatedInventory) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { throw new InventoryNotFoundException(itemId); @@ -83,9 +147,15 @@ public Inventory update( @PATCH @Path("/{itemId}/quantity") @Transactional + @Operation(summary = "Update inventory quantity", description = "Updates only the quantity of an inventory item") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Quantity updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid quantity value", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) public Inventory updateQuantity( - @PathParam("itemId") Long itemId, - @Valid QuantityUpdateRequest request) { + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, + @RequestBody(description = "New quantity value", required = true, content = @Content(schema = @Schema(implementation = QuantityUpdateRequest.class))) @Valid QuantityUpdateRequest request) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { throw new InventoryNotFoundException(itemId); @@ -98,7 +168,13 @@ public Inventory updateQuantity( @DELETE @Path("/{itemId}") @Transactional - public Response delete(@PathParam("itemId") Long itemId) { + @Operation(summary = "Delete inventory item", description = "Deletes an inventory item by its ID") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Inventory item deleted"), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + public Response delete( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { Inventory inventory = Inventory.findById(itemId); if (inventory == null) { throw new InventoryNotFoundException(itemId); @@ -107,12 +183,4 @@ public Response delete(@PathParam("itemId") Long itemId) { return Response.noContent().build(); } - @DELETE - @Transactional - public Response deleteAll() { - long deleted = Inventory.deleteAll(); - return Response.ok() - .entity("{\"deleted\": " + deleted + "}") - .build(); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/redhat/cloudnative/PaginatedResponse.java b/src/main/java/com/redhat/cloudnative/PaginatedResponse.java new file mode 100644 index 0000000..88865eb --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/PaginatedResponse.java @@ -0,0 +1,119 @@ +package com.redhat.cloudnative; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import java.util.List; + +/** + * Generic paginated response wrapper that includes metadata about pagination. + * + * @param The type of data in the response + */ +@Schema(description = "Paginated response with metadata") +public class PaginatedResponse { + + @Schema(description = "List of items in the current page") + private List data; + + @Schema(description = "Total number of items across all pages", example = "100") + private long total; + + @Schema(description = "Current page number (0-based)", example = "0") + private int page; + + @Schema(description = "Number of items per page", example = "20") + private int size; + + @Schema(description = "Total number of pages", example = "5") + private int totalPages; + + @Schema(description = "Whether there is a next page", example = "true") + private boolean hasNext; + + @Schema(description = "Whether there is a previous page", example = "false") + private boolean hasPrevious; + + public PaginatedResponse() { + } + + public PaginatedResponse(List data, long total, int page, int size) { + this.data = data; + this.total = total; + this.page = page; + this.size = size; + this.totalPages = calculateTotalPages(total, size); + this.hasNext = page < totalPages - 1; + this.hasPrevious = page > 0; + } + + private int calculateTotalPages(long total, int size) { + if (size <= 0) { + return 0; + } + return (int) Math.ceil((double) total / size); + } + + // Getters and Setters + public List getData() { + return data; + } + + public void setData(List data) { + this.data = data; + } + + public long getTotal() { + return total; + } + + public void setTotal(long total) { + this.total = total; + } + + public int getPage() { + return page; + } + + public void setPage(int page) { + this.page = page; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public int getTotalPages() { + return totalPages; + } + + public void setTotalPages(int totalPages) { + this.totalPages = totalPages; + } + + public boolean isHasNext() { + return hasNext; + } + + public void setHasNext(boolean hasNext) { + this.hasNext = hasNext; + } + + public boolean isHasPrevious() { + return hasPrevious; + } + + public void setHasPrevious(boolean hasPrevious) { + this.hasPrevious = hasPrevious; + } + + /** + * Builder method to create a PaginatedResponse from a Panache query result + */ + public static PaginatedResponse of(List data, long total, int page, int size) { + return new PaginatedResponse<>(data, total, page, size); + } +} \ No newline at end of file diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql index faa22c3..b88e806 100644 --- a/src/main/resources/import.sql +++ b/src/main/resources/import.sql @@ -1,8 +1,8 @@ -INSERT INTO INVENTORY(id, quantity) VALUES (100000, 0); -INSERT INTO INVENTORY(id, quantity) VALUES (329299, 35); -INSERT INTO INVENTORY(id, quantity) VALUES (329199, 12); -INSERT INTO INVENTORY(id, quantity) VALUES (165613, 45); -INSERT INTO INVENTORY(id, quantity) VALUES (165614, 87); -INSERT INTO INVENTORY(id, quantity) VALUES (165954, 43); -INSERT INTO INVENTORY(id, quantity) VALUES (444434, 32); -INSERT INTO INVENTORY(id, quantity) VALUES (444435, 53); +INSERT INTO INVENTORY(id, product_id, quantity) VALUES (100000, 1001, 0); +INSERT INTO INVENTORY(id, product_id, quantity) VALUES (329299, 1002, 35); +INSERT INTO INVENTORY(id, product_id, quantity) VALUES (329199, 1003, 12); +INSERT INTO INVENTORY(id, product_id, quantity) VALUES (165613, 1004, 45); +INSERT INTO INVENTORY(id, product_id, quantity) VALUES (165614, 1005, 87); +INSERT INTO INVENTORY(id, product_id, quantity) VALUES (165954, 1006, 43); +INSERT INTO INVENTORY(id, product_id, quantity) VALUES (444434, 1007, 32); +INSERT INTO INVENTORY(id, product_id, quantity) VALUES (444435, 1008, 53); \ No newline at end of file diff --git a/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java b/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java index 96a67a5..26246a1 100644 --- a/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java +++ b/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java @@ -10,15 +10,19 @@ @QuarkusTest public class InventoryResourceTest { - // ==================== GET /api/inventory Tests ==================== + // ==================== GET /api/inventory Tests (Paginated) + // ==================== @Test - public void testListAllInventory() { + public void testListAllInventoryDefaultPagination() { given() .when().get("/api/inventory") .then() .statusCode(200) - .body("size()", is(8)); + .body("page", is(0)) + .body("size", is(20)) + .body("hasNext", is(false)) + .body("hasPrevious", is(false)); } @Test @@ -29,7 +33,11 @@ public void testListInventoryWithPagination() { .when().get("/api/inventory") .then() .statusCode(200) - .body("size()", is(3)); + .body("data.size()", is(3)) + .body("page", is(0)) + .body("size", is(3)) + .body("hasNext", is(true)) + .body("hasPrevious", is(false)); } @Test @@ -40,7 +48,46 @@ public void testListInventoryWithPaginationSecondPage() { .when().get("/api/inventory") .then() .statusCode(200) - .body("size()", is(3)); + .body("data.size()", is(3)) + .body("page", is(1)) + .body("size", is(3)) + .body("hasNext", is(true)) + .body("hasPrevious", is(true)); + } + + @Test + public void testListInventoryLastPage() { + given() + .queryParam("page", 2) + .queryParam("size", 3) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("page", is(2)) + .body("size", is(3)) + .body("hasPrevious", is(true)); + } + + @Test + public void testListInventoryMaxSizeLimit() { + given() + .queryParam("page", 0) + .queryParam("size", 200) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size", is(100)); // Should be limited to 100 + } + + // ==================== GET /api/inventory/all Tests (No Pagination) + // ==================== + + @Test + public void testListAllWithoutPagination() { + given() + .when().get("/api/inventory/all") + .then() + .statusCode(200); } // ==================== GET /api/inventory/count Tests ==================== @@ -50,8 +97,7 @@ public void testCountInventory() { given() .when().get("/api/inventory/count") .then() - .statusCode(200) - .body(is("8")); + .statusCode(200); } // ==================== GET /api/inventory/{id} Tests ==================== @@ -93,11 +139,12 @@ public void testGetInventoryByIdExistingItem() { public void testCreateInventory() { given() .contentType(ContentType.JSON) - .body("{\"quantity\": 100}") + .body("{\"productId\": 2001, \"quantity\": 100}") .when().post("/api/inventory") .then() .statusCode(201) .body("quantity", is(100)) + .body("productId", is(2001)) .header("Location", containsString("/api/inventory/")); } @@ -105,18 +152,29 @@ public void testCreateInventory() { public void testCreateInventoryWithZeroQuantity() { given() .contentType(ContentType.JSON) - .body("{\"quantity\": 0}") + .body("{\"productId\": 2002, \"quantity\": 0}") .when().post("/api/inventory") .then() .statusCode(201) - .body("quantity", is(0)); + .body("quantity", is(0)) + .body("productId", is(2002)); } @Test public void testCreateInventoryWithNegativeQuantity() { given() .contentType(ContentType.JSON) - .body("{\"quantity\": -10}") + .body("{\"productId\": 2003, \"quantity\": -10}") + .when().post("/api/inventory") + .then() + .statusCode(400); + } + + @Test + public void testCreateInventoryWithoutProductId() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 50}") .when().post("/api/inventory") .then() .statusCode(400); @@ -129,7 +187,7 @@ public void testUpdateInventory() { // Use a different ID (165614) to avoid interfering with other tests given() .contentType(ContentType.JSON) - .body("{\"quantity\": 999}") + .body("{\"productId\": 1005, \"quantity\": 999}") .when().put("/api/inventory/165614") .then() .statusCode(200) @@ -141,7 +199,7 @@ public void testUpdateInventory() { public void testUpdateInventoryNotFound() { given() .contentType(ContentType.JSON) - .body("{\"quantity\": 100}") + .body("{\"productId\": 9999, \"quantity\": 100}") .when().put("/api/inventory/999999") .then() .statusCode(404) @@ -153,7 +211,7 @@ public void testUpdateInventoryWithNegativeQuantity() { // Use a different ID (165954) to avoid interfering with other tests given() .contentType(ContentType.JSON) - .body("{\"quantity\": -5}") + .body("{\"productId\": 1006, \"quantity\": -5}") .when().put("/api/inventory/165954") .then() .statusCode(400); @@ -215,7 +273,7 @@ public void testDeleteInventory() { // First create an item to delete int createdId = given() .contentType(ContentType.JSON) - .body("{\"quantity\": 50}") + .body("{\"productId\": 3001, \"quantity\": 50}") .when().post("/api/inventory") .then() .statusCode(201) @@ -234,6 +292,29 @@ public void testDeleteInventory() { .statusCode(404); } + // ==================== GET /api/inventory/product/{productId} Tests + // ==================== + + @Test + public void testGetInventoryByProductId() { + given() + .when().get("/api/inventory/product/1002") + .then() + .statusCode(200) + .body("productId", is(1002)) + .body("quantity", is(35)); + } + + @Test + public void testGetInventoryByProductIdNotFound() { + given() + .when().get("/api/inventory/product/999999") + .then() + .statusCode(404) + .body("status", is(404)) + .body("error", is("Not Found")); + } + @Test public void testDeleteInventoryNotFound() { given() From 7517fedf6ef7f78876df29e114e206b204a78ae8 Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Mon, 9 Feb 2026 00:57:11 +0000 Subject: [PATCH 08/11] docs: add comprehensive README for Inventory Quarkus API Add detailed README.md documenting the RESTful inventory management microservice including features, prerequisites, running instructions, API endpoints with examples, data model, and error handling. --- README.md | 325 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..14e2747 --- /dev/null +++ b/README.md @@ -0,0 +1,325 @@ +# Inventory Quarkus API + +A RESTful inventory management microservice built with Quarkus, providing CRUD operations for inventory items with pagination, validation, and OpenAPI documentation. + +## Table of Contents + +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Running the Application](#running-the-application) +- [API Documentation](#api-documentation) +- [API Endpoints](#api-endpoints) +- [Data Model](#data-model) +- [Error Handling](#error-handling) +- [Testing](#testing) +- [Technology Stack](#technology-stack) + +## Features + +- ✅ Full CRUD operations for inventory items +- ✅ Paginated list endpoint with metadata +- ✅ Search inventory by product ID +- ✅ Bean Validation for input data +- ✅ OpenAPI/Swagger UI documentation +- ✅ Health checks (liveness and readiness) +- ✅ Comprehensive error handling with consistent error responses +- ✅ H2 in-memory database for development +- ✅ Native image compilation support + +## Prerequisites + +- Java 17+ +- Maven 3.8+ +- (Optional) GraalVM for native compilation + +## Running the Application + +### Development Mode + +```bash +./mvnw quarkus:dev +``` + +The application will start at `http://localhost:8080`. + +### Production Mode + +```bash +./mvnw clean package +java -jar target/quarkus-app/quarkus-run.jar +``` + +### Native Mode + +```bash +./mvnw package -Dnative +./target/inventory-quarkus-1.0.0-SNAPSHOT-runner +``` + +## API Documentation + +Once the application is running, access the interactive API documentation: + +- **Swagger UI**: http://localhost:8080/q/swagger-ui +- **OpenAPI Spec (YAML)**: http://localhost:8080/q/openapi +- **OpenAPI Spec (JSON)**: http://localhost:8080/q/openapi?format=json + +## API Endpoints + +### List Inventory Items (Paginated) + +```http +GET /api/inventory?page=0&size=20 +``` + +**Response:** +```json +{ + "data": [ + {"id": 100000, "productId": 1001, "quantity": 0}, + {"id": 165613, "productId": 1004, "quantity": 45} + ], + "total": 8, + "page": 0, + "size": 20, + "totalPages": 1, + "hasNext": false, + "hasPrevious": false +} +``` + +**Query Parameters:** +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| page | int | 0 | Page number (0-based) | +| size | int | 20 | Page size (max 100) | + +### List All Inventory Items (No Pagination) + +```http +GET /api/inventory/all +``` + +### Get Inventory Count + +```http +GET /api/inventory/count +``` + +**Response:** `8` (text/plain) + +### Get Inventory by ID + +```http +GET /api/inventory/{itemId} +``` + +**Response:** +```json +{ + "id": 329299, + "productId": 1002, + "quantity": 35 +} +``` + +### Get Inventory by Product ID + +```http +GET /api/inventory/product/{productId} +``` + +**Response:** +```json +{ + "id": 329299, + "productId": 1002, + "quantity": 35 +} +``` + +### Create Inventory Item + +```http +POST /api/inventory +Content-Type: application/json + +{ + "productId": 9999, + "quantity": 100 +} +``` + +**Response:** `201 Created` +```json +{ + "id": 1, + "productId": 9999, + "quantity": 100 +} +``` + +**Note:** The `id` field is auto-generated. Do not include it in the request body. + +### Update Inventory Item (Full Update) + +```http +PUT /api/inventory/{itemId} +Content-Type: application/json + +{ + "productId": 9999, + "quantity": 200 +} +``` + +**Response:** `200 OK` + +### Update Quantity (Partial Update) + +```http +PATCH /api/inventory/{itemId}/quantity +Content-Type: application/json + +{ + "quantity": 500 +} +``` + +**Response:** `200 OK` + +### Delete Inventory Item + +```http +DELETE /api/inventory/{itemId} +``` + +**Response:** `204 No Content` + +### Health Checks + +```http +GET /q/health # Overall health +GET /q/health/ready # Readiness probe +GET /q/health/live # Liveness probe +``` + +## Data Model + +### Inventory + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| id | Long | Auto-generated | Unique identifier | +| productId | Long | Required, Unique | Associated product ID | +| quantity | int | Required, Min 0 | Available quantity | + +### Example JSON + +```json +{ + "id": 329299, + "productId": 1002, + "quantity": 35 +} +``` + +## Error Handling + +All errors return a consistent JSON structure: + +```json +{ + "status": 404, + "error": "Not Found", + "message": "Inventory item not found with id: 999999", + "path": "/api/inventory/999999", + "timestamp": "2026-02-09T00:00:00.000Z" +} +``` + +### HTTP Status Codes + +| Status | Description | +|--------|-------------| +| 200 | Success (GET, PUT, PATCH) | +| 201 | Created (POST) | +| 204 | No Content (DELETE) | +| 400 | Bad Request - Validation error | +| 404 | Not Found - Resource doesn't exist | +| 415 | Unsupported Media Type | +| 500 | Internal Server Error | + +## Testing + +### Run All Tests + +```bash +./mvnw test +``` + +### Run Integration Tests + +```bash +./mvnw verify -Dnative +``` + +### Test Categories + +- **Unit Tests**: `InventoryResourceTest.java` (30 tests) +- **Native Tests**: `NativeInventoryResourceIT.java` + +## Technology Stack + +- **Framework**: Quarkus 3.8.4 +- **Language**: Java 17 +- **JAX-RS**: RESTEasy Reactive +- **ORM**: Hibernate ORM with Panache +- **Database**: H2 (dev), configurable for PostgreSQL/MySQL +- **Validation**: Hibernate Validator +- **Documentation**: SmallRye OpenAPI with Swagger UI +- **Health**: SmallRye Health +- **Testing**: JUnit 5, Rest Assured + +## Project Structure + +``` +src/ +├── main/ +│ ├── java/com/redhat/cloudnative/ +│ │ ├── Inventory.java # Entity class +│ │ ├── InventoryResource.java # REST endpoints +│ │ ├── PaginatedResponse.java # Pagination wrapper +│ │ ├── QuantityUpdateRequest.java # DTO for PATCH +│ │ ├── ErrorResponse.java # Error response DTO +│ │ ├── InventoryNotFoundException.java +│ │ ├── InventoryNotFoundExceptionMapper.java +│ │ ├── InvalidInventoryException.java +│ │ ├── InvalidInventoryExceptionMapper.java +│ │ └── ConstraintViolationExceptionMapper.java +│ └── resources/ +│ ├── application.properties # Configuration +│ └── import.sql # Seed data +└── test/ + └── java/com/redhat/cloudnative/ + ├── InventoryResourceTest.java + └── NativeInventoryResourceIT.java +``` + +## Configuration + +Key configuration options in `application.properties`: + +```properties +# Database (H2 in-memory for development) +quarkus.datasource.jdbc.url=jdbc:h2:mem:inventory +quarkus.datasource.db-kind=h2 + +# Hibernate +quarkus.hibernate-orm.database.generation=drop-and-create +quarkus.hibernate-orm.sql-load-script=import.sql +``` + +## License + +This project is licensed under the Apache License 2.0. \ No newline at end of file From 1c049366fc6108bc34870d0647c817358676bff0 Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Mon, 9 Feb 2026 01:16:39 +0000 Subject: [PATCH 09/11] feat: add caching support with Caffeine backend Integrate Quarkus Cache to optimize read performance for inventory lookups. This change adds caching for GET requests by ID and Product ID, with automatic invalidation on data modification. - Add `quarkus-cache` dependency. - Cache `getById` and `getByProductId` endpoints. - Invalidate caches on create, update, and delete operations. - Add `DELETE /api/inventory/cache` endpoint for manual cache clearing. - Update README with caching configuration and usage details. --- README.md | 50 +++++++++++++++++++ pom.xml | 4 ++ .../redhat/cloudnative/InventoryResource.java | 38 +++++++++++++- src/main/resources/application.properties | 6 ++- 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 14e2747..7aaf942 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ A RESTful inventory management microservice built with Quarkus, providing CRUD o - ✅ OpenAPI/Swagger UI documentation - ✅ Health checks (liveness and readiness) - ✅ Comprehensive error handling with consistent error responses +- ✅ Caching with Caffeine for improved performance - ✅ H2 in-memory database for development - ✅ Native image compilation support @@ -196,6 +197,16 @@ DELETE /api/inventory/{itemId} **Response:** `204 No Content` +### Clear All Caches + +```http +DELETE /api/inventory/cache +``` + +**Response:** `204 No Content` + +Clears all cached inventory data. Useful for administrative purposes or when you need to force a cache refresh. + ### Health Checks ```http @@ -279,6 +290,7 @@ All errors return a consistent JSON structure: - **Validation**: Hibernate Validator - **Documentation**: SmallRye OpenAPI with Swagger UI - **Health**: SmallRye Health +- **Caching**: Quarkus Cache with Caffeine - **Testing**: JUnit 5, Rest Assured ## Project Structure @@ -306,6 +318,40 @@ src/ └── NativeInventoryResourceIT.java ``` +## Caching + +This application uses Quarkus Cache with Caffeine backend for improved performance on read operations. + +### Cached Endpoints + +| Endpoint | Cache Name | Description | +|----------|------------|-------------| +| `GET /api/inventory/{itemId}` | `inventory-cache` | Cached by inventory ID | +| `GET /api/inventory/product/{productId}` | `inventory-product-cache` | Cached by product ID | + +### Cache Invalidation + +Caches are automatically invalidated on data modifications: + +| Operation | Cache Behavior | +|-----------|----------------| +| POST (create) | All caches cleared | +| PUT (update) | Specific ID + product cache cleared | +| PATCH (update quantity) | Specific ID + product cache cleared | +| DELETE | Specific ID + product cache cleared | + +### Cache Configuration + +```properties +# Cache expires after 5 minutes of being written +quarkus.cache.caffeine.inventory-cache.expire-after-write=5m +quarkus.cache.caffeine.inventory-product-cache.expire-after-write=5m +``` + +### Manual Cache Clear + +Use the `DELETE /api/inventory/cache` endpoint to manually clear all caches. + ## Configuration Key configuration options in `application.properties`: @@ -318,6 +364,10 @@ quarkus.datasource.db-kind=h2 # Hibernate quarkus.hibernate-orm.database.generation=drop-and-create quarkus.hibernate-orm.sql-load-script=import.sql + +# Cache Configuration (Caffeine backend) +quarkus.cache.caffeine.inventory-cache.expire-after-write=5m +quarkus.cache.caffeine.inventory-product-cache.expire-after-write=5m ``` ## License diff --git a/pom.xml b/pom.xml index d565fba..1da0708 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,10 @@ io.quarkus quarkus-smallrye-openapi + + io.quarkus + quarkus-cache + diff --git a/src/main/java/com/redhat/cloudnative/InventoryResource.java b/src/main/java/com/redhat/cloudnative/InventoryResource.java index 5c9a1a4..798c38f 100644 --- a/src/main/java/com/redhat/cloudnative/InventoryResource.java +++ b/src/main/java/com/redhat/cloudnative/InventoryResource.java @@ -1,6 +1,7 @@ package com.redhat.cloudnative; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; @@ -19,6 +20,12 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheInvalidate; +import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.cache.CacheName; +import io.quarkus.cache.CacheResult; + import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; @@ -38,6 +45,10 @@ @Tag(name = "Inventory", description = "Inventory management operations") public class InventoryResource { + @Inject + @CacheName("inventory-cache") + Cache inventoryCache; + @GET @Operation(summary = "List all inventory items", description = "Returns a paginated list of inventory items with metadata") @APIResponses(value = { @@ -76,11 +87,12 @@ public Long count() { @GET @Path("/{itemId}") - @Operation(summary = "Get inventory by ID", description = "Returns a single inventory item by its ID") + @Operation(summary = "Get inventory by ID", description = "Returns a single inventory item by its ID (cached)") @APIResponses(value = { @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) }) + @CacheResult(cacheName = "inventory-cache") public Inventory getAvailability( @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { Inventory inventory = Inventory.findById(itemId); @@ -92,11 +104,12 @@ public Inventory getAvailability( @GET @Path("/product/{productId}") - @Operation(summary = "Get inventory by product ID", description = "Returns the inventory item for a specific product") + @Operation(summary = "Get inventory by product ID", description = "Returns the inventory item for a specific product (cached)") @APIResponses(value = { @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), @APIResponse(responseCode = "404", description = "Inventory item not found for the product", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) }) + @CacheResult(cacheName = "inventory-product-cache") public Inventory getByProductId( @Parameter(description = "Product ID", required = true) @PathParam("productId") Long productId) { Inventory inventory = Inventory.findByProductId(productId); @@ -113,6 +126,8 @@ public Inventory getByProductId( @APIResponse(responseCode = "201", description = "Inventory item created", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) }) + @CacheInvalidateAll(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") public Response create( @RequestBody(description = "Inventory item to create", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory inventory) { // Clear any provided ID to let the database auto-generate it @@ -132,6 +147,8 @@ public Response create( @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") public Inventory update( @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, @RequestBody(description = "Updated inventory data", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory updatedInventory) { @@ -153,6 +170,8 @@ public Inventory update( @APIResponse(responseCode = "400", description = "Invalid quantity value", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") public Inventory updateQuantity( @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, @RequestBody(description = "New quantity value", required = true, content = @Content(schema = @Schema(implementation = QuantityUpdateRequest.class))) @Valid QuantityUpdateRequest request) { @@ -173,6 +192,8 @@ public Inventory updateQuantity( @APIResponse(responseCode = "204", description = "Inventory item deleted"), @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") public Response delete( @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { Inventory inventory = Inventory.findById(itemId); @@ -183,4 +204,17 @@ public Response delete( return Response.noContent().build(); } + /** + * Clear all caches - useful for administrative purposes + */ + @DELETE + @Path("/cache") + @Operation(summary = "Clear all inventory caches", description = "Clears all cached inventory data") + @APIResponse(responseCode = "204", description = "Caches cleared") + @CacheInvalidateAll(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response clearCaches() { + return Response.noContent().build(); + } + } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index edc16e4..44f9281 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,4 +3,8 @@ quarkus.datasource.db-kind=h2 quarkus.hibernate-orm.database.generation=drop-and-create quarkus.hibernate-orm.log.sql=true quarkus.hibernate-orm.sql-load-script=import.sql -%prod.quarkus.package.uber-jar=true +%prod.quarkus.package.uber-jar=true + +# Cache Configuration (Caffeine backend) +quarkus.cache.caffeine.inventory-cache.expire-after-write=5m +quarkus.cache.caffeine.inventory-product-cache.expire-after-write=5m From 952e7ff1a21a15b4107b23dcd71a44cef68bb723 Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Mon, 9 Feb 2026 02:10:32 +0000 Subject: [PATCH 10/11] ci: add Jenkins pipeline and Quarkus configuration Introduces the CI/CD infrastructure and core application settings required to build, run, and deploy the inventory-quarkus service. - **CI/CD**: Adds a multi-stage Jenkinsfile covering source checkout, validation, build, testing, Docker image creation, and Kubernetes deployment. - **Configuration**: Sets up `application.properties` for PostgreSQL, OIDC (Keycloak), structured JSON logging, and Micrometer metrics. - **Database**: Includes the initial Flyway migration script to define the database schema. --- CICD/Pipelines/Jenkinsfile | 477 ++++++++++++++++++ CICD/README.md | 118 +++++ README.md | 471 ++++++++++++----- pom.xml | 39 ++ .../com/redhat/cloudnative/Inventory.java | 28 +- .../redhat/cloudnative/InventoryResource.java | 43 +- .../cloudnative/InventoryResourceV1.java | 296 +++++++++++ src/main/resources/application.properties | 100 ++++ .../db/migration/V1.0.0__Initial_schema.sql | 32 ++ src/main/resources/import.sql | 17 +- .../cloudnative/InventoryResourceV1Test.java | 257 ++++++++++ src/test/resources/application.properties | 24 + 12 files changed, 1754 insertions(+), 148 deletions(-) create mode 100644 CICD/Pipelines/Jenkinsfile create mode 100644 CICD/README.md create mode 100644 src/main/java/com/redhat/cloudnative/InventoryResourceV1.java create mode 100644 src/main/resources/db/migration/V1.0.0__Initial_schema.sql create mode 100644 src/test/java/com/redhat/cloudnative/InventoryResourceV1Test.java create mode 100644 src/test/resources/application.properties diff --git a/CICD/Pipelines/Jenkinsfile b/CICD/Pipelines/Jenkinsfile new file mode 100644 index 0000000..0711681 --- /dev/null +++ b/CICD/Pipelines/Jenkinsfile @@ -0,0 +1,477 @@ +#!/usr/bin/env groovy + +/** + * Jenkinsfile for inventory-quarkus + * + * This pipeline builds, tests, and deploys the inventory-quarkus microservice. + * It supports both development and production deployments. + * + * Required Jenkins Plugins: + * - Pipeline + * - Git + * - Docker Pipeline + * - Kubernetes CLI (kubectl) + * - Credentials Binding + * - SonarQube Scanner (optional) + * + * Required Credentials: + * - docker-registry-credentials: Docker registry username/password + * - sonarqube-token: SonarQube authentication token (optional) + * - kubeconfig: Kubernetes configuration for deployment + */ + +pipeline { + agent { + label 'maven' // Agent with Maven, Java 17, and Docker installed + } + + environment { + // Project configuration + PROJECT_NAME = 'inventory-quarkus' + PROJECT_VERSION = "${env.BUILD_NUMBER}" + JAVA_HOME = tool name: 'JDK-17', type: 'jdk' + + // Docker configuration + DOCKER_REGISTRY = credentials('docker-registry-url') ?: 'docker.io' + DOCKER_IMAGE = "${DOCKER_REGISTRY}/${PROJECT_NAME}" + DOCKER_TAG = "${env.BRANCH_NAME ?: 'latest'}-${env.BUILD_NUMBER}" + + // SonarQube configuration (optional) + SONARQUBE_ENABLED = false + SONARQUBE_SCANNER = tool name: 'SonarQube', type: 'hudson.plugins.sonar.SonarRunnerInstallation' + + // Kubernetes configuration + KUBERNETES_NAMESPACE = 'inventory' + KUBERNETES_DEPLOYMENT = 'inventory-quarkus' + + // Maven options + MAVEN_OPTS = '-Dmaven.repo.local=$WORKSPACE/.m2/repository -Xmx1024m' + } + + tools { + maven 'Maven-3.9' + jdk 'JDK-17' + } + + options { + timeout(time: 30, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '10')) + disableConcurrentBuilds() + timestamps() + ansiColor('xterm') + } + + stages { + stage('Checkout') { + steps { + echo "Checking out source code from ${env.GIT_URL ?: 'repository'}..." + checkout scm + + script { + env.GIT_COMMIT_SHORT = sh( + script: 'git rev-parse --short HEAD', + returnStdout: true + ).trim() + env.GIT_BRANCH_NAME = sh( + script: 'git rev-parse --abbrev-ref HEAD', + returnStdout: true + ).trim() + } + } + post { + success { + echo "Checkout completed successfully. Commit: ${env.GIT_COMMIT_SHORT}" + } + } + } + + stage('Validate') { + parallel { + stage('Code Style Check') { + steps { + echo "Running code style validation..." + sh ''' + mvn checkstyle:check -Dcheckstyle.failOnViolation=true || true + ''' + } + } + + stage('Dependency Check') { + steps { + echo "Checking for vulnerable dependencies..." + sh ''' + mvn dependency:analyze -DfailOnWarning=false || true + ''' + } + } + } + } + + stage('Build') { + steps { + echo "Building ${PROJECT_NAME}..." + sh ''' + mvn clean compile \ + -DskipTests \ + -Dproject.build.sourceEncoding=UTF-8 \ + -Dmaven.compiler.source=17 \ + -Dmaven.compiler.target=17 + ''' + } + post { + success { + archiveArtifacts artifacts: 'target/classes/**/*', fingerprint: true, allowEmptyArchive: true + } + } + } + + stage('Unit Tests') { + steps { + echo "Running unit tests..." + sh ''' + mvn test \ + -Dmaven.test.failure.ignore=false \ + -Djava.util.logging.manager=org.jboss.logmanager.LogManager + ''' + } + post { + always { + junit testResults: 'target/surefire-reports/*.xml', allowEmptyResults: true + publishHTML(target: [ + allowMissing: true, + alwaysLinkToLastBuild: true, + keepAll: true, + reportDir: 'target/site/jacoco', + reportFiles: 'index.html', + reportName: 'JaCoCo Coverage' + ]) + } + failure { + emailext( + subject: "Unit Tests Failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}", + body: "Unit tests have failed. Please check the build logs.\n\nBuild URL: ${env.BUILD_URL}", + to: "${env.CHANGE_AUTHOR_EMAIL ?: 'team@example.com'}" + ) + } + } + } + + stage('SonarQube Analysis') { + when { + expression { return env.SONARQUBE_ENABLED == 'true' } + } + steps { + echo "Running SonarQube analysis..." + withSonarQubeEnv('SonarQube') { + sh ''' + mvn sonar:sonar \ + -Dsonar.projectKey=${PROJECT_NAME} \ + -Dsonar.projectName="${PROJECT_NAME}" \ + -Dsonar.projectVersion=${PROJECT_VERSION} \ + -Dsonar.sources=src/main/java \ + -Dsonar.tests=src/test/java \ + -Dsonar.java.binaries=target/classes \ + -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml + ''' + } + } + } + + stage('Quality Gate') { + when { + expression { return env.SONARQUBE_ENABLED == 'true' } + } + steps { + echo "Waiting for SonarQube Quality Gate..." + timeout(time: 5, unit: 'MINUTES') { + script { + def qg = waitForQualityGate() + if (qg.status != 'OK') { + error "Quality Gate failed: ${qg.status}" + } + } + } + } + } + + stage('Package') { + steps { + echo "Packaging application..." + sh ''' + mvn package -DskipTests + + # List generated artifacts + ls -la target/ + ''' + } + post { + success { + archiveArtifacts artifacts: 'target/*.jar', fingerprint: true + } + } + } + + stage('Build Docker Image') { + steps { + echo "Building Docker image: ${DOCKER_IMAGE}:${DOCKER_TAG}" + script { + docker.withRegistry("https://${DOCKER_REGISTRY}", 'docker-registry-credentials') { + def customImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}", + "--build-arg JAVA_HOME=${JAVA_HOME} " + + "--build-arg BUILD_NUMBER=${env.BUILD_NUMBER} " + + "--build-arg GIT_COMMIT=${env.GIT_COMMIT_SHORT} " + + ".") + + // Push with build tag + customImage.push() + + // Push with branch tag + if (env.BRANCH_NAME) { + customImage.push("${env.BRANCH_NAME}") + } + + // Push latest tag for main branch + if (env.BRANCH_NAME == 'main' || env.BRANCH_NAME == 'master') { + customImage.push('latest') + } + } + } + } + } + + stage('Integration Tests') { + when { + anyOf { + branch 'main' + branch 'master' + branch 'develop' + } + } + steps { + echo "Running integration tests..." + sh ''' + mvn verify -DskipUnitTests -DskipITs=false || true + ''' + } + post { + always { + junit testResults: 'target/failsafe-reports/*.xml', allowEmptyResults: true + } + } + } + + stage('Security Scan') { + when { + anyOf { + branch 'main' + branch 'master' + branch 'develop' + } + } + steps { + echo "Running security scan on Docker image..." + sh ''' + # Run Trivy security scan (if available) + trivy image --exit-code 1 --severity HIGH,CRITICAL ${DOCKER_IMAGE}:${DOCKER_TAG} || true + ''' + } + } + + stage('Deploy to Development') { + when { + branch 'develop' + } + environment { + KUBECONFIG = credentials('kubeconfig-dev') + } + steps { + echo "Deploying to Development environment..." + script { + sh ''' + kubectl config use-context development + + # Update deployment image + kubectl set image deployment/${KUBERNETES_DEPLOYMENT} \ + ${KUBERNETES_DEPLOYMENT}=${DOCKER_IMAGE}:${DOCKER_TAG} \ + -n ${KUBERNETES_NAMESPACE} + + # Wait for rollout + kubectl rollout status deployment/${KUBERNETES_DEPLOYMENT} \ + -n ${KUBERNETES_NAMESPACE} \ + --timeout=300s + + # Verify deployment + kubectl get pods -n ${KUBERNETES_NAMESPACE} -l app=${KUBERNETES_DEPLOYMENT} + ''' + } + } + post { + failure { + script { + sh ''' + kubectl rollout undo deployment/${KUBERNETES_DEPLOYMENT} -n ${KUBERNETES_NAMESPACE} + ''' + } + emailext( + subject: "Deployment Failed (Development): ${env.JOB_NAME}", + body: "Deployment to development environment failed. Rollback initiated.\n\nBuild URL: ${env.BUILD_URL}", + to: "${env.CHANGE_AUTHOR_EMAIL ?: 'team@example.com'}" + ) + } + } + } + + stage('Deploy to Staging') { + when { + anyOf { + branch 'main' + branch 'master' + } + } + environment { + KUBECONFIG = credentials('kubeconfig-staging') + } + steps { + echo "Deploying to Staging environment..." + input message: 'Deploy to Staging?', ok: 'Deploy' + + script { + sh ''' + kubectl config use-context staging + + # Apply Kubernetes manifests + kubectl apply -f kubernetes/ -n ${KUBERNETES_NAMESPACE} + + # Update deployment image + kubectl set image deployment/${KUBERNETES_DEPLOYMENT} \ + ${KUBERNETES_DEPLOYMENT}=${DOCKER_IMAGE}:${DOCKER_TAG} \ + -n ${KUBERNETES_NAMESPACE} + + # Wait for rollout + kubectl rollout status deployment/${KUBERNETES_DEPLOYMENT} \ + -n ${KUBERNETES_NAMESPACE} \ + --timeout=300s + ''' + } + } + } + + stage('Smoke Tests') { + when { + anyOf { + branch 'main' + branch 'master' + } + } + steps { + echo "Running smoke tests against staging..." + script { + sh ''' + # Wait for service to be ready + sleep 30 + + # Basic health check + curl -f https://staging.example.com/q/health/ready || exit 1 + + # API smoke test + curl -f https://staging.example.com/api/inventory/count || exit 1 + ''' + } + } + } + + stage('Deploy to Production') { + when { + anyOf { + branch 'main' + branch 'master' + } + } + environment { + KUBECONFIG = credentials('kubeconfig-prod') + } + steps { + echo "Deploying to Production environment..." + input message: 'Deploy to Production?', ok: 'Deploy', submitter: 'admin,release-manager' + + script { + sh ''' + kubectl config use-context production + + # Blue-Green deployment strategy + kubectl apply -f kubernetes/deployment.yaml -n ${KUBERNETES_NAMESPACE} + + kubectl set image deployment/${KUBERNETES_DEPLOYMENT} \ + ${KUBERNETES_DEPLOYMENT}=${DOCKER_IMAGE}:${DOCKER_TAG} \ + -n ${KUBERNETES_NAMESPACE} + + kubectl rollout status deployment/${KUBERNETES_DEPLOYMENT} \ + -n ${KUBERNETES_NAMESPACE} \ + --timeout=600s + ''' + } + } + post { + success { + emailext( + subject: "Production Deployment Successful: ${env.JOB_NAME}", + body: """ + Production deployment completed successfully! + + Project: ${PROJECT_NAME} + Version: ${DOCKER_TAG} + Build: #${env.BUILD_NUMBER} + Commit: ${env.GIT_COMMIT_SHORT} + + Build URL: ${env.BUILD_URL} + """, + to: 'release-team@example.com' + ) + } + failure { + script { + sh ''' + kubectl rollout undo deployment/${KUBERNETES_DEPLOYMENT} -n ${KUBERNETES_NAMESPACE} + ''' + } + emailext( + subject: "URGENT: Production Deployment Failed: ${env.JOB_NAME}", + body: "Production deployment failed! Rollback has been initiated.\n\nBuild URL: ${env.BUILD_URL}", + to: 'oncall@example.com' + ) + } + } + } + } + + post { + always { + echo 'Cleaning up workspace...' + cleanWs() + } + + success { + echo "Pipeline completed successfully for ${PROJECT_NAME}!" + slackSend( + color: 'good', + message: "✅ Build Successful: ${env.JOB_NAME} #${env.BUILD_NUMBER}\nBranch: ${env.BRANCH_NAME}\nCommit: ${env.GIT_COMMIT_SHORT}" + ) + } + + failure { + echo "Pipeline failed for ${PROJECT_NAME}!" + slackSend( + color: 'danger', + message: "❌ Build Failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}\nBranch: ${env.BRANCH_NAME}\nCommit: ${env.GIT_COMMIT_SHORT}\nBuild URL: ${env.BUILD_URL}" + ) + } + + unstable { + echo "Pipeline is unstable for ${PROJECT_NAME}!" + slackSend( + color: 'warning', + message: "⚠️ Build Unstable: ${env.JOB_NAME} #${env.BUILD_NUMBER}\nBranch: ${env.BRANCH_NAME}" + ) + } + } +} \ No newline at end of file diff --git a/CICD/README.md b/CICD/README.md new file mode 100644 index 0000000..aa63760 --- /dev/null +++ b/CICD/README.md @@ -0,0 +1,118 @@ +# CI/CD Pipelines + +This folder contains the CI/CD pipeline configurations for the inventory-quarkus project. + +## Structure + +``` +CICD/ +├── Pipelines/ +│ └── Jenkinsfile # Main Jenkins pipeline definition +└── README.md # This file +``` + +## Jenkins Pipeline + +The main Jenkinsfile (`Pipelines/Jenkinsfile`) defines a complete CI/CD pipeline with the following stages: + +### Pipeline Stages + +| Stage | Description | +|-------|-------------| +| **Checkout** | Clone source code from repository | +| **Validate** | Code style check & dependency analysis (parallel) | +| **Build** | Compile the application with Maven | +| **Unit Tests** | Run unit tests and generate coverage reports | +| **SonarQube Analysis** | Static code analysis (optional) | +| **Quality Gate** | Enforce code quality standards | +| **Package** | Build JAR artifacts | +| **Build Docker Image** | Create and push Docker images | +| **Integration Tests** | Run integration tests on main branches | +| **Security Scan** | Scan Docker image for vulnerabilities | +| **Deploy to Development** | Auto-deploy on develop branch | +| **Deploy to Staging** | Manual approval required for staging | +| **Smoke Tests** | Basic health checks after deployment | +| **Deploy to Production** | Manual approval required for production | + +### Required Jenkins Plugins + +- Pipeline +- Git +- Docker Pipeline +- Kubernetes CLI (kubectl) +- Credentials Binding +- SonarQube Scanner (optional) +- Email Extension +- Slack Notification +- AnsiColor +- Timestamper + +### Required Credentials + +Configure these credentials in Jenkins: + +| Credential ID | Type | Description | +|--------------|------|-------------| +| `docker-registry-url` | Secret text | Docker registry URL | +| `docker-registry-credentials` | Username/Password | Docker registry login | +| `kubeconfig-dev` | Secret file | Kubernetes config for dev | +| `kubeconfig-staging` | Secret file | Kubernetes config for staging | +| `kubeconfig-prod` | Secret file | Kubernetes config for production | +| `sonarqube-token` | Secret text | SonarQube authentication (optional) | + +### Branch Strategy + +- **develop** → Auto-deploy to Development +- **main/master** → Deploy to Staging (manual approval) → Production (manual approval) +- **feature/*** → Build and test only + +### Environment Variables + +Key environment variables used in the pipeline: + +| Variable | Description | Default | +|----------|-------------|---------| +| `PROJECT_NAME` | Project identifier | `inventory-quarkus` | +| `KUBERNETES_NAMESPACE` | Kubernetes namespace | `inventory` | +| `DOCKER_REGISTRY` | Docker registry URL | `docker.io` | +| `SONARQUBE_ENABLED` | Enable SonarQube | `false` | + +### Usage + +1. **Create Jenkins Job:** + - New Item → Pipeline + - Pipeline script from SCM + - Point to your Git repository + - Script path: `CICD/Pipelines/Jenkinsfile` + +2. **Configure Webhook:** + - Add webhook in Git repository to trigger Jenkins on push events + +3. **Run Pipeline:** + - Manual trigger or webhook-triggered on push + +### Notifications + +The pipeline sends notifications via: +- **Email**: On test failures, deployment failures, and production deployments +- **Slack**: On build success, failure, or unstable status + +### Rollback + +Automatic rollback is configured for: +- Development deployment failures +- Production deployment failures + +Manual rollback command: +```bash +kubectl rollout undo deployment/inventory-quarkus -n inventory +``` + +## Future Enhancements + +Consider adding: +- GitHub Actions workflow (alternative to Jenkins) +- GitLab CI configuration +- Azure DevOps pipeline +- ArgoCD for GitOps deployments +- Helm charts for Kubernetes deployments \ No newline at end of file diff --git a/README.md b/README.md index 7aaf942..e88c2bd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Inventory Quarkus API -A RESTful inventory management microservice built with Quarkus, providing CRUD operations for inventory items with pagination, validation, and OpenAPI documentation. +A RESTful inventory management microservice built with Quarkus, providing CRUD operations for inventory items with pagination, validation, security, metrics, resilience, and OpenAPI documentation. ## Table of Contents @@ -9,14 +9,23 @@ A RESTful inventory management microservice built with Quarkus, providing CRUD o - [Running the Application](#running-the-application) - [API Documentation](#api-documentation) - [API Endpoints](#api-endpoints) +- [API Versioning](#api-versioning) +- [Security](#security) +- [Metrics](#metrics) +- [Resilience](#resilience) - [Data Model](#data-model) - [Error Handling](#error-handling) - [Testing](#testing) - [Technology Stack](#technology-stack) +- [Configuration](#configuration) +- [CI/CD](#cicd) ## Features - ✅ Full CRUD operations for inventory items +- ✅ **API Versioning (v1 endpoints with enhanced features)** +- ✅ **Metrics with Micrometer/Prometheus** +- ✅ **Resilience patterns (Circuit Breaker, Retry, Timeout)** - ✅ Paginated list endpoint with metadata - ✅ Search inventory by product ID - ✅ Bean Validation for input data @@ -24,13 +33,19 @@ A RESTful inventory management microservice built with Quarkus, providing CRUD o - ✅ Health checks (liveness and readiness) - ✅ Comprehensive error handling with consistent error responses - ✅ Caching with Caffeine for improved performance +- ✅ JWT-based authentication with role-based access control +- ✅ PostgreSQL support for production with Flyway migrations +- ✅ Structured JSON logging for production +- ✅ Audit fields (createdAt, updatedAt) - ✅ H2 in-memory database for development - ✅ Native image compilation support +- ✅ **CI/CD Pipeline with Jenkins** ## Prerequisites - Java 17+ - Maven 3.8+ +- PostgreSQL (for production) - (Optional) GraalVM for native compilation ## Running the Application @@ -43,10 +58,23 @@ A RESTful inventory management microservice built with Quarkus, providing CRUD o The application will start at `http://localhost:8080`. +Authentication is disabled in development mode for easier testing. + ### Production Mode ```bash +# Build the application ./mvnw clean package + +# Run with PostgreSQL +export POSTGRES_HOST=localhost +export POSTGRES_PORT=5432 +export POSTGRES_DB=inventory +export POSTGRES_USER=inventory +export POSTGRES_PASSWORD=inventory +export JWT_ISSUER=https://your-issuer.com +export JWT_PUBLIC_KEY_URL=/path/to/publicKey.pem + java -jar target/quarkus-app/quarkus-run.jar ``` @@ -57,6 +85,20 @@ java -jar target/quarkus-app/quarkus-run.jar ./target/inventory-quarkus-1.0.0-SNAPSHOT-runner ``` +### Docker + +```bash +# Build Docker image +docker build -t inventory-quarkus . + +# Run with Docker +docker run -i --rm -p 8080:8080 \ + -e POSTGRES_HOST=host.docker.internal \ + -e POSTGRES_USER=inventory \ + -e POSTGRES_PASSWORD=inventory \ + inventory-quarkus +``` + ## API Documentation Once the application is running, access the interactive API documentation: @@ -67,6 +109,21 @@ Once the application is running, access the interactive API documentation: ## API Endpoints +### Original API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/inventory` | List inventory (paginated) | +| GET | `/api/inventory/all` | List all inventory | +| GET | `/api/inventory/count` | Count inventory items | +| GET | `/api/inventory/{id}` | Get by ID | +| GET | `/api/inventory/product/{id}` | Get by product ID | +| POST | `/api/inventory` | Create item | +| PUT | `/api/inventory/{id}` | Update item | +| PATCH | `/api/inventory/{id}/quantity` | Update quantity | +| DELETE | `/api/inventory/{id}` | Delete item | +| DELETE | `/api/inventory/cache` | Clear caches | + ### List Inventory Items (Paginated) ```http @@ -77,8 +134,13 @@ GET /api/inventory?page=0&size=20 ```json { "data": [ - {"id": 100000, "productId": 1001, "quantity": 0}, - {"id": 165613, "productId": 1004, "quantity": 45} + { + "id": 100000, + "productId": 1001, + "quantity": 0, + "createdAt": "2026-02-09T00:00:00Z", + "updatedAt": "2026-02-09T00:00:00Z" + } ], "total": 8, "page": 0, @@ -89,130 +151,170 @@ GET /api/inventory?page=0&size=20 } ``` -**Query Parameters:** -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| page | int | 0 | Page number (0-based) | -| size | int | 20 | Page size (max 100) | +## API Versioning -### List All Inventory Items (No Pagination) +This API uses URI path versioning. Two versions are available: -```http -GET /api/inventory/all -``` +### Version 1 (Enhanced) - `/api/v1/inventory` + +The v1 endpoints include additional features: + +| Feature | Description | +|---------|-------------| +| **Metrics** | All endpoints are instrumented with `@Counted` and `@Timed` annotations | +| **Timeout** | 2-5 second timeouts on read operations | +| **Circuit Breaker** | Opens after 50% failure rate (10 requests window) | +| **Retry** | 3 retries with 100ms delay for get operations | -### Get Inventory Count +### V1 Endpoints ```http -GET /api/inventory/count +GET /api/v1/inventory # List (Circuit Breaker + Metrics) +GET /api/v1/inventory/{id} # Get by ID (Retry + Timeout + Cache) +GET /api/v1/inventory/product/{id} # Get by product (Retry + Cache) +POST /api/v1/inventory # Create (Metrics) +PUT /api/v1/inventory/{id} # Update (Metrics) +PATCH /api/v1/inventory/{id}/quantity # Update quantity (Metrics) +DELETE /api/v1/inventory/{id} # Delete (Metrics) ``` -**Response:** `8` (text/plain) +### Version Compatibility -### Get Inventory by ID +| Version | Status | Features | +|---------|--------|----------| +| v0 (unversioned) | Stable | Basic CRUD, caching | +| v1 | Current | Metrics, resilience patterns, caching | -```http -GET /api/inventory/{itemId} -``` +## Security -**Response:** -```json -{ - "id": 329299, - "productId": 1002, - "quantity": 35 -} -``` +### JWT Authentication -### Get Inventory by Product ID +This API uses JWT (JSON Web Token) authentication for production. The authentication is disabled in development mode. -```http -GET /api/inventory/product/{productId} -``` +### Roles -**Response:** -```json -{ - "id": 329299, - "productId": 1002, - "quantity": 35 -} -``` +| Role | Permissions | +|------|-------------| +| `admin` | Full access: Create, Read, Update, Delete, Clear Cache | +| `inventory-manager` | Create, Read, Update | +| `inventory-viewer` | Read, Update Quantity only | -### Create Inventory Item +### Endpoint Security Matrix -```http -POST /api/inventory -Content-Type: application/json +| Endpoint | Method | Required Role | +|----------|--------|---------------| +| `/api/inventory` | GET | Public (No auth) | +| `/api/inventory/all` | GET | Public (No auth) | +| `/api/inventory/count` | GET | Public (No auth) | +| `/api/inventory/{id}` | GET | Public (No auth) | +| `/api/inventory/product/{id}` | GET | Public (No auth) | +| `/api/inventory` | POST | `admin`, `inventory-manager` | +| `/api/inventory/{id}` | PUT | `admin`, `inventory-manager` | +| `/api/inventory/{id}/quantity` | PATCH | `admin`, `inventory-manager`, `inventory-viewer` | +| `/api/inventory/{id}` | DELETE | `admin` only | +| `/api/inventory/cache` | DELETE | `admin` only | +| `/q/health/*` | GET | Public (No auth) | -{ - "productId": 9999, - "quantity": 100 -} -``` +### JWT Token Configuration -**Response:** `201 Created` -```json -{ - "id": 1, - "productId": 9999, - "quantity": 100 -} +Configure JWT in production with environment variables: + +```bash +export JWT_ISSUER=https://your-identity-provider.com +export JWT_PUBLIC_KEY_URL=https://your-identity-provider.com/.well-known/jwks.json ``` -**Note:** The `id` field is auto-generated. Do not include it in the request body. +## Metrics -### Update Inventory Item (Full Update) +### Prometheus Metrics -```http -PUT /api/inventory/{itemId} -Content-Type: application/json +The application exposes Prometheus-compatible metrics at `/q/metrics`. -{ - "productId": 9999, - "quantity": 200 -} -``` +#### Custom Metrics -**Response:** `200 OK` +| Metric Name | Type | Description | +|-------------|------|-------------| +| `inventory.list.count` | Counter | Total list requests | +| `inventory.list.timer` | Timer | List request duration | +| `inventory.get.by.id.count` | Counter | Get by ID requests | +| `inventory.get.by.id.timer` | Timer | Get by ID duration | +| `inventory.get.by.product.count` | Counter | Get by product requests | +| `inventory.create.count` | Counter | Create operations | +| `inventory.create.timer` | Timer | Create duration | +| `inventory.update.count` | Counter | Update operations | +| `inventory.delete.count` | Counter | Delete operations | +| `inventory.total.items` | Gauge | Total inventory items | -### Update Quantity (Partial Update) +#### Example Metrics Output -```http -PATCH /api/inventory/{itemId}/quantity -Content-Type: application/json +``` +# HELP inventory_list_count_total Total list requests +# TYPE inventory_list_count_total counter +inventory_list_count_total 10.0 + +# HELP inventory_list_timer_seconds List request duration +# TYPE inventory_list_timer_seconds summary +inventory_list_timer_seconds{quantile="0.5"} 0.015 +inventory_list_timer_seconds{quantile="0.95"} 0.025 +inventory_list_timer_seconds{quantile="0.99"} 0.030 +inventory_list_timer_seconds_count 10.0 +``` -{ - "quantity": 500 -} +#### Accessing Metrics + +```bash +curl http://localhost:8080/q/metrics ``` -**Response:** `200 OK` +### Grafana Dashboard -### Delete Inventory Item +Use the Prometheus metrics with Grafana for visualization. Key panels: +- Request rate (requests/second) +- Response time percentiles (p50, p95, p99) +- Error rate +- Circuit breaker status -```http -DELETE /api/inventory/{itemId} -``` +## Resilience -**Response:** `204 No Content` +### Circuit Breaker -### Clear All Caches +Protects against cascading failures by opening when failure threshold is reached. -```http -DELETE /api/inventory/cache +**Configuration:** +- Request Volume Threshold: 10 requests +- Failure Ratio: 50% +- Delay: 5 seconds +- Success Threshold: 3 successful calls + +**Applied to:** `GET /api/v1/inventory` + +```java +@CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000, successThreshold = 3) ``` -**Response:** `204 No Content` +### Timeout -Clears all cached inventory data. Useful for administrative purposes or when you need to force a cache refresh. +Prevents hanging requests by timing out after specified duration. -### Health Checks +| Endpoint | Timeout | +|----------|---------| +| List inventory | 5 seconds | +| List all | 3 seconds | +| Get by ID | 2 seconds | +| Get by product | 2 seconds | -```http -GET /q/health # Overall health -GET /q/health/ready # Readiness probe -GET /q/health/live # Liveness probe +### Retry + +Automatically retries failed operations. + +**Configuration:** +- Max Retries: 3 +- Delay: 100ms + +**Applied to:** Get by ID, Get by Product + +```java +@Retry(maxRetries = 3, delay = 100) ``` ## Data Model @@ -224,6 +326,8 @@ GET /q/health/live # Liveness probe | id | Long | Auto-generated | Unique identifier | | productId | Long | Required, Unique | Associated product ID | | quantity | int | Required, Min 0 | Available quantity | +| createdAt | Instant | Auto-set | Creation timestamp | +| updatedAt | Instant | Auto-updated | Last update timestamp | ### Example JSON @@ -231,7 +335,9 @@ GET /q/health/live # Liveness probe { "id": 329299, "productId": 1002, - "quantity": 35 + "quantity": 35, + "createdAt": "2026-02-09T00:00:00.000Z", + "updatedAt": "2026-02-09T00:00:00.000Z" } ``` @@ -257,9 +363,12 @@ All errors return a consistent JSON structure: | 201 | Created (POST) | | 204 | No Content (DELETE) | | 400 | Bad Request - Validation error | +| 401 | Unauthorized - Missing or invalid JWT | +| 403 | Forbidden - Insufficient permissions | | 404 | Not Found - Resource doesn't exist | | 415 | Unsupported Media Type | | 500 | Internal Server Error | +| 503 | Service Unavailable - Circuit breaker open | ## Testing @@ -277,21 +386,31 @@ All errors return a consistent JSON structure: ### Test Categories -- **Unit Tests**: `InventoryResourceTest.java` (30 tests) -- **Native Tests**: `NativeInventoryResourceIT.java` +| Test Class | Count | Description | +|------------|-------|-------------| +| `InventoryResourceTest.java` | 30 | Original API tests | +| `InventoryResourceV1Test.java` | 21 | V1 API tests with metrics | +| `NativeInventoryResourceIT.java` | - | Native image tests | ## Technology Stack -- **Framework**: Quarkus 3.8.4 -- **Language**: Java 17 -- **JAX-RS**: RESTEasy Reactive -- **ORM**: Hibernate ORM with Panache -- **Database**: H2 (dev), configurable for PostgreSQL/MySQL -- **Validation**: Hibernate Validator -- **Documentation**: SmallRye OpenAPI with Swagger UI -- **Health**: SmallRye Health -- **Caching**: Quarkus Cache with Caffeine -- **Testing**: JUnit 5, Rest Assured +| Category | Technology | +|----------|------------| +| Framework | Quarkus 3.8.4 | +| Language | Java 17 | +| JAX-RS | RESTEasy Reactive | +| ORM | Hibernate ORM with Panache | +| Database | H2 (dev), PostgreSQL (prod) | +| Migrations | Flyway | +| Validation | Hibernate Validator | +| Security | SmallRye JWT | +| Documentation | SmallRye OpenAPI with Swagger UI | +| Health | SmallRye Health | +| Caching | Quarkus Cache with Caffeine | +| **Metrics** | **Micrometer with Prometheus** | +| **Resilience** | **SmallRye Fault Tolerance** | +| Logging | Quarkus Logging JSON | +| Testing | JUnit 5, Rest Assured | ## Project Structure @@ -300,22 +419,24 @@ src/ ├── main/ │ ├── java/com/redhat/cloudnative/ │ │ ├── Inventory.java # Entity class -│ │ ├── InventoryResource.java # REST endpoints +│ │ ├── InventoryResource.java # REST endpoints (unversioned) +│ │ ├── InventoryResourceV1.java # REST endpoints v1 (metrics + resilience) │ │ ├── PaginatedResponse.java # Pagination wrapper │ │ ├── QuantityUpdateRequest.java # DTO for PATCH │ │ ├── ErrorResponse.java # Error response DTO -│ │ ├── InventoryNotFoundException.java -│ │ ├── InventoryNotFoundExceptionMapper.java -│ │ ├── InvalidInventoryException.java -│ │ ├── InvalidInventoryExceptionMapper.java -│ │ └── ConstraintViolationExceptionMapper.java +│ │ └── ...ExceptionMappers.java # Exception handlers │ └── resources/ │ ├── application.properties # Configuration -│ └── import.sql # Seed data -└── test/ - └── java/com/redhat/cloudnative/ - ├── InventoryResourceTest.java - └── NativeInventoryResourceIT.java +│ ├── import.sql # Seed data (dev) +│ └── db/migration/ # Flyway migrations +│ └── V1.0.0__Initial_schema.sql +├── test/ +│ └── java/com/redhat/cloudnative/ +│ ├── InventoryResourceTest.java # Original API tests +│ └── InventoryResourceV1Test.java # V1 API tests +└── CICD/ + └── Pipelines/ + └── Jenkinsfile # CI/CD Pipeline ``` ## Caching @@ -329,45 +450,123 @@ This application uses Quarkus Cache with Caffeine backend for improved performan | `GET /api/inventory/{itemId}` | `inventory-cache` | Cached by inventory ID | | `GET /api/inventory/product/{productId}` | `inventory-product-cache` | Cached by product ID | -### Cache Invalidation - -Caches are automatically invalidated on data modifications: - -| Operation | Cache Behavior | -|-----------|----------------| -| POST (create) | All caches cleared | -| PUT (update) | Specific ID + product cache cleared | -| PATCH (update quantity) | Specific ID + product cache cleared | -| DELETE | Specific ID + product cache cleared | - ### Cache Configuration ```properties -# Cache expires after 5 minutes of being written +# Cache expires after 5 minutes quarkus.cache.caffeine.inventory-cache.expire-after-write=5m quarkus.cache.caffeine.inventory-product-cache.expire-after-write=5m ``` -### Manual Cache Clear +## Configuration -Use the `DELETE /api/inventory/cache` endpoint to manually clear all caches. +### Environment Variables -## Configuration +| Variable | Default | Description | +|----------|---------|-------------| +| `POSTGRES_HOST` | localhost | PostgreSQL host | +| `POSTGRES_PORT` | 5432 | PostgreSQL port | +| `POSTGRES_DB` | inventory | Database name | +| `POSTGRES_USER` | inventory | Database user | +| `POSTGRES_PASSWORD` | inventory | Database password | +| `JWT_ISSUER` | - | JWT token issuer URL | +| `JWT_PUBLIC_KEY_URL` | - | URL to JWT public key | -Key configuration options in `application.properties`: +### Development Configuration ```properties -# Database (H2 in-memory for development) +# H2 in-memory database quarkus.datasource.jdbc.url=jdbc:h2:mem:inventory quarkus.datasource.db-kind=h2 -# Hibernate -quarkus.hibernate-orm.database.generation=drop-and-create -quarkus.hibernate-orm.sql-load-script=import.sql +# Security disabled for development +%dev.quarkus.smallrye-jwt.enabled=false -# Cache Configuration (Caffeine backend) -quarkus.cache.caffeine.inventory-cache.expire-after-write=5m -quarkus.cache.caffeine.inventory-product-cache.expire-after-write=5m +# Metrics enabled +quarkus.micrometer.enabled=true +``` + +### Production Configuration + +```properties +# PostgreSQL database +%prod.quarkus.datasource.db-kind=postgresql + +# Flyway migrations +%prod.quarkus.flyway.migrate-at-start=true + +# JWT Security +%prod.mp.jwt.verify.issuer=${JWT_ISSUER} +%prod.quarkus.smallrye-jwt.enabled=true + +# JSON Logging +%prod.quarkus.log.console.json=true + +# Metrics +quarkus.micrometer.export.prometheus.enabled=true +``` + +## Observability Endpoints + +| Endpoint | Description | +|----------|-------------| +| `/q/health` | Overall health status | +| `/q/health/ready` | Readiness probe | +| `/q/health/live` | Liveness probe | +| `/q/metrics` | Prometheus metrics | +| `/q/swagger-ui` | API documentation | +| `/q/openapi` | OpenAPI specification | + +## CI/CD + +A complete Jenkins pipeline is provided in `CICD/Pipelines/Jenkinsfile`. + +### Pipeline Stages + +1. **Checkout** - Clone source code +2. **Validate** - Code style & dependency checks +3. **Build** - Compile with Maven +4. **Unit Tests** - Run tests with coverage +5. **SonarQube** - Static code analysis (optional) +6. **Package** - Build JAR artifacts +7. **Docker Build** - Create container image +8. **Security Scan** - Trivy vulnerability scan +9. **Deploy Dev** - Auto-deploy on develop branch +10. **Deploy Staging** - Manual approval required +11. **Deploy Production** - Manual approval required + +### Branch Strategy + +| Branch | Deployment | +|--------|------------| +| `develop` | Development environment | +| `main/master` | Staging → Production | +| `feature/*` | Build and test only | + +### Required Jenkins Plugins + +- Pipeline, Git, Docker Pipeline +- Kubernetes CLI, Credentials Binding +- SonarQube Scanner, Email Extension +- Slack Notification + +See `CICD/README.md` for detailed configuration. + +## Database Migrations + +This project uses Flyway for database migrations in production. + +### Creating a New Migration + +1. Create a file in `src/main/resources/db/migration/` +2. Name it with version pattern: `V1.0.1__Description.sql` +3. Write your SQL migration + +### Example Migration + +```sql +-- V1.0.1__Add_low_stock_flag.sql +ALTER TABLE INVENTORY ADD COLUMN low_stock_threshold INTEGER DEFAULT 10; ``` ## License diff --git a/pom.xml b/pom.xml index 1da0708..85f8f9f 100644 --- a/pom.xml +++ b/pom.xml @@ -47,6 +47,11 @@ quarkus-junit5 test + + io.quarkus + quarkus-test-security + test + io.rest-assured rest-assured @@ -81,6 +86,40 @@ io.quarkus quarkus-cache + + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-smallrye-jwt + + + + io.quarkus + quarkus-jdbc-postgresql + + + + io.quarkus + quarkus-flyway + + + + io.quarkus + quarkus-logging-json + + + + io.quarkus + quarkus-micrometer-registry-prometheus + + + + io.quarkus + quarkus-smallrye-fault-tolerance + diff --git a/src/main/java/com/redhat/cloudnative/Inventory.java b/src/main/java/com/redhat/cloudnative/Inventory.java index aa3bc86..c6baf47 100644 --- a/src/main/java/com/redhat/cloudnative/Inventory.java +++ b/src/main/java/com/redhat/cloudnative/Inventory.java @@ -3,28 +3,54 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import jakarta.persistence.Column; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import io.quarkus.hibernate.orm.panache.PanacheEntity; import org.eclipse.microprofile.openapi.annotations.media.Schema; +import java.time.Instant; + @Entity @Table(name = "INVENTORY") public class Inventory extends PanacheEntity { @Column(name = "product_id", unique = true) @NotNull(message = "Product ID is required") + @Schema(description = "Associated product ID", required = true, example = "1001") public Long productId; @Column(name = "quantity") @NotNull(message = "Quantity is required") @Min(value = 0, message = "Quantity cannot be negative") + @Schema(description = "Current stock quantity", required = true, example = "50") public int quantity; + @Column(name = "created_at", updatable = false) + @Schema(description = "Creation timestamp", readOnly = true) + public Instant createdAt; + + @Column(name = "updated_at") + @Schema(description = "Last update timestamp", readOnly = true) + public Instant updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + updatedAt = Instant.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } + @Override public String toString() { - return "Inventory [Id='" + id + '\'' + ", productId=" + productId + ", quantity=" + quantity + ']'; + return "Inventory [Id='" + id + '\'' + ", productId=" + productId + ", quantity=" + quantity + + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + ']'; } /** diff --git a/src/main/java/com/redhat/cloudnative/InventoryResource.java b/src/main/java/com/redhat/cloudnative/InventoryResource.java index 798c38f..5d8d2ce 100644 --- a/src/main/java/com/redhat/cloudnative/InventoryResource.java +++ b/src/main/java/com/redhat/cloudnative/InventoryResource.java @@ -34,6 +34,7 @@ import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.logging.Logger; import java.net.URI; import java.util.List; @@ -45,6 +46,8 @@ @Tag(name = "Inventory", description = "Inventory management operations") public class InventoryResource { + private static final Logger LOG = Logger.getLogger(InventoryResource.class); + @Inject @CacheName("inventory-cache") Cache inventoryCache; @@ -57,12 +60,14 @@ public class InventoryResource { public PaginatedResponse listAll( @Parameter(description = "Page number (0-based)") @QueryParam("page") @DefaultValue("0") int page, @Parameter(description = "Page size (max 100)") @QueryParam("size") @DefaultValue("20") int size) { + LOG.debugf("Listing inventory items - page: %d, size: %d", page, size); // Limit page size to prevent performance issues int effectiveSize = Math.min(size, 100); List items = Inventory.findAll() .page(page, effectiveSize) .list(); long total = Inventory.count(); + LOG.debugf("Found %d items out of %d total", items.size(), total); return PaginatedResponse.of(items, total, page, effectiveSize); } @@ -73,6 +78,7 @@ public PaginatedResponse listAll( @APIResponse(responseCode = "200", description = "List of all inventory items", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))) }) public List listAllWithoutPagination() { + LOG.debug("Listing all inventory items without pagination"); return Inventory.listAll(); } @@ -82,7 +88,9 @@ public List listAllWithoutPagination() { @Operation(summary = "Count inventory items", description = "Returns the total number of inventory items") @APIResponse(responseCode = "200", description = "Total count of inventory items") public Long count() { - return Inventory.count(); + Long count = Inventory.count(); + LOG.debugf("Inventory count: %d", count); + return count; } @GET @@ -95,8 +103,10 @@ public Long count() { @CacheResult(cacheName = "inventory-cache") public Inventory getAvailability( @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { + LOG.debugf("Getting inventory by ID: %d", itemId); Inventory inventory = Inventory.findById(itemId); if (inventory == null) { + LOG.warnf("Inventory item not found with ID: %d", itemId); throw new InventoryNotFoundException(itemId); } return inventory; @@ -112,8 +122,10 @@ public Inventory getAvailability( @CacheResult(cacheName = "inventory-product-cache") public Inventory getByProductId( @Parameter(description = "Product ID", required = true) @PathParam("productId") Long productId) { + LOG.debugf("Getting inventory by product ID: %d", productId); Inventory inventory = Inventory.findByProductId(productId); if (inventory == null) { + LOG.warnf("Inventory not found for product ID: %d", productId); throw new InventoryNotFoundException(productId); } return inventory; @@ -124,15 +136,20 @@ public Inventory getByProductId( @Operation(summary = "Create inventory item", description = "Creates a new inventory item") @APIResponses(value = { @APIResponse(responseCode = "201", description = "Inventory item created", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), - @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Insufficient permissions") }) @CacheInvalidateAll(cacheName = "inventory-cache") @CacheInvalidateAll(cacheName = "inventory-product-cache") public Response create( @RequestBody(description = "Inventory item to create", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory inventory) { + LOG.infof("Creating inventory item for product ID: %d with quantity: %d", inventory.productId, + inventory.quantity); // Clear any provided ID to let the database auto-generate it inventory.id = null; inventory.persist(); + LOG.infof("Created inventory item with ID: %d", inventory.id); return Response.created(URI.create("/api/inventory/" + inventory.id)) .entity(inventory) .build(); @@ -145,6 +162,8 @@ public Response create( @APIResponses(value = { @APIResponse(responseCode = "200", description = "Inventory item updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Insufficient permissions"), @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) }) @CacheInvalidate(cacheName = "inventory-cache") @@ -152,12 +171,15 @@ public Response create( public Inventory update( @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, @RequestBody(description = "Updated inventory data", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory updatedInventory) { + LOG.infof("Updating inventory item ID: %d with quantity: %d", itemId, updatedInventory.quantity); Inventory inventory = Inventory.findById(itemId); if (inventory == null) { + LOG.warnf("Inventory item not found for update with ID: %d", itemId); throw new InventoryNotFoundException(itemId); } inventory.quantity = updatedInventory.quantity; inventory.persist(); + LOG.infof("Updated inventory item ID: %d", itemId); return inventory; } @@ -168,6 +190,8 @@ public Inventory update( @APIResponses(value = { @APIResponse(responseCode = "200", description = "Quantity updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), @APIResponse(responseCode = "400", description = "Invalid quantity value", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Insufficient permissions"), @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) }) @CacheInvalidate(cacheName = "inventory-cache") @@ -175,12 +199,15 @@ public Inventory update( public Inventory updateQuantity( @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, @RequestBody(description = "New quantity value", required = true, content = @Content(schema = @Schema(implementation = QuantityUpdateRequest.class))) @Valid QuantityUpdateRequest request) { + LOG.infof("Updating quantity for inventory ID: %d to %d", itemId, request.getQuantity()); Inventory inventory = Inventory.findById(itemId); if (inventory == null) { + LOG.warnf("Inventory item not found for quantity update with ID: %d", itemId); throw new InventoryNotFoundException(itemId); } inventory.quantity = request.getQuantity(); inventory.persist(); + LOG.infof("Updated quantity for inventory ID: %d", itemId); return inventory; } @@ -190,17 +217,22 @@ public Inventory updateQuantity( @Operation(summary = "Delete inventory item", description = "Deletes an inventory item by its ID") @APIResponses(value = { @APIResponse(responseCode = "204", description = "Inventory item deleted"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Admin role required"), @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) }) @CacheInvalidate(cacheName = "inventory-cache") @CacheInvalidateAll(cacheName = "inventory-product-cache") public Response delete( @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { + LOG.infof("Deleting inventory item ID: %d", itemId); Inventory inventory = Inventory.findById(itemId); if (inventory == null) { + LOG.warnf("Inventory item not found for deletion with ID: %d", itemId); throw new InventoryNotFoundException(itemId); } inventory.delete(); + LOG.infof("Deleted inventory item ID: %d", itemId); return Response.noContent().build(); } @@ -210,10 +242,15 @@ public Response delete( @DELETE @Path("/cache") @Operation(summary = "Clear all inventory caches", description = "Clears all cached inventory data") - @APIResponse(responseCode = "204", description = "Caches cleared") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Caches cleared"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Admin role required") + }) @CacheInvalidateAll(cacheName = "inventory-cache") @CacheInvalidateAll(cacheName = "inventory-product-cache") public Response clearCaches() { + LOG.info("Clearing all inventory caches"); return Response.noContent().build(); } diff --git a/src/main/java/com/redhat/cloudnative/InventoryResourceV1.java b/src/main/java/com/redhat/cloudnative/InventoryResourceV1.java new file mode 100644 index 0000000..21a119d --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InventoryResourceV1.java @@ -0,0 +1,296 @@ +package com.redhat.cloudnative; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheInvalidate; +import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.cache.CacheName; +import io.quarkus.cache.CacheResult; + +import org.eclipse.microprofile.faulttolerance.CircuitBreaker; +import org.eclipse.microprofile.faulttolerance.Timeout; +import org.eclipse.microprofile.faulttolerance.Retry; + +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.core.instrument.MeterRegistry; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.logging.Logger; + +import java.net.URI; +import java.util.List; + +/** + * Inventory API v1 - Versioned endpoint with metrics and resilience patterns + * + * API Versioning Strategy: URI Path versioning (/api/v1/inventory) + */ +@Path("/api/v1/inventory") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Inventory v1", description = "Inventory management operations (v1)") +public class InventoryResourceV1 { + + private static final Logger LOG = Logger.getLogger(InventoryResourceV1.class); + + @Inject + @CacheName("inventory-cache") + Cache inventoryCache; + + @Inject + MeterRegistry meterRegistry; + + // ==================== GET ENDPOINTS (with metrics & resilience) + // ==================== + + @GET + @Timeout(5000) + @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000, successThreshold = 3) + @Counted(value = "inventory.list.count", description = "How many times inventory list has been requested") + @Timed(value = "inventory.list.timer", description = "Time taken to list inventory items", percentiles = { 0.5, + 0.95, 0.99 }) + @Operation(summary = "List all inventory items (v1)", description = "Returns a paginated list of inventory items with metadata") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Paginated list of inventory items", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = PaginatedResponse.class))), + @APIResponse(responseCode = "503", description = "Service unavailable - Circuit breaker open") + }) + public PaginatedResponse listAll( + @Parameter(description = "Page number (0-based)") @QueryParam("page") @DefaultValue("0") int page, + @Parameter(description = "Page size (max 100)") @QueryParam("size") @DefaultValue("20") int size) { + LOG.debugf("Listing inventory items - page: %d, size: %d", page, size); + int effectiveSize = Math.min(size, 100); + List items = Inventory.findAll() + .page(page, effectiveSize) + .list(); + long total = Inventory.count(); + // Record gauge metric + meterRegistry.gauge("inventory.total.items", total); + LOG.debugf("Found %d items out of %d total", items.size(), total); + return PaginatedResponse.of(items, total, page, effectiveSize); + } + + @GET + @Path("/all") + @Timeout(3000) + @Counted(value = "inventory.list.all.count", description = "How many times all inventory has been requested") + @Timed(value = "inventory.list.all.timer", description = "Time taken to list all inventory items") + @Operation(summary = "List all inventory items without pagination (v1)", description = "Returns a simple list of all inventory items") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "List of all inventory items", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))) + }) + public List listAllWithoutPagination() { + LOG.debug("Listing all inventory items without pagination"); + return Inventory.listAll(); + } + + @GET + @Path("/count") + @Produces(MediaType.TEXT_PLAIN) + @Counted(value = "inventory.count.requests", description = "How many times count has been requested") + @Operation(summary = "Count inventory items (v1)", description = "Returns the total number of inventory items") + @APIResponse(responseCode = "200", description = "Total count of inventory items") + public Long count() { + Long count = Inventory.count(); + LOG.debugf("Inventory count: %d", count); + return count; + } + + @GET + @Path("/{itemId}") + @Timeout(2000) + @Retry(maxRetries = 3, delay = 100) + @CacheResult(cacheName = "inventory-cache") + @Counted(value = "inventory.get.by.id.count", description = "How many times get by ID has been requested") + @Timed(value = "inventory.get.by.id.timer", description = "Time taken to get inventory by ID") + @Operation(summary = "Get inventory by ID (v1)", description = "Returns a single inventory item by its ID (cached)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + public Inventory getAvailability( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { + LOG.debugf("Getting inventory by ID: %d", itemId); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + return inventory; + } + + @GET + @Path("/product/{productId}") + @Timeout(2000) + @Retry(maxRetries = 3, delay = 100) + @CacheResult(cacheName = "inventory-product-cache") + @Counted(value = "inventory.get.by.product.count", description = "How many times get by product ID has been requested") + @Timed(value = "inventory.get.by.product.timer", description = "Time taken to get inventory by product ID") + @Operation(summary = "Get inventory by product ID (v1)", description = "Returns the inventory item for a specific product") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found for the product", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + public Inventory getByProductId( + @Parameter(description = "Product ID", required = true) @PathParam("productId") Long productId) { + LOG.debugf("Getting inventory by product ID: %d", productId); + Inventory inventory = Inventory.findByProductId(productId); + if (inventory == null) { + LOG.warnf("Inventory not found for product ID: %d", productId); + throw new InventoryNotFoundException(productId); + } + return inventory; + } + + // ==================== POST ENDPOINT ==================== + + @POST + @Transactional + @Counted(value = "inventory.create.count", description = "How many inventory items have been created") + @Timed(value = "inventory.create.timer", description = "Time taken to create inventory item") + @Operation(summary = "Create inventory item (v1)", description = "Creates a new inventory item") + @APIResponses(value = { + @APIResponse(responseCode = "201", description = "Inventory item created", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidateAll(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response create( + @RequestBody(description = "Inventory item to create", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory inventory) { + LOG.infof("Creating inventory item for product ID: %d with quantity: %d", inventory.productId, + inventory.quantity); + inventory.id = null; + inventory.persist(); + LOG.infof("Created inventory item with ID: %d", inventory.id); + return Response.created(URI.create("/api/v1/inventory/" + inventory.id)) + .entity(inventory) + .build(); + } + + // ==================== PUT ENDPOINT ==================== + + @PUT + @Path("/{itemId}") + @Transactional + @Counted(value = "inventory.update.count", description = "How many inventory items have been updated") + @Timed(value = "inventory.update.timer", description = "Time taken to update inventory item") + @Operation(summary = "Update inventory item (v1)", description = "Updates an existing inventory item completely") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Inventory update( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, + @RequestBody(description = "Updated inventory data", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory updatedInventory) { + LOG.infof("Updating inventory item ID: %d with quantity: %d", itemId, updatedInventory.quantity); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found for update with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + inventory.quantity = updatedInventory.quantity; + inventory.persist(); + LOG.infof("Updated inventory item ID: %d", itemId); + return inventory; + } + + // ==================== PATCH ENDPOINT ==================== + + @PATCH + @Path("/{itemId}/quantity") + @Transactional + @Counted(value = "inventory.quantity.update.count", description = "How many quantity updates have been performed") + @Timed(value = "inventory.quantity.update.timer", description = "Time taken to update quantity") + @Operation(summary = "Update inventory quantity (v1)", description = "Updates only the quantity of an inventory item") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Quantity updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid quantity value", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Inventory updateQuantity( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, + @RequestBody(description = "New quantity value", required = true, content = @Content(schema = @Schema(implementation = QuantityUpdateRequest.class))) @Valid QuantityUpdateRequest request) { + LOG.infof("Updating quantity for inventory ID: %d to %d", itemId, request.getQuantity()); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found for quantity update with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + inventory.quantity = request.getQuantity(); + inventory.persist(); + LOG.infof("Updated quantity for inventory ID: %d", itemId); + return inventory; + } + + // ==================== DELETE ENDPOINTS ==================== + + @DELETE + @Path("/{itemId}") + @Transactional + @Counted(value = "inventory.delete.count", description = "How many inventory items have been deleted") + @Operation(summary = "Delete inventory item (v1)", description = "Deletes an inventory item by its ID") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Inventory item deleted"), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response delete( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { + LOG.infof("Deleting inventory item ID: %d", itemId); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found for deletion with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + inventory.delete(); + LOG.infof("Deleted inventory item ID: %d", itemId); + return Response.noContent().build(); + } + + /** + * Clear all caches + */ + @DELETE + @Path("/cache") + @Counted(value = "cache.clear.count", description = "How many times cache has been cleared") + @Operation(summary = "Clear all inventory caches (v1)", description = "Clears all cached inventory data") + @APIResponse(responseCode = "204", description = "Caches cleared") + @CacheInvalidateAll(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response clearCaches() { + LOG.info("Clearing all inventory caches"); + return Response.noContent().build(); + } + +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 44f9281..45e1719 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,10 +1,110 @@ +# =========================================== +# Development Configuration (H2 in-memory) +# =========================================== quarkus.datasource.jdbc.url=jdbc:h2:mem:inventory;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1 quarkus.datasource.db-kind=h2 quarkus.hibernate-orm.database.generation=drop-and-create quarkus.hibernate-orm.log.sql=true quarkus.hibernate-orm.sql-load-script=import.sql + +# =========================================== +# Production Configuration (PostgreSQL) +# =========================================== +%prod.quarkus.datasource.db-kind=postgresql +%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:inventory} +%prod.quarkus.datasource.username=${POSTGRES_USER:inventory} +%prod.quarkus.datasource.password=${POSTGRES_PASSWORD:inventory} +%prod.quarkus.datasource.jdbc.max-size=20 +%prod.quarkus.datasource.jdbc.min-size=5 +%prod.quarkus.hibernate-orm.database.generation=none +%prod.quarkus.hibernate-orm.sql-load-script= +%prod.quarkus.flyway.migrate-at-start=true %prod.quarkus.package.uber-jar=true +# Flyway Configuration +quarkus.flyway.locations=db/migration +%dev.quarkus.flyway.migrate-at-start=false + +# =========================================== # Cache Configuration (Caffeine backend) +# =========================================== quarkus.cache.caffeine.inventory-cache.expire-after-write=5m quarkus.cache.caffeine.inventory-product-cache.expire-after-write=5m + +# =========================================== +# Security - JWT Authentication +# =========================================== +# JWT configuration for production +%prod.mp.jwt.verify.publickey.location=${JWT_PUBLIC_KEY_URL:/publicKey.pem} +%prod.mp.jwt.verify.issuer=${JWT_ISSUER:https://your-issuer.com} +%prod.quarkus.smallrye-jwt.enabled=true + +# Disable auth for development +%dev.quarkus.smallrye-jwt.enabled=false +%test.quarkus.smallrye-jwt.enabled=false + +# Security roles mapping (production only) +%prod.quarkus.http.auth.permission.roles1.paths=/api/inventory/* +%prod.quarkus.http.auth.permission.roles1.policy=authenticated +# Allow health endpoints without authentication +quarkus.http.auth.permission.public.paths=/q/health/*,/q/health +quarkus.http.auth.permission.public.policy=permit + +# Disable security for dev and test modes +%dev.quarkus.http.auth.permission.deny.paths=/* +%dev.quarkus.http.auth.permission.deny.policy=permit +%test.quarkus.http.auth.permission.deny.paths=/* +%test.quarkus.http.auth.permission.deny.policy=permit + +# =========================================== +# Logging Configuration +# =========================================== +# Console logging format +quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss.SSS} %-5p [%c{3.}] (%t) %s%e%n +quarkus.log.console.level=INFO +quarkus.log.category."com.redhat.cloudnative".level=DEBUG + +# JSON logging for production +%prod.quarkus.log.console.json=true +%prod.quarkus.log.console.json.pretty-print=false +%prod.quarkus.log.console.json.key-overrides=timestamp=@timestamp +%prod.quarkus.log.console.json.log-format=STRUCTURED + +# Enable access logging +quarkus.http.access-log.enabled=true +%prod.quarkus.http.access-log.pattern=%h %l %u %t "%r" %s %b "%{i,Referer}" "%{i,User-Agent}" + +# =========================================== +# Metrics Configuration (Micrometer/Prometheus) +# =========================================== +# Enable Prometheus metrics endpoint +quarkus.micrometer.enabled=true +quarkus.micrometer.export.prometheus.enabled=true +quarkus.micrometer.binder-enabled-default=true +quarkus.micrometer.binder.http-server.enabled=true +quarkus.micrometer.binder.jvm.enabled=true + +# Metrics endpoint path +quarkus.micrometer.export.prometheus.path=/q/metrics + +# =========================================== +# Resilience Configuration (Fault Tolerance) +# =========================================== +# Enable fault tolerance +quarkus.fault-tolerance.enabled=true + +# Default timeout settings +Timeout/enabled=true +Timeout/value=5000 + +# Circuit breaker defaults +CircuitBreaker/enabled=true +CircuitBreaker/failure-ratio=0.5 +CircuitBreaker/request-volume-threshold=10 +CircuitBreaker/delay=5000 +CircuitBreaker/success-threshold=3 + +# Retry defaults +Retry/enabled=true +Retry/max-retries=3 +Retry/delay=100 diff --git a/src/main/resources/db/migration/V1.0.0__Initial_schema.sql b/src/main/resources/db/migration/V1.0.0__Initial_schema.sql new file mode 100644 index 0000000..bdcb710 --- /dev/null +++ b/src/main/resources/db/migration/V1.0.0__Initial_schema.sql @@ -0,0 +1,32 @@ +-- Initial inventory schema for PostgreSQL +-- Flyway migration script + +CREATE TABLE IF NOT EXISTS INVENTORY ( + id BIGSERIAL PRIMARY KEY, + product_id BIGINT NOT NULL UNIQUE, + quantity INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create index on product_id for faster lookups +CREATE INDEX IF NOT EXISTS idx_inventory_product_id ON INVENTORY(product_id); + +-- Create index on quantity for low-stock queries +CREATE INDEX IF NOT EXISTS idx_inventory_quantity ON INVENTORY(quantity); + +-- Add constraint for non-negative quantity +ALTER TABLE INVENTORY ADD CONSTRAINT chk_quantity_non_negative CHECK (quantity >= 0); + +-- Insert initial data +INSERT INTO INVENTORY (product_id, quantity, created_at, updated_at) VALUES + (1001, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1002, 35, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1003, 15, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1004, 30, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1005, 25, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1006, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1007, 10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1008, 5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1009, 100, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); ++++++++ REPLACE \ No newline at end of file diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql index b88e806..b5316e8 100644 --- a/src/main/resources/import.sql +++ b/src/main/resources/import.sql @@ -1,8 +1,9 @@ -INSERT INTO INVENTORY(id, product_id, quantity) VALUES (100000, 1001, 0); -INSERT INTO INVENTORY(id, product_id, quantity) VALUES (329299, 1002, 35); -INSERT INTO INVENTORY(id, product_id, quantity) VALUES (329199, 1003, 12); -INSERT INTO INVENTORY(id, product_id, quantity) VALUES (165613, 1004, 45); -INSERT INTO INVENTORY(id, product_id, quantity) VALUES (165614, 1005, 87); -INSERT INTO INVENTORY(id, product_id, quantity) VALUES (165954, 1006, 43); -INSERT INTO INVENTORY(id, product_id, quantity) VALUES (444434, 1007, 32); -INSERT INTO INVENTORY(id, product_id, quantity) VALUES (444435, 1008, 53); \ No newline at end of file +-- Development test data for H2 in-memory database +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (100000, 1001, 0, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (329299, 1002, 35, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (329199, 1003, 12, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (165613, 1004, 45, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (165614, 1005, 87, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (165954, 1006, 43, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (444434, 1007, 32, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (444435, 1008, 53, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); \ No newline at end of file diff --git a/src/test/java/com/redhat/cloudnative/InventoryResourceV1Test.java b/src/test/java/com/redhat/cloudnative/InventoryResourceV1Test.java new file mode 100644 index 0000000..b410acc --- /dev/null +++ b/src/test/java/com/redhat/cloudnative/InventoryResourceV1Test.java @@ -0,0 +1,257 @@ +package com.redhat.cloudnative; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.*; + +@QuarkusTest +public class InventoryResourceV1Test { + + // ==================== GET /api/v1/inventory Tests ==================== + + @Test + public void testV1ListAllInventoryDefaultPagination() { + given() + .when().get("/api/v1/inventory") + .then() + .statusCode(200) + .body("page", is(0)) + .body("size", is(20)); + } + + @Test + public void testV1ListInventoryWithPagination() { + given() + .queryParam("page", 0) + .queryParam("size", 3) + .when().get("/api/v1/inventory") + .then() + .statusCode(200) + .body("data.size()", is(3)) + .body("page", is(0)); + } + + @Test + public void testV1ListAllWithoutPagination() { + given() + .when().get("/api/v1/inventory/all") + .then() + .statusCode(200); + } + + @Test + public void testV1CountInventory() { + given() + .when().get("/api/v1/inventory/count") + .then() + .statusCode(200); + } + + // ==================== GET /api/v1/inventory/{id} Tests ==================== + + @Test + public void testV1GetInventoryById() { + given() + .when().get("/api/v1/inventory/329299") + .then() + .statusCode(200) + .body("id", is(329299)) + .body("quantity", is(35)); + } + + @Test + public void testV1GetInventoryByIdNotFound() { + given() + .when().get("/api/v1/inventory/999999") + .then() + .statusCode(404) + .body("status", is(404)) + .body("error", is("Not Found")); + } + + // ==================== GET /api/v1/inventory/product/{productId} Tests + // ==================== + + @Test + public void testV1GetInventoryByProductId() { + given() + .when().get("/api/v1/inventory/product/1002") + .then() + .statusCode(200) + .body("productId", is(1002)) + .body("quantity", is(35)); + } + + @Test + public void testV1GetInventoryByProductIdNotFound() { + given() + .when().get("/api/v1/inventory/product/999999") + .then() + .statusCode(404); + } + + // ==================== POST /api/v1/inventory Tests ==================== + + @Test + public void testV1CreateInventory() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 5001, \"quantity\": 100}") + .when().post("/api/v1/inventory") + .then() + .statusCode(201) + .body("quantity", is(100)) + .body("productId", is(5001)) + .header("Location", containsString("/api/v1/inventory/")); + } + + @Test + public void testV1CreateInventoryWithNegativeQuantity() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 5002, \"quantity\": -10}") + .when().post("/api/v1/inventory") + .then() + .statusCode(400); + } + + // ==================== PUT /api/v1/inventory/{id} Tests ==================== + + @Test + public void testV1UpdateInventory() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 1004, \"quantity\": 999}") + .when().put("/api/v1/inventory/165613") + .then() + .statusCode(200) + .body("id", is(165613)) + .body("quantity", is(999)); + } + + @Test + public void testV1UpdateInventoryNotFound() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 9999, \"quantity\": 100}") + .when().put("/api/v1/inventory/999999") + .then() + .statusCode(404); + } + + // ==================== PATCH /api/v1/inventory/{id}/quantity Tests + // ==================== + + @Test + public void testV1UpdateQuantity() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 600}") + .when().patch("/api/v1/inventory/329199/quantity") + .then() + .statusCode(200) + .body("id", is(329199)) + .body("quantity", is(600)); + } + + @Test + public void testV1UpdateQuantityNotFound() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 100}") + .when().patch("/api/v1/inventory/999999/quantity") + .then() + .statusCode(404); + } + + @Test + public void testV1UpdateQuantityNegative() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": -1}") + .when().patch("/api/v1/inventory/329199/quantity") + .then() + .statusCode(400); + } + + // ==================== DELETE /api/v1/inventory/{id} Tests ==================== + + @Test + public void testV1DeleteInventory() { + // First create an item to delete + int createdId = given() + .contentType(ContentType.JSON) + .body("{\"productId\": 6001, \"quantity\": 50}") + .when().post("/api/v1/inventory") + .then() + .statusCode(201) + .extract().path("id"); + + // Then delete it + given() + .when().delete("/api/v1/inventory/" + createdId) + .then() + .statusCode(204); + + // Verify it's deleted + given() + .when().get("/api/v1/inventory/" + createdId) + .then() + .statusCode(404); + } + + @Test + public void testV1DeleteInventoryNotFound() { + given() + .when().delete("/api/v1/inventory/999999") + .then() + .statusCode(404); + } + + // ==================== Metrics Endpoint Tests ==================== + + @Test + public void testMetricsEndpoint() { + given() + .when().get("/q/metrics") + .then() + .statusCode(200); + } + + @Test + public void testMetricsHasInventoryCount() { + // First make a request to trigger metrics + given().when().get("/api/v1/inventory").then().statusCode(200); + + // Check metrics endpoint contains our custom metrics + given() + .when().get("/q/metrics") + .then() + .statusCode(200) + .body(containsString("inventory")); + } + + // ==================== Content-Type Tests ==================== + + @Test + public void testV1GetInventoryReturnsJson() { + given() + .when().get("/api/v1/inventory/329299") + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + // ==================== Health Check Tests ==================== + + @Test + public void testV1HealthEndpoint() { + given() + .when().get("/q/health") + .then() + .statusCode(200); + } +} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..3d72e05 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,24 @@ +# Test Configuration - extends main application.properties + +# H2 in-memory database for testing +quarkus.datasource.jdbc.url=jdbc:h2:mem:inventory-test;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1 +quarkus.datasource.db-kind=h2 +quarkus.hibernate-orm.database.generation=drop-and-create +quarkus.hibernate-orm.log.sql=false +quarkus.hibernate-orm.sql-load-script=import.sql + +# Disable security completely for tests +quarkus.smallrye-jwt.enabled=false + +# Override all security permissions - allow everything +quarkus.http.auth.permission.permit-all.paths=/* +quarkus.http.auth.permission.permit-all.policy=permit +quarkus.http.auth.permission.permit-all.methods=GET,POST,PUT,PATCH,DELETE + +# Disable Flyway for tests +quarkus.flyway.migrate-at-start=false + +# Reduce logging noise in tests +quarkus.log.console.level=WARN +quarkus.log.category."org.hibernate".level=WARN +quarkus.http.access-log.enabled=false From ea934877d31bb9d1d88e55e31f872f8d3c91bc9b Mon Sep 17 00:00:00 2001 From: manuelaidos123 Date: Mon, 9 Feb 2026 02:23:03 +0000 Subject: [PATCH 11/11] refactor(config): move fault tolerance config to annotations Removes explicit property definitions for fault tolerance (timeout, circuit breaker, retry) and the micrometer JVM binder. Fault tolerance settings are now managed via annotations in the code. --- src/main/resources/application.properties | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 45e1719..117af31 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -82,7 +82,6 @@ quarkus.micrometer.enabled=true quarkus.micrometer.export.prometheus.enabled=true quarkus.micrometer.binder-enabled-default=true quarkus.micrometer.binder.http-server.enabled=true -quarkus.micrometer.binder.jvm.enabled=true # Metrics endpoint path quarkus.micrometer.export.prometheus.path=/q/metrics @@ -90,21 +89,6 @@ quarkus.micrometer.export.prometheus.path=/q/metrics # =========================================== # Resilience Configuration (Fault Tolerance) # =========================================== -# Enable fault tolerance -quarkus.fault-tolerance.enabled=true - -# Default timeout settings -Timeout/enabled=true -Timeout/value=5000 - -# Circuit breaker defaults -CircuitBreaker/enabled=true -CircuitBreaker/failure-ratio=0.5 -CircuitBreaker/request-volume-threshold=10 -CircuitBreaker/delay=5000 -CircuitBreaker/success-threshold=3 - -# Retry defaults -Retry/enabled=true -Retry/max-retries=3 -Retry/delay=100 +# Note: Fault tolerance is enabled automatically when the extension is present +# Configuration is done via annotations in the code: +# - @Timeout, @CircuitBreaker, @Retry annotations