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))
)
);
}