diff --git a/pom.xml b/pom.xml index 3d156c5..4a4b12d 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,10 @@ io.quarkus quarkus-rest + + io.quarkus + quarkus-hibernate-validator + io.quarkus quarkus-junit diff --git a/src/main/java/de/schulung/quarkus/recipes/Ingredient.java b/src/main/java/de/schulung/quarkus/recipes/Ingredient.java index 39c75a3..27c6189 100644 --- a/src/main/java/de/schulung/quarkus/recipes/Ingredient.java +++ b/src/main/java/de/schulung/quarkus/recipes/Ingredient.java @@ -1,5 +1,9 @@ package de.schulung.quarkus.recipes; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + import lombok.Getter; import lombok.Setter; @@ -10,14 +14,18 @@ public class Ingredient { /** * The ingredient name. */ + @NotNull + @Size(min = 1, max = 100) private String name; /** * The quantity of the ingredient. */ + @Positive private double quantity; /** * The unit of the ingredient. */ + @NotNull private IngredientUnit unit; } diff --git a/src/main/java/de/schulung/quarkus/recipes/Recipe.java b/src/main/java/de/schulung/quarkus/recipes/Recipe.java index af6da5d..13058d3 100644 --- a/src/main/java/de/schulung/quarkus/recipes/Recipe.java +++ b/src/main/java/de/schulung/quarkus/recipes/Recipe.java @@ -1,7 +1,11 @@ package de.schulung.quarkus.recipes; import com.fasterxml.jackson.annotation.JsonProperty; - +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.Setter; @@ -20,14 +24,19 @@ public class Recipe { /** * The recipe name. */ + @NotNull + @Size(min = 1, max = 100) private String name; /** * URL of the recipe image, absolute or relative. */ + @Size(max = 255) + @Pattern(regexp = "^(/|https?://).+") private String img; /** * The number of servings. */ + @Min(1) private int servings; /** * The last edited timestamp (read-only, assigned by the server). @@ -37,6 +46,7 @@ public class Recipe { /** * Preparation time in minutes. */ + @Min(1) private int duration; /** * Difficulty level of the recipe. @@ -45,10 +55,14 @@ public class Recipe { /** * List of ingredients. */ - private List ingredients; + @NotNull + @Size(max = 100) + private List<@Valid @NotNull Ingredient> ingredients; /** * Preparation instructions. */ + @NotNull + @Size(max = 2000) private String preparation; } diff --git a/src/main/java/de/schulung/quarkus/recipes/RecipesResource.java b/src/main/java/de/schulung/quarkus/recipes/RecipesResource.java index 66e02fd..aa71ffe 100644 --- a/src/main/java/de/schulung/quarkus/recipes/RecipesResource.java +++ b/src/main/java/de/schulung/quarkus/recipes/RecipesResource.java @@ -1,5 +1,6 @@ package de.schulung.quarkus.recipes; +import jakarta.validation.Valid; import jakarta.ws.rs.*; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; @@ -26,6 +27,7 @@ public Collection getAllRecipes() { @POST public Response createRecipe( + @Valid Recipe recipe, @Context UriInfo uriInfo diff --git a/src/test/java/de/schulung/quarkus/recipes/RecipesResourceTests.java b/src/test/java/de/schulung/quarkus/recipes/RecipesResourceTests.java index 9c71c0d..b75a3f6 100644 --- a/src/test/java/de/schulung/quarkus/recipes/RecipesResourceTests.java +++ b/src/test/java/de/schulung/quarkus/recipes/RecipesResourceTests.java @@ -238,6 +238,7 @@ void shouldApplyDefaultDifficultyWhenNotProvided() { static Stream invalidRecipeRequests() { return Stream.of( + // --- unknown / read-only properties --- Arguments.of( "unknown property", """ @@ -286,6 +287,326 @@ static Stream invalidRecipeRequests() { "preparation": "Cook it." } """ + ), + // --- Recipe: name (required, minLength 1, maxLength 100) --- + Arguments.of( + "missing name", + """ + { + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "empty name", + """ + { + "name": "", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "name too long", + """ + { + "name": "%s", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """.formatted("A".repeat(101)) + ), + // --- Recipe: img (maxLength 255, pattern ^(/|https?://).+) --- + Arguments.of( + "img invalid pattern", + """ + { + "name": "Test Recipe", + "img": "not-a-valid-url", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "img too long", + """ + { + "name": "Test Recipe", + "img": "/%s", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """.formatted("a".repeat(255)) + ), + // --- Recipe: servings (required, minimum 1) --- + Arguments.of( + "missing servings", + """ + { + "name": "Test Recipe", + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "servings zero", + """ + { + "name": "Test Recipe", + "servings": 0, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + // --- Recipe: duration (required, minimum 1) --- + Arguments.of( + "missing duration", + """ + { + "name": "Test Recipe", + "servings": 4, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "duration zero", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 0, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + // --- Recipe: difficulty (enum: easy, medium, hard) --- + Arguments.of( + "invalid difficulty", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "impossible", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + // --- Recipe: ingredients (required) --- + Arguments.of( + "missing ingredients", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "preparation": "Cook it." + } + """ + ), + // --- Recipe: preparation (required, maxLength 2000) --- + Arguments.of( + "missing preparation", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ] + } + """ + ), + Arguments.of( + "preparation too long", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "%s" + } + """.formatted("A".repeat(2001)) + ), + // --- Ingredient: unit (required, enum) --- + Arguments.of( + "ingredient: missing unit", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "ingredient: invalid unit", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "bushels", "quantity": 2.0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + // --- Ingredient: quantity (required, exclusiveMinimum 0) --- + Arguments.of( + "ingredient: missing quantity", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "ingredient: quantity zero", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 0, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "ingredient: quantity negative", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": -1, "name": "Tomato"} + ], + "preparation": "Cook it." + } + """ + ), + // --- Ingredient: name (required, minLength 1, maxLength 100) --- + Arguments.of( + "ingredient: missing name", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "ingredient: empty name", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": ""} + ], + "preparation": "Cook it." + } + """ + ), + Arguments.of( + "ingredient: name too long", + """ + { + "name": "Test Recipe", + "servings": 4, + "duration": 30, + "difficulty": "easy", + "ingredients": [ + {"unit": "pieces", "quantity": 2.0, "name": "%s"} + ], + "preparation": "Cook it." + } + """.formatted("A".repeat(101)) ) ); }