diff --git a/pom.xml b/pom.xml index 209d9e14..dae89d75 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,11 @@ quarkus-hibernate-orm + + io.quarkus + quarkus-hibernate-validator + + io.quarkus @@ -103,7 +108,7 @@ provided 4.3.1 - + io.quarkus quarkus-logging-json @@ -136,8 +141,12 @@ 0.2.0 provided + + org.apache.commons + commons-lang3 + 3.12.0 + - @@ -165,7 +174,7 @@ org.testcontainers postgresql test - + @@ -225,7 +234,6 @@ - diff --git a/src/main/java/com/devonfw/quarkus/general/rest/exception/ApplicationBusinessException.java b/src/main/java/com/devonfw/quarkus/general/rest/exception/ApplicationBusinessException.java new file mode 100644 index 00000000..9040b69a --- /dev/null +++ b/src/main/java/com/devonfw/quarkus/general/rest/exception/ApplicationBusinessException.java @@ -0,0 +1,34 @@ +package com.devonfw.quarkus.general.rest.exception; + +public abstract class ApplicationBusinessException extends RuntimeException { + + public ApplicationBusinessException() { + + super(); + } + + public ApplicationBusinessException(String message) { + + super(message); + } + + public ApplicationBusinessException(String message, Exception e) { + + super(message, e); + } + + public boolean isTechnical() { + + return false; + } + + public String getCode() { + + return getClass().getSimpleName(); + } + + public Integer getStatusCode() { + + return null; + } +} diff --git a/src/main/java/com/devonfw/quarkus/general/rest/exception/InvalidParameterException.java b/src/main/java/com/devonfw/quarkus/general/rest/exception/InvalidParameterException.java new file mode 100644 index 00000000..76d9fd9e --- /dev/null +++ b/src/main/java/com/devonfw/quarkus/general/rest/exception/InvalidParameterException.java @@ -0,0 +1,20 @@ +package com.devonfw.quarkus.general.rest.exception; + +public class InvalidParameterException extends ApplicationBusinessException { + + public InvalidParameterException() { + + super(); + } + + public InvalidParameterException(String message) { + + super(message); + } + + @Override + public Integer getStatusCode() { + + return Integer.valueOf(422); + } +} diff --git a/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/AbstractExceptionMapper.java b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/AbstractExceptionMapper.java new file mode 100644 index 00000000..b98b33e5 --- /dev/null +++ b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/AbstractExceptionMapper.java @@ -0,0 +1,69 @@ +package com.devonfw.quarkus.general.rest.exception.mapper; + +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +import lombok.extern.slf4j.Slf4j; + +/** + * Abstract super class for all specific exception mapper. To override the default ExceptionMapper of RESTEasy, own + * ExceptionMapper for specific exceptions (e.g. NotFoundException) have to be created. Just using + * ExcecptionMapper will not work, because the RESTEasy mappers are then more specific. + * + * + * @see Quarkus Issue 7883 + * + */ +@Slf4j +public abstract class AbstractExceptionMapper { + + protected boolean exposeInternalErrorDetails = false; + + @Context + UriInfo uriInfo; + + protected void logError(Exception exception) { + + log.error("Exception:{},URL:{},ERROR:{}", exception.getClass().getCanonicalName(), this.uriInfo.getRequestUri(), + exception.getMessage()); + } + + protected Response createResponse(int status, String errorCode, Exception exception) { + + Map jsonMap = new HashMap<>(); + jsonMap.put("code", errorCode); + if (this.exposeInternalErrorDetails) { + jsonMap.put("message", getExposedErrorDetails(exception)); + } else { + jsonMap.put("message", exception.getMessage()); + } + jsonMap.put("uri", this.uriInfo.getPath()); + jsonMap.put("uuid", UUID.randomUUID()); + jsonMap.put("timestamp", ZonedDateTime.now().toString()); + return Response.status(status).type(MediaType.APPLICATION_JSON).entity(jsonMap).build(); + } + + protected String getExposedErrorDetails(Throwable error) { + + StringBuilder buffer = new StringBuilder(); + Throwable e = error; + while (e != null) { + if (buffer.length() > 0) { + buffer.append(System.lineSeparator()); + } + buffer.append(e.getClass().getSimpleName()); + buffer.append(": "); + buffer.append(e.getLocalizedMessage()); + e = e.getCause(); + } + return buffer.toString(); + } + +} diff --git a/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/NotFoundExceptionMapper.java b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/NotFoundExceptionMapper.java new file mode 100644 index 00000000..29c33d81 --- /dev/null +++ b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/NotFoundExceptionMapper.java @@ -0,0 +1,19 @@ +package com.devonfw.quarkus.general.rest.exception.mapper; + +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +@Provider +public class NotFoundExceptionMapper extends AbstractExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(NotFoundException exception) { + + logError(exception); + + return createResponse(Status.NOT_FOUND.getStatusCode(), exception.getClass().getSimpleName(), exception); + } +} diff --git a/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/RuntimeExceptionMapper.java b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/RuntimeExceptionMapper.java new file mode 100644 index 00000000..051626cf --- /dev/null +++ b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/RuntimeExceptionMapper.java @@ -0,0 +1,46 @@ +package com.devonfw.quarkus.general.rest.exception.mapper; + +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +import com.devonfw.quarkus.general.rest.exception.ApplicationBusinessException; + +@Provider +public class RuntimeExceptionMapper extends AbstractExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(RuntimeException exception) { + + logError(exception); + + if (exception instanceof ApplicationBusinessException) { + return createResponse((ApplicationBusinessException) exception); + } else if (exception instanceof WebApplicationException) { + return createResponse((WebApplicationException) exception); + } + + return createResponse(exception); + } + + private Response createResponse(ApplicationBusinessException exception) { + + int status = exception.getStatusCode() == null ? Status.BAD_REQUEST.getStatusCode() : exception.getStatusCode(); + return createResponse(status, exception.getCode(), exception); + } + + private Response createResponse(WebApplicationException exception) { + + Status status = Status.fromStatusCode(exception.getResponse().getStatus()); + return createResponse(status.getStatusCode(), exception.getClass().getSimpleName(), exception); + } + + private Response createResponse(Exception exception) { + + return createResponse(Status.INTERNAL_SERVER_ERROR.getStatusCode(), exception.getClass().getSimpleName(), + exception); + } + +} diff --git a/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/UnauthorizedExceptionMapper.java b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/UnauthorizedExceptionMapper.java new file mode 100644 index 00000000..8097eed4 --- /dev/null +++ b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/UnauthorizedExceptionMapper.java @@ -0,0 +1,21 @@ +package com.devonfw.quarkus.general.rest.exception.mapper; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +import io.quarkus.security.UnauthorizedException; + +@Provider +public class UnauthorizedExceptionMapper extends AbstractExceptionMapper + implements ExceptionMapper { + + @Override + public Response toResponse(UnauthorizedException exception) { + + logError(exception); + + return createResponse(Status.UNAUTHORIZED.getStatusCode(), exception.getClass().getSimpleName(), exception); + } +} diff --git a/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/ValidationExceptionMapper.java b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/ValidationExceptionMapper.java new file mode 100644 index 00000000..6d20a7ee --- /dev/null +++ b/src/main/java/com/devonfw/quarkus/general/rest/exception/mapper/ValidationExceptionMapper.java @@ -0,0 +1,19 @@ +package com.devonfw.quarkus.general.rest.exception.mapper; + +import javax.validation.ValidationException; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +@Provider +public class ValidationExceptionMapper extends AbstractExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(ValidationException exception) { + + logError(exception); + + return createResponse(Status.BAD_REQUEST.getStatusCode(), exception.getClass().getSimpleName(), exception); + } +} diff --git a/src/main/java/com/devonfw/quarkus/productmanagement/domain/repo/ProductFragmentImpl.java b/src/main/java/com/devonfw/quarkus/productmanagement/domain/repo/ProductFragmentImpl.java index f657c346..8a253126 100644 --- a/src/main/java/com/devonfw/quarkus/productmanagement/domain/repo/ProductFragmentImpl.java +++ b/src/main/java/com/devonfw/quarkus/productmanagement/domain/repo/ProductFragmentImpl.java @@ -1,6 +1,5 @@ package com.devonfw.quarkus.productmanagement.domain.repo; -import static com.devonfw.quarkus.productmanagement.utils.StringUtils.isEmpty; import static java.util.Objects.isNull; import java.util.ArrayList; @@ -9,6 +8,7 @@ import javax.inject.Inject; import javax.persistence.EntityManager; +import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -30,7 +30,7 @@ public Page findByCriteria(ProductSearchCriteriaDto searchCriteri QProductEntity product = QProductEntity.productEntity; List predicates = new ArrayList<>(); - if (!isEmpty(searchCriteria.getTitle())) { + if (!StringUtils.isEmpty(searchCriteria.getTitle())) { predicates.add(product.title.eq(searchCriteria.getTitle())); } if (!isNull(searchCriteria.getPrice())) { diff --git a/src/main/java/com/devonfw/quarkus/productmanagement/domain/repo/ProductRepository.java b/src/main/java/com/devonfw/quarkus/productmanagement/domain/repo/ProductRepository.java index 39a68d4f..a5664aa1 100644 --- a/src/main/java/com/devonfw/quarkus/productmanagement/domain/repo/ProductRepository.java +++ b/src/main/java/com/devonfw/quarkus/productmanagement/domain/repo/ProductRepository.java @@ -1,13 +1,13 @@ package com.devonfw.quarkus.productmanagement.domain.repo; import org.springframework.data.domain.Page; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; import com.devonfw.quarkus.productmanagement.domain.model.ProductEntity; -public interface ProductRepository extends CrudRepository, ProductFragment { +public interface ProductRepository extends JpaRepository, ProductFragment { @Query("select a from ProductEntity a where title = :title") ProductEntity findByTitle(@Param("title") String title); diff --git a/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/ProductRestService.java b/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/ProductRestService.java index 42c2220a..5e85985b 100644 --- a/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/ProductRestService.java +++ b/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/ProductRestService.java @@ -1,30 +1,30 @@ package com.devonfw.quarkus.productmanagement.rest.v1; -import static com.devonfw.quarkus.productmanagement.utils.StringUtils.isEmpty; import static javax.ws.rs.core.Response.created; -import static javax.ws.rs.core.Response.status; import java.util.Optional; import javax.inject.Inject; +import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; +import javax.ws.rs.NotFoundException; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; -import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriInfo; +import org.apache.commons.lang3.StringUtils; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.springframework.data.domain.Page; +import com.devonfw.quarkus.general.rest.exception.InvalidParameterException; import com.devonfw.quarkus.productmanagement.domain.model.ProductEntity; import com.devonfw.quarkus.productmanagement.domain.repo.ProductRepository; import com.devonfw.quarkus.productmanagement.rest.v1.mapper.ProductMapper; @@ -56,11 +56,7 @@ public Page getAllOrderedByTitle() { } @POST - public Response createNewProduct(ProductDto product) { - - if (isEmpty(product.getTitle())) { - throw new WebApplicationException("Title was not set on request.", 400); - } + public Response createNewProduct(@Valid ProductDto product) { ProductEntity productEntity = this.productRepository.save(this.productMapper.map(product)); @@ -83,11 +79,15 @@ public Page findProducts(ProductSearchCriteriaDto searchCriteria) { @Path("{id}") public ProductDto getProductById(@Parameter(description = "Product unique id") @PathParam("id") String id) { + if (!StringUtils.isNumeric(id)) { + throw new InvalidParameterException("Unable to parse ID: " + id); + } + Optional product = this.productRepository.findById(Long.valueOf(id)); - if (product.isPresent()) { - return this.productMapper.map(product.get()); + if (!product.isPresent()) { + throw new NotFoundException(); } - return null; + return this.productMapper.map(product.get()); } @GET @@ -99,10 +99,16 @@ public ProductDto getProductByTitle(@PathParam("title") String title) { @DELETE @Path("{id}") - public Response deleteProductById(@Parameter(description = "Product unique id") @PathParam("id") String id) { + public void deleteProductById(@Parameter(description = "Product unique id") @PathParam("id") String id) { - this.productRepository.deleteById(Long.valueOf(id)); - return status(Status.NO_CONTENT.getStatusCode()).build(); - } + if (!StringUtils.isNumeric(id)) { + throw new InvalidParameterException("Unable to parse ID: " + id); + } + try { + this.productRepository.deleteById(Long.valueOf(id)); + } catch (IllegalArgumentException e) { + throw new NotFoundException(); + } + } } diff --git a/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/model/ProductDto.java b/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/model/ProductDto.java index 1f11e7c7..064be3f6 100644 --- a/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/model/ProductDto.java +++ b/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/model/ProductDto.java @@ -2,28 +2,21 @@ import java.math.BigDecimal; -import org.eclipse.microprofile.openapi.annotations.media.Schema; +import javax.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor public class ProductDto extends AbstractDto { - @Schema(nullable = false, description = "Product title", minLength = 3, maxLength = 500) + @Size(min = 3, max = 500) private String title; - @Schema(description = "Product description", minLength = 3, maxLength = 500) + @Size(min = 3, max = 4000) private String description; - @Schema(description = "Product price") private BigDecimal price; } diff --git a/src/main/java/com/devonfw/quarkus/productmanagement/utils/StringUtils.java b/src/main/java/com/devonfw/quarkus/productmanagement/utils/StringUtils.java deleted file mode 100644 index effc93ca..00000000 --- a/src/main/java/com/devonfw/quarkus/productmanagement/utils/StringUtils.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.devonfw.quarkus.productmanagement.utils; - -public class StringUtils { - public static boolean isEmpty(String str) { - - return (str == null || "".equals(str)); - } -} diff --git a/src/test/java/com/devonfw/demoquarkus/rest/v1/ProductRestServiceTest.java b/src/test/java/com/devonfw/demoquarkus/rest/v1/ProductRestServiceTest.java index dc3a7325..8fc99ba5 100644 --- a/src/test/java/com/devonfw/demoquarkus/rest/v1/ProductRestServiceTest.java +++ b/src/test/java/com/devonfw/demoquarkus/rest/v1/ProductRestServiceTest.java @@ -37,13 +37,6 @@ void getAll() { @Test @Order(2) - void getNonExistingTest() { - - given().when().contentType(MediaType.APPLICATION_JSON).get("/product/v1/99999").then().log().all().statusCode(204); - } - - @Test - @Order(3) void createNewProduct() { ProductDto product = new ProductDto(); @@ -59,7 +52,7 @@ void createNewProduct() { } @Test - @Order(4) + @Order(3) public void testGetById() { given().when().log().all().contentType(MediaType.APPLICATION_JSON).get("/product/v1/1").then().statusCode(200) @@ -68,11 +61,45 @@ public void testGetById() { } @Test - @Order(5) + @Order(4) public void deleteById() { given().when().log().all().contentType(MediaType.APPLICATION_JSON).delete("/product/v1/1").then().statusCode(204); - given().when().log().all().contentType(MediaType.APPLICATION_JSON).get("/product/v1/1").then().statusCode(204); + + // after deletion it should be deleted + given().when().log().all().contentType(MediaType.APPLICATION_JSON).get("/product/v1/1").then().statusCode(404); + + // delete again should fail + given().when().log().all().contentType(MediaType.APPLICATION_JSON).delete("/product/v1/1").then().statusCode(404); + } + + @Test + @Order(5) + void businessExceptionTest() { + + given().when().contentType(MediaType.APPLICATION_JSON).get("/product/v1/doesnotexist").then().log().all() + .statusCode(422).extract().response(); + } + + @Test + @Order(6) + void notFoundExceptionTest() { + + given().when().contentType(MediaType.APPLICATION_JSON).get("/product/v1/0").then().log().all().statusCode(404) + .extract().response(); + } + + @Test + @Order(7) + void validationExceptionTest() { + + // Create a product that does not match the validation rules + ProductDto product = new ProductDto(); + product.setTitle(""); + + given().when().body(product).contentType(MediaType.APPLICATION_JSON).post("/product/v1").then().log().all() + .statusCode(400); + } } \ No newline at end of file