From fb49500ad18b35dd261320eff3b6ccf11e6beb9a Mon Sep 17 00:00:00 2001 From: evebrnd Date: Mon, 27 Apr 2026 17:03:18 +0200 Subject: [PATCH 01/15] feat(propertyRules): add validation of property rules against type --- docs/src/static/swagger.yaml | 58 +- .../domain/constant/ValidationMessages.java | 19 + .../EntityTemplateAlreadyExistsException.java | 2 +- ...ityTemplateNameAlreadyExistsException.java | 2 +- .../PropertyRulesConflictException.java | 35 + .../{ => entity_template}/EntityService.java | 2 +- .../RelationService.java | 2 +- .../EntityTemplateService.java | 42 +- .../entity_template/PropertyRulesService.java | 195 ++++++ .../api/controller/EntityController.java | 2 +- .../controller/EntityTemplateController.java | 10 +- .../api/handler/ApiExceptionHandler.java | 13 + .../api/mapper/entity/EntityDtoOutMapper.java | 6 +- .../PropertyRulesServiceTest.java | 646 ++++++++++++++++++ .../EntityTemplateControllerTest.java | 13 + ...mplateWithoutRelationsDefinitions_201.json | 4 +- .../v1/postEntityTemplate_201.json | 4 +- .../postEntityTemplate_400_invalid_rules.json | 23 + .../v1/putEntityTemplate_200.json | 3 +- 19 files changed, 1044 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java rename src/main/java/com/decathlon/idp_core/domain/service/{ => entity_template}/EntityService.java (98%) rename src/main/java/com/decathlon/idp_core/domain/service/{ => entity_template}/RelationService.java (96%) rename src/main/java/com/decathlon/idp_core/domain/service/{ => entity_template/entity_template}/EntityTemplateService.java (87%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/PropertyRulesService.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java create mode 100644 src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_invalid_rules.json diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 37c9d48..70dcd1b 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -250,8 +250,11 @@ components: description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoIn' - description: Property validation rules + oneOf: + - $ref: '#/components/schemas/PropertyRulesDtoInForString' + - $ref: '#/components/schemas/PropertyRulesDtoInForNumber' + - $ref: '#/components/schemas/PropertyRulesDtoInForBoolean' + description: Property validation rules (must match property type) required: - description - name @@ -299,6 +302,57 @@ components: format: int32 description: Minimum value for numeric properties example: 0 + PropertyRulesDtoInForString: + type: object + description: Validation rules for STRING property type. Allowed rules - format, enum_values, regex, max_length, min_length. Constraint - 0 ≤ min_length ≤ max_length + properties: + format: + type: string + description: Property format validation (for STRING only) + enum: + - URL + - EMAIL + example: EMAIL + enum_values: + type: array + description: Enumeration values for enum properties (for STRING only) + example: + - ACTIVE + - INACTIVE + items: + type: string + regex: + type: string + description: Regular expression pattern for validation (for STRING only) + example: ^[a-zA-Z0-9]+$ + max_length: + type: integer + format: int32 + description: Maximum length for string properties + example: 255 + min_length: + type: integer + format: int32 + description: Minimum length for string properties (must be ≤ max_length and ≥ 0) + example: 1 + PropertyRulesDtoInForNumber: + type: object + description: Validation rules for NUMBER property type. Allowed rules - max_value, min_value. Constraint - min_value ≤ max_value + properties: + max_value: + type: integer + format: int32 + description: Maximum value for numeric properties + example: 100 + min_value: + type: integer + format: int32 + description: Minimum value for numeric properties (must be ≤ max_value) + example: 0 + PropertyRulesDtoInForBoolean: + type: object + nullable: true + description: Boolean properties do not accept any rules. This field must be null or empty RelationDefinitionDtoIn: type: object description: Input DTO for creating or updating a relation definition diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 8d30dda..7c8b15b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -27,4 +27,23 @@ public class ValidationMessages { public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; + + // Property Rules validation messages - templates and specific constraints + public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; + public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; + public static final String PROPERTY_RULES_MIN_VALUE_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules; rules field must be null or empty"; + + // Helper method to construct rule-not-allowed message + public static String ruleNotAllowed(String rule, String propertyType) { + return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE + .replace("{rule}", rule) + .replace("{type}", propertyType); + } + + // Helper method to construct min/max constraint violation message + public static String minMaxConstraintViolated(String constraint) { + return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED + .replace("{constraint}", constraint); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java index aff0ad4..be97a82 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java @@ -3,7 +3,7 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.entity_template.EntityTemplateService; /// Exception thrown when attempting to create an [EntityTemplate] with an identifier that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java index 885bc83..4399dc2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java @@ -3,7 +3,7 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_NAME_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.entity_template.EntityTemplateService; /// Exception thrown when attempting to create or update an [EntityTemplate] with a name that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java new file mode 100644 index 0000000..3841985 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java @@ -0,0 +1,35 @@ +package com.decathlon.idp_core.domain.exception; + +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Domain exception for property rule validation violations. +/// +/// **Business purpose:** Represents the business rule violation when property rules +/// conflict with their assigned property type. This ensures data integrity +/// by preventing invalid rule configurations before persistence. +/// +/// **Exception design rationale:** +/// - Reports specific property name and type for debugging clarity +/// - Includes detailed violation message explaining the constraint failure +/// - Domain-level exception keeps business logic separate from HTTP concerns +/// +/// **Usage patterns:** +/// - Property template creation with invalid rules +/// - Template updates introducing rule conflicts +/// - Batch validation of property definitions +public class PropertyRulesConflictException extends RuntimeException { + + /// Constructs a new exception for rule type conflict. + /// + /// **Why this exists:** Provides standardized error message format when + /// a rule parameter (format, enum_values, regex, etc.) is not supported + /// for the given property type. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + + ": " + violationMessage); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityService.java similarity index 98% rename from src/main/java/com/decathlon/idp_core/domain/service/EntityService.java rename to src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityService.java index b09cff9..30937d5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityService.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.entity_template; import java.util.List; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/RelationService.java similarity index 96% rename from src/main/java/com/decathlon/idp_core/domain/service/RelationService.java rename to src/main/java/com/decathlon/idp_core/domain/service/entity_template/RelationService.java index bdc934a..a221f21 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/RelationService.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.entity_template; import java.util.List; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/EntityTemplateService.java similarity index 87% rename from src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java rename to src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/EntityTemplateService.java index d9cf376..a1fd29d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/EntityTemplateService.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.entity_template.entity_template; import java.util.ArrayList; import java.util.List; @@ -79,6 +79,7 @@ public EntityTemplate getEntityTemplateByIdentifier(String identifier) { /// **Business rules enforced:** /// - If `identifier` is provided it must not already exist in the system. /// - If `name` is provided it must not already exist in the system. + /// - Validation of property rules according to their defined constraints. /// /// @param entityTemplate validated template to create and persist /// @return the persisted template with generated identifiers @@ -94,6 +95,7 @@ public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) entityTemplateRepositoryPort.existsByName(entityTemplate.name())) { throw new EntityTemplateNameAlreadyExistsException(entityTemplate.name()); } + validateTemplateRules(entityTemplate); return entityTemplateRepositoryPort.save(entityTemplate); } @@ -112,41 +114,51 @@ public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) /// - *Matched by name* → existing ID is preserved, other fields are overwritten. /// - *Not matched* → treated as a new definition (no ID yet). /// - *Missing from update* → removed (handled downstream by the persistence adapter). + /// - Validation of property rules according to their defined constraints. /// /// @param identifier current business identifier of the template to update - /// @param updatedTemplate validated template carrying the desired state + /// @param entityTemplate validated template carrying the desired state /// @return the persisted template after merge, with generated or preserved identifiers /// @throws EntityTemplateNotFoundException when no template matches `identifier` /// @throws EntityTemplateAlreadyExistsException when renaming would cause a duplicate @Transactional - public EntityTemplate putEntityTemplate(String identifier, @Valid EntityTemplate updatedTemplate) { + public EntityTemplate updateEntityTemplate(String identifier, @Valid EntityTemplate entityTemplate) { EntityTemplate existingTemplate = getEntityTemplateByIdentifier(identifier); - if (!identifier.equals(updatedTemplate.identifier()) && - entityTemplateRepositoryPort.existsByIdentifier(updatedTemplate.identifier())) { - throw new EntityTemplateAlreadyExistsException(updatedTemplate.identifier()); + if (!identifier.equals(entityTemplate.identifier()) && + entityTemplateRepositoryPort.existsByIdentifier(entityTemplate.identifier())) { + throw new EntityTemplateAlreadyExistsException(entityTemplate.identifier()); } - if (updatedTemplate.name() != null && - !Objects.equals(existingTemplate.name(), updatedTemplate.name()) && - entityTemplateRepositoryPort.existsByName(updatedTemplate.name())) { - throw new EntityTemplateNameAlreadyExistsException(updatedTemplate.name()); + if (entityTemplate.name() != null && + !Objects.equals(existingTemplate.name(), entityTemplate.name()) && + entityTemplateRepositoryPort.existsByName(entityTemplate.name())) { + throw new EntityTemplateNameAlreadyExistsException(entityTemplate.name()); } EntityTemplate mergedTemplate = new EntityTemplate( existingTemplate.id(), - updatedTemplate.identifier(), - updatedTemplate.name(), - updatedTemplate.description(), + entityTemplate.identifier(), + entityTemplate.name(), + entityTemplate.description(), mergePropertyDefinitions(existingTemplate.propertiesDefinitions(), - updatedTemplate.propertiesDefinitions()), + entityTemplate.propertiesDefinitions()), mergeRelationDefinitions(existingTemplate.relationsDefinitions(), - updatedTemplate.relationsDefinitions()) + entityTemplate.relationsDefinitions()) ); + validateTemplateRules(mergedTemplate); return entityTemplateRepositoryPort.save(mergedTemplate); } + private void validateTemplateRules(@Valid EntityTemplate entityTemplate) { + if (entityTemplate.propertiesDefinitions() != null) { + for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { + PropertyRulesService.validatePropertyRules(property); + } + } + } + private List mergePropertyDefinitions( List existing, List updated) { diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/PropertyRulesService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/PropertyRulesService.java new file mode 100644 index 0000000..d604f42 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/PropertyRulesService.java @@ -0,0 +1,195 @@ +package com.decathlon.idp_core.domain.service.entity_template.entity_template; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_BOOLEAN_NOT_ALLOWED; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_VALUE_NON_NEGATIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.minMaxConstraintViolated; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; + +import com.decathlon.idp_core.domain.exception.PropertyRulesConflictException; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Domain service for validating property rule compatibility with property types. +/// +/// Provides pure validation functions ensuring property rules conform to their assigned +/// data types. Enforces business invariants around which rules apply to each type and +/// validates numeric constraints (min ≤ max). +/// +/// **Business rules:** +/// - STRING: Allows format, enum_values, regex, max_length, min_length. Rejects numeric rules. +/// - NUMBER: Allows max_value, min_value. Rejects string and format rules. +/// - BOOLEAN: Rejects all rules; rules field must be null or empty. +/// +/// **Design principles:** +/// - Pure functions: No side effects, no state mutation +/// - Single responsibility: Only validates rules, doesn't throw or log +/// - Testable: Can be tested independently without Spring context +public final class PropertyRulesService { + + private PropertyRulesService() { + // Utility class - prevent instantiation + } + + /// Validates property rules are compatible with the property's data type. + /// + /// **Contract:** Performs comprehensive validation including: + /// - Rule type compatibility with property type + /// - Numeric constraint ordering (min ≤ max) + /// - Boolean properties reject all rules + /// + /// @param propertyDefinition the property definition containing type and rules + /// @throws PropertyRulesConflictException when rules violate business invariants + public static void validatePropertyRules(PropertyDefinition propertyDefinition) { + if (propertyDefinition.rules() == null) { + return; + } + + PropertyRules rules = propertyDefinition.rules(); + PropertyType type = propertyDefinition.type(); + + switch (type) { + case STRING: + validateStringPropertyRules(propertyDefinition.name(), rules); + break; + case NUMBER: + validateNumberPropertyRules(propertyDefinition.name(), rules); + break; + case BOOLEAN: + validateBooleanPropertyRules(propertyDefinition.name(), rules); + break; + default: + throw new IllegalArgumentException("Unknown property type: " + type); + } + } + + /// Validates rules for STRING property type. + /// + /// **Allowed rules:** format, enum_values, regex, max_length, min_length + /// **Rejected rules:** max_value, min_value (numeric) + /// **Constraints:** 0 ≤ min_length ≤ max_length + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyRulesConflictException when numeric rules are present + /// or min/max length constraints are violated + private static void validateStringPropertyRules(String propertyName, PropertyRules rules) { + // Reject numeric rules for STRING type + if (rules.maxValue() != null || rules.minValue() != null) { + String violation = rules.maxValue() != null ? + "Numeric rule maxValue is not allowed for STRING properties" : + "Numeric rule minValue is not allowed for STRING properties"; + throw new PropertyRulesConflictException(propertyName, PropertyType.STRING, violation); + } + + // Validate min_length and max_length constraints + if (rules.minLength() != null && rules.maxLength() != null) { + if (rules.minLength() > rules.maxLength()) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.STRING, + minMaxConstraintViolated("length") + ); + } + } + + // Validate min_length is non-negative + if (rules.minLength() != null && rules.minLength() < 0) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_MIN_VALUE_NON_NEGATIVE + ); + } + } + + /// Validates rules for NUMBER property type. + /// + /// **Allowed rules:** max_value, min_value + /// **Rejected rules:** format, enum_values, regex, max_length, min_length (string) + /// **Constraints:** min_value ≤ max_value + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyRulesConflictException when string rules are present + /// or min/max value constraints are violated + private static void validateNumberPropertyRules(String propertyName, PropertyRules rules) { + // Reject string-related rules for NUMBER type + if (rules.format() != null) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed("format", "NUMBER") + ); + } + + if (rules.enumValues() != null && !rules.enumValues().isEmpty()) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed("enum_values", "NUMBER") + ); + } + + if (rules.regex() != null && !rules.regex().isBlank()) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed("regex", "NUMBER") + ); + } + + if (rules.minLength() != null) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed("min_length", "NUMBER") + ); + } + + if (rules.maxLength() != null) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed("max_length", "NUMBER") + ); + } + + // Validate min_value and max_value constraints + if (rules.minValue() != null && rules.maxValue() != null) { + if (rules.minValue() > rules.maxValue()) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.NUMBER, + minMaxConstraintViolated("value") + ); + } + } + } + + /// Validates rules for BOOLEAN property type. + /// + /// **Allowed rules:** None + /// **Rejected rules:** All rules must be null or empty + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyRulesConflictException when any rule is set for BOOLEAN + private static void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + // Check if any rule field is set + if (rules.format() != null || + (rules.enumValues() != null && !rules.enumValues().isEmpty()) || + (rules.regex() != null && !rules.regex().isBlank()) || + rules.maxLength() != null || + rules.minLength() != null || + rules.maxValue() != null || + rules.minValue() != null) { + + throw new PropertyRulesConflictException( + propertyName, + PropertyType.BOOLEAN, + PROPERTY_RULES_BOOLEAN_NOT_ALLOWED + ); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 3f94e44..3195e60 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -35,7 +35,7 @@ import org.springframework.web.bind.annotation.RestController; import com.decathlon.idp_core.domain.model.entity.Entity; -import com.decathlon.idp_core.domain.service.EntityService; +import com.decathlon.idp_core.domain.service.entity_template.EntityService; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java index 0e6cb62..28eebf5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java @@ -44,7 +44,7 @@ import org.springframework.web.bind.annotation.RestController; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.entity_template.EntityTemplateService; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.TemplatePageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateUpdateDtoIn; @@ -138,8 +138,8 @@ public EntityTemplateDtoOut getTemplateByIdentifier(@PathVariable String identif @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) @PostMapping @ResponseStatus(CREATED) - public EntityTemplateDtoOut createTemplate(@Valid @RequestBody EntityTemplateCreateDtoIn templateDto) { - EntityTemplate entityTemplate = entityTemplateService.createEntityTemplate(templateMapper.fromDtoToEntityTemplate(templateDto)); + public EntityTemplateDtoOut createTemplate(@Valid @RequestBody EntityTemplateCreateDtoIn entityTemplateCreateDtoIn) { + EntityTemplate entityTemplate = entityTemplateService.createEntityTemplate(templateMapper.fromDtoToEntityTemplate(entityTemplateCreateDtoIn)); return templateMapper.fromEntityTemplatetoDto(entityTemplate); } @@ -155,8 +155,8 @@ public EntityTemplateDtoOut createTemplate(@Valid @RequestBody EntityTemplateCre @PutMapping("/{identifier}") public EntityTemplateDtoOut updateTemplate( @PathVariable(name = "identifier") String identifier, - @RequestBody @Valid EntityTemplateUpdateDtoIn updatedTemplateDto) { - EntityTemplate entityTemplate = entityTemplateService.putEntityTemplate(identifier, templateMapper.fromPutDtoToEntityTemplate(identifier, updatedTemplateDto)); + @RequestBody @Valid EntityTemplateUpdateDtoIn entityTemplateUpdateDtoIn) { + EntityTemplate entityTemplate = entityTemplateService.updateEntityTemplate(identifier, templateMapper.fromPutDtoToEntityTemplate(identifier, entityTemplateUpdateDtoIn)); return templateMapper.fromEntityTemplatetoDto(entityTemplate); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index a9d6f59..f6eb713 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -5,6 +5,7 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; +import com.decathlon.idp_core.domain.exception.PropertyRulesConflictException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -83,6 +84,18 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } + /// Handles domain exception for wrong entity template property rules. + /// + /// **HTTP mapping:** Maps domain PropertyRulesConflictException to HTTP 400 + /// status indicating validation error for wrong property rules. + @ExceptionHandler(PropertyRulesConflictException.class) + public ResponseEntity handleWrongPropertyRulesException( + PropertyRulesConflictException ex) { + log.warn("Wrong Entity template property rules: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + /// Handles Bean Validation constraint violations from domain model validation. /// /// **Error aggregation:** Combines multiple constraint violation messages into diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 2c295a3..d7500a7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -20,9 +20,9 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import com.decathlon.idp_core.domain.service.EntityService; -import com.decathlon.idp_core.domain.service.EntityTemplateService; -import com.decathlon.idp_core.domain.service.RelationService; +import com.decathlon.idp_core.domain.service.entity_template.EntityService; +import com.decathlon.idp_core.domain.service.entity_template.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.RelationService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java new file mode 100644 index 0000000..0082199 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java @@ -0,0 +1,646 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.UUID; + +import com.decathlon.idp_core.domain.service.entity_template.entity_template.PropertyRulesService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.PropertyRulesConflictException; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyFormat; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +@DisplayName("PropertyRulesService Tests") +class PropertyRulesServiceTest { + + @Nested + @DisplayName("STRING Property Type") + class StringPropertyTypeTests { + + @Test + @DisplayName("Happy path: STRING with format and max_length rules") + void testStringWithValidRules() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + null, + null, + 255, + 1, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "email", + "Email address", + PropertyType.STRING, + true, + rules + ); + + assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with min_length and max_length") + void testStringWithLengthConstraints() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 100, + 10, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "description", + "A description", + PropertyType.STRING, + false, + rules + ); + + assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with enum_values") + void testStringWithEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("ACTIVE", "INACTIVE"), + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "status", + "Status", + PropertyType.STRING, + true, + rules + ); + + assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with regex pattern") + void testStringWithRegex() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "^[a-zA-Z0-9]+$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "username", + "Username", + PropertyType.STRING, + true, + rules + ); + + assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Error: STRING with numeric max_value rule") + void testStringRejectsMaxValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 100, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "name", + "Name", + PropertyType.STRING, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("name")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: STRING with numeric min_value rule") + void testStringRejectsMinValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + null, + 0 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "counter", + "Counter", + PropertyType.STRING, + false, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("counter")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: STRING with min_length > max_length") + void testStringWithInvalidLengthConstraints() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 50, + 100, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("min_length")); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: STRING with negative min_length") + void testStringWithNegativeMinLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 255, + -1, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("min_length")); + assertTrue(ex.getMessage().contains("0")); + } + } + + @Nested + @DisplayName("NUMBER Property Type") + class NumberPropertyTypeTests { + + @Test + @DisplayName("Happy path: NUMBER with min_value and max_value") + void testNumberWithValidRules() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 1000, + 0 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "score", + "Numeric score", + PropertyType.NUMBER, + true, + rules + ); + + assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with only max_value") + void testNumberWithOnlyMaxValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 100, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "percentage", + "Percentage value", + PropertyType.NUMBER, + false, + rules + ); + + assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Error: NUMBER with format rule") + void testNumberRejectsFormat() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + null, + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "value", + "Numeric value", + PropertyType.NUMBER, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("value")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertTrue(ex.getMessage().contains("format")); + } + + @Test + @DisplayName("Error: NUMBER with enum_values rule") + void testNumberRejectsEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("1", "2", "3"), + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "category", + "Category", + PropertyType.NUMBER, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: NUMBER with regex rule") + void testNumberRejectsRegex() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "^[0-9]+$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "id", + "ID", + PropertyType.NUMBER, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("regex")); + } + + @Test + @DisplayName("Error: NUMBER with min_length rule") + void testNumberRejectsMinLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + 5, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.NUMBER, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("min_length")); + } + + @Test + @DisplayName("Error: NUMBER with max_length rule") + void testNumberRejectsMaxLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 50, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.NUMBER, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: NUMBER with min_value > max_value") + void testNumberWithInvalidValueConstraints() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 0, + 100 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "range", + "A range", + PropertyType.NUMBER, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("min_value")); + assertTrue(ex.getMessage().contains("max_value")); + } + } + + @Nested + @DisplayName("BOOLEAN Property Type") + class BooleanPropertyTypeTests { + + @Test + @DisplayName("Happy path: BOOLEAN with no rules") + void testBooleanWithNullRules() { + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "active", + "Is active", + PropertyType.BOOLEAN, + true, + null + ); + + assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Error: BOOLEAN with format rule") + void testBooleanRejectsFormat() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + null, + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "enabled", + "Enabled", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + assertTrue(ex.getMessage().contains("rules")); + } + + @Test + @DisplayName("Error: BOOLEAN with enum_values rule") + void testBooleanRejectsEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("true", "false"), + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "flag", + "A flag", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with regex rule") + void testBooleanRejectsRegex() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + ".*", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "test", + "Test", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with min_value rule") + void testBooleanRejectsMinValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + null, + 0 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "valid", + "Valid", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with max_value rule") + void testBooleanRejectsMaxValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 1, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "valid", + "Valid", + PropertyType.BOOLEAN, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index b6c873c..63f0cc1 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -431,6 +431,19 @@ void postTemplate_400_property_format_invalid_enum() throws Exception { assertNotNull(res, "Test executed successfully"); } + /// Tests the POST /api/v1/entity-templates endpoint with invalid property rules. + /// This test verifies that rules incompatible with property type are rejected. + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when STRING property has numeric rules") + void postTemplate_400_string_property_with_numeric_rules() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_invalid_rules.json", + "Property 'property-test' of type STRING: Numeric rule maxValue is not allowed for STRING properties"); + assertNotNull(res, "Test executed successfully"); + } + /// Tests the POST /api/v1/entity-templates endpoint with no property definitions. /// This test verifies that: /// - Templates can be created without any properties diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json index 82501af..07bb170 100644 --- a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json @@ -13,9 +13,7 @@ "enum_values": [], "regex": "", "max_length": 200, - "min_length": 1, - "max_value": 0, - "min_value": 0 + "min_length": 1 } } ], diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json index a1502c8..5426461 100644 --- a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json @@ -14,8 +14,8 @@ "regex": "", "max_length": 200, "min_length": 1, - "max_value": 0, - "min_value": 0 + "max_value": null, + "min_value": null } } ], diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_invalid_rules.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_invalid_rules.json new file mode 100644 index 0000000..9fdbcdc --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_invalid_rules.json @@ -0,0 +1,23 @@ +{ + "identifier": "temp-test-wrong-property-rules", + "name": "Temp Test Wrong Property Rules", + "description": "This is a test template", + "properties_definitions": [ + { + "name": "property-test", + "description": "description", + "required": true, + "type": "STRING", + "rules": { + "format": "URL", + "enum_values": [], + "regex": "", + "max_length": 200, + "min_length": 1, + "max_value": 0, + "min_value": 0 + } + } + ], + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_200.json b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_200.json index f723194..1052d2f 100644 --- a/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_200.json +++ b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_200.json @@ -9,8 +9,7 @@ "required": true, "rules": { "regex": "^[a-zA-Z0-9]+$", - "max_length": 255, - "min_value": 0 + "max_length": 255 } }, { From 7a0ead3f52ecee56938462c4f4ae5b96f9d59d9c Mon Sep 17 00:00:00 2001 From: evebrnd Date: Mon, 27 Apr 2026 17:56:04 +0200 Subject: [PATCH 02/15] fix: fix swagger and refactor folders --- docs/src/static/swagger.yaml | 59 +------------- .../domain/constant/ValidationMessages.java | 1 + .../EntityTemplateAlreadyExistsException.java | 2 +- ...ityTemplateNameAlreadyExistsException.java | 2 +- .../{entity_template => }/EntityService.java | 2 +- .../RelationService.java | 2 +- .../EntityTemplateService.java | 2 +- .../PropertyRulesService.java | 81 ++++++++++--------- .../api/controller/EntityController.java | 2 +- .../controller/EntityTemplateController.java | 2 +- .../api/mapper/entity/EntityDtoOutMapper.java | 6 +- .../PropertyRulesServiceTest.java | 1 - .../EntityTemplateControllerTest.java | 2 +- 13 files changed, 56 insertions(+), 108 deletions(-) rename src/main/java/com/decathlon/idp_core/domain/service/{entity_template => }/EntityService.java (98%) rename src/main/java/com/decathlon/idp_core/domain/service/{entity_template => }/RelationService.java (96%) rename src/main/java/com/decathlon/idp_core/domain/service/entity_template/{entity_template => }/EntityTemplateService.java (99%) rename src/main/java/com/decathlon/idp_core/domain/service/entity_template/{entity_template => }/PropertyRulesService.java (75%) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 70dcd1b..83a9b74 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -250,11 +250,8 @@ components: description: Whether this property is required example: true rules: - oneOf: - - $ref: '#/components/schemas/PropertyRulesDtoInForString' - - $ref: '#/components/schemas/PropertyRulesDtoInForNumber' - - $ref: '#/components/schemas/PropertyRulesDtoInForBoolean' - description: Property validation rules (must match property type) + $ref: '#/components/schemas/PropertyRulesDtoIn' + description: Property validation rules required: - description - name @@ -302,57 +299,7 @@ components: format: int32 description: Minimum value for numeric properties example: 0 - PropertyRulesDtoInForString: - type: object - description: Validation rules for STRING property type. Allowed rules - format, enum_values, regex, max_length, min_length. Constraint - 0 ≤ min_length ≤ max_length - properties: - format: - type: string - description: Property format validation (for STRING only) - enum: - - URL - - EMAIL - example: EMAIL - enum_values: - type: array - description: Enumeration values for enum properties (for STRING only) - example: - - ACTIVE - - INACTIVE - items: - type: string - regex: - type: string - description: Regular expression pattern for validation (for STRING only) - example: ^[a-zA-Z0-9]+$ - max_length: - type: integer - format: int32 - description: Maximum length for string properties - example: 255 - min_length: - type: integer - format: int32 - description: Minimum length for string properties (must be ≤ max_length and ≥ 0) - example: 1 - PropertyRulesDtoInForNumber: - type: object - description: Validation rules for NUMBER property type. Allowed rules - max_value, min_value. Constraint - min_value ≤ max_value - properties: - max_value: - type: integer - format: int32 - description: Maximum value for numeric properties - example: 100 - min_value: - type: integer - format: int32 - description: Minimum value for numeric properties (must be ≤ max_value) - example: 0 - PropertyRulesDtoInForBoolean: - type: object - nullable: true - description: Boolean properties do not accept any rules. This field must be null or empty + RelationDefinitionDtoIn: type: object description: Input DTO for creating or updating a relation definition diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 7c8b15b..f4e8ac5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -33,6 +33,7 @@ public class ValidationMessages { public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; public static final String PROPERTY_RULES_MIN_VALUE_NON_NEGATIVE = "min_length must be greater than or equal to 0"; public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules; rules field must be null or empty"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; // Helper method to construct rule-not-allowed message public static String ruleNotAllowed(String rule, String propertyType) { diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java index be97a82..6477ccf 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java @@ -3,7 +3,7 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.service.entity_template.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; /// Exception thrown when attempting to create an [EntityTemplate] with an identifier that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java index 4399dc2..a07201a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java @@ -3,7 +3,7 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_NAME_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.service.entity_template.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; /// Exception thrown when attempting to create or update an [EntityTemplate] with a name that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java similarity index 98% rename from src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityService.java rename to src/main/java/com/decathlon/idp_core/domain/service/EntityService.java index 30937d5..b09cff9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.service.entity_template; +package com.decathlon.idp_core.domain.service; import java.util.List; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/RelationService.java b/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java similarity index 96% rename from src/main/java/com/decathlon/idp_core/domain/service/entity_template/RelationService.java rename to src/main/java/com/decathlon/idp_core/domain/service/RelationService.java index a221f21..bdc934a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/RelationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.service.entity_template; +package com.decathlon.idp_core.domain.service; import java.util.List; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java similarity index 99% rename from src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/EntityTemplateService.java rename to src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java index a1fd29d..be8c3c2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.service.entity_template.entity_template; +package com.decathlon.idp_core.domain.service.entity_template; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/PropertyRulesService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java similarity index 75% rename from src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/PropertyRulesService.java rename to src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java index d604f42..60b5bc4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/entity_template/PropertyRulesService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java @@ -1,7 +1,8 @@ -package com.decathlon.idp_core.domain.service.entity_template.entity_template; +package com.decathlon.idp_core.domain.service.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_BOOLEAN_NOT_ALLOWED; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_VALUE_NON_NEGATIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED; import static com.decathlon.idp_core.domain.constant.ValidationMessages.minMaxConstraintViolated; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; @@ -12,24 +13,29 @@ /// Domain service for validating property rule compatibility with property types. /// -/// Provides pure validation functions ensuring property rules conform to their assigned -/// data types. Enforces business invariants around which rules apply to each type and -/// validates numeric constraints (min ≤ max). /// /// **Business rules:** /// - STRING: Allows format, enum_values, regex, max_length, min_length. Rejects numeric rules. /// - NUMBER: Allows max_value, min_value. Rejects string and format rules. /// - BOOLEAN: Rejects all rules; rules field must be null or empty. /// -/// **Design principles:** -/// - Pure functions: No side effects, no state mutation -/// - Single responsibility: Only validates rules, doesn't throw or log -/// - Testable: Can be tested independently without Spring context public final class PropertyRulesService { - private PropertyRulesService() { - // Utility class - prevent instantiation - } + // Property type constants + public static final String PROPERTY_TYPE_NUMBER = "NUMBER"; + + // Rule name constants + public static final String REGEX = "regex"; + public static final String LENGTH = "length"; + public static final String VALUE = "value"; + public static final String FORMAT = "format"; + public static final String ENUM_VALUES = "enum_values"; + public static final String MAX_LENGTH = "max_length"; + public static final String MIN_LENGTH = "min_length"; + public static final String MAX_VALUE = "max_value"; + public static final String MIN_VALUE = "min_value"; + + private PropertyRulesService() {} /// Validates property rules are compatible with the property's data type. /// @@ -76,21 +82,21 @@ public static void validatePropertyRules(PropertyDefinition propertyDefinition) private static void validateStringPropertyRules(String propertyName, PropertyRules rules) { // Reject numeric rules for STRING type if (rules.maxValue() != null || rules.minValue() != null) { - String violation = rules.maxValue() != null ? - "Numeric rule maxValue is not allowed for STRING properties" : - "Numeric rule minValue is not allowed for STRING properties"; - throw new PropertyRulesConflictException(propertyName, PropertyType.STRING, violation); + String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; + throw new PropertyRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) + ); } - // Validate min_length and max_length constraints - if (rules.minLength() != null && rules.maxLength() != null) { - if (rules.minLength() > rules.maxLength()) { - throw new PropertyRulesConflictException( - propertyName, - PropertyType.STRING, - minMaxConstraintViolated("length") - ); - } + // Validate min_length is below max_length + if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.STRING, + minMaxConstraintViolated(LENGTH) + ); } // Validate min_length is non-negative @@ -114,12 +120,11 @@ private static void validateStringPropertyRules(String propertyName, PropertyRul /// @throws PropertyRulesConflictException when string rules are present /// or min/max value constraints are violated private static void validateNumberPropertyRules(String propertyName, PropertyRules rules) { - // Reject string-related rules for NUMBER type if (rules.format() != null) { throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed("format", "NUMBER") + ruleNotAllowed(FORMAT, PROPERTY_TYPE_NUMBER) ); } @@ -127,7 +132,7 @@ private static void validateNumberPropertyRules(String propertyName, PropertyRul throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed("enum_values", "NUMBER") + ruleNotAllowed(ENUM_VALUES, PROPERTY_TYPE_NUMBER) ); } @@ -135,7 +140,7 @@ private static void validateNumberPropertyRules(String propertyName, PropertyRul throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed("regex", "NUMBER") + ruleNotAllowed(REGEX, PROPERTY_TYPE_NUMBER) ); } @@ -143,7 +148,7 @@ private static void validateNumberPropertyRules(String propertyName, PropertyRul throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed("min_length", "NUMBER") + ruleNotAllowed(MIN_LENGTH, PROPERTY_TYPE_NUMBER) ); } @@ -151,19 +156,16 @@ private static void validateNumberPropertyRules(String propertyName, PropertyRul throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed("max_length", "NUMBER") + ruleNotAllowed(MAX_LENGTH, PROPERTY_TYPE_NUMBER) ); } - // Validate min_value and max_value constraints - if (rules.minValue() != null && rules.maxValue() != null) { - if (rules.minValue() > rules.maxValue()) { - throw new PropertyRulesConflictException( - propertyName, - PropertyType.NUMBER, - minMaxConstraintViolated("value") - ); - } + if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.NUMBER, + minMaxConstraintViolated(VALUE) + ); } } @@ -176,7 +178,6 @@ private static void validateNumberPropertyRules(String propertyName, PropertyRul /// @param rules the property rules to validate /// @throws PropertyRulesConflictException when any rule is set for BOOLEAN private static void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { - // Check if any rule field is set if (rules.format() != null || (rules.enumValues() != null && !rules.enumValues().isEmpty()) || (rules.regex() != null && !rules.regex().isBlank()) || diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 3195e60..3f94e44 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -35,7 +35,7 @@ import org.springframework.web.bind.annotation.RestController; import com.decathlon.idp_core.domain.model.entity.Entity; -import com.decathlon.idp_core.domain.service.entity_template.EntityService; +import com.decathlon.idp_core.domain.service.EntityService; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java index 28eebf5..b4e975d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java @@ -44,7 +44,7 @@ import org.springframework.web.bind.annotation.RestController; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.service.entity_template.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.TemplatePageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateUpdateDtoIn; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index d7500a7..4b7bbc9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -20,9 +20,9 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import com.decathlon.idp_core.domain.service.entity_template.EntityService; -import com.decathlon.idp_core.domain.service.entity_template.entity_template.EntityTemplateService; -import com.decathlon.idp_core.domain.service.entity_template.RelationService; +import com.decathlon.idp_core.domain.service.EntityService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.RelationService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java index 0082199..83f5a45 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.UUID; -import com.decathlon.idp_core.domain.service.entity_template.entity_template.PropertyRulesService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index 63f0cc1..d691134 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -440,7 +440,7 @@ void postTemplate_400_property_format_invalid_enum() throws Exception { void postTemplate_400_string_property_with_numeric_rules() throws Exception { MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_invalid_rules.json", - "Property 'property-test' of type STRING: Numeric rule maxValue is not allowed for STRING properties"); + "Property 'property-test' of type STRING: Numeric rule max_value is not allowed for STRING properties"); assertNotNull(res, "Test executed successfully"); } From aa3525115c8df1c43c551838ab722f836a1acd45 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Mon, 27 Apr 2026 18:05:00 +0200 Subject: [PATCH 03/15] fix: fix swagger and refactor folders --- docs/src/static/swagger.yaml | 1 - .../exception/PropertyRulesConflictException.java | 12 +----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 83a9b74..37c9d48 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -299,7 +299,6 @@ components: format: int32 description: Minimum value for numeric properties example: 0 - RelationDefinitionDtoIn: type: object description: Input DTO for creating or updating a relation definition diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java index 3841985..379222b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java @@ -8,23 +8,13 @@ /// conflict with their assigned property type. This ensures data integrity /// by preventing invalid rule configurations before persistence. /// -/// **Exception design rationale:** -/// - Reports specific property name and type for debugging clarity -/// - Includes detailed violation message explaining the constraint failure -/// - Domain-level exception keeps business logic separate from HTTP concerns -/// /// **Usage patterns:** /// - Property template creation with invalid rules -/// - Template updates introducing rule conflicts -/// - Batch validation of property definitions +/// - Property template updates introducing rule conflicts public class PropertyRulesConflictException extends RuntimeException { /// Constructs a new exception for rule type conflict. /// - /// **Why this exists:** Provides standardized error message format when - /// a rule parameter (format, enum_values, regex, etc.) is not supported - /// for the given property type. - /// /// @param propertyName the name of the property with invalid rules /// @param propertyType the data type of the property /// @param violationMessage detailed explanation of what rule is invalid From 8705a9646fb20895ee1c776cec2f24a6583374e8 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Tue, 28 Apr 2026 10:42:41 +0200 Subject: [PATCH 04/15] feat: add regex format validation --- .../domain/constant/ValidationMessages.java | 4 +-- .../entity_template/PropertyRulesService.java | 30 +++++++++++++++++-- .../configuration/SwaggerConfiguration.java | 2 +- .../controller/EntityTemplateController.java | 4 +-- .../EntityTemplateDtoOut.java | 2 +- .../PropertyDefinitionDtoOut.java | 2 +- .../PropertyRulesDtoOut.java | 2 +- .../RelationDefinitionDtoOut.java | 2 +- .../EntityTemplateMapper.java | 10 +++---- src/main/resources/application.yml | 3 ++ .../PropertyRulesServiceTest.java | 30 +++++++++++++++++++ .../EntityTemplateControllerTest.java | 1 - .../api/mapper/EntityTemplateMapperTest.java | 10 +++---- 13 files changed, 80 insertions(+), 22 deletions(-) rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/{entitytemplate => entity_template}/EntityTemplateDtoOut.java (98%) rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/{entitytemplate => entity_template}/PropertyDefinitionDtoOut.java (98%) rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/{entitytemplate => entity_template}/PropertyRulesDtoOut.java (99%) rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/{entitytemplate => entity_template}/RelationDefinitionDtoOut.java (98%) rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/{entitytemplate => entity_template}/EntityTemplateMapper.java (98%) diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index f4e8ac5..2c6a155 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -23,7 +23,7 @@ public class ValidationMessages { // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target entity identifier is mandatory and cannot be blank"; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; @@ -32,7 +32,7 @@ public class ValidationMessages { public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; public static final String PROPERTY_RULES_MIN_VALUE_NON_NEGATIVE = "min_length must be greater than or equal to 0"; - public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules; rules field must be null or empty"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; // Helper method to construct rule-not-allowed message diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java index 60b5bc4..044d6b6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java @@ -6,6 +6,9 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.minMaxConstraintViolated; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + import com.decathlon.idp_core.domain.exception.PropertyRulesConflictException; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; @@ -73,12 +76,12 @@ public static void validatePropertyRules(PropertyDefinition propertyDefinition) /// /// **Allowed rules:** format, enum_values, regex, max_length, min_length /// **Rejected rules:** max_value, min_value (numeric) - /// **Constraints:** 0 ≤ min_length ≤ max_length + /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid /// /// @param propertyName name of the property (for error reporting) /// @param rules the property rules to validate /// @throws PropertyRulesConflictException when numeric rules are present - /// or min/max length constraints are violated + /// or min/max length constraints are violated or regex is invalid private static void validateStringPropertyRules(String propertyName, PropertyRules rules) { // Reject numeric rules for STRING type if (rules.maxValue() != null || rules.minValue() != null) { @@ -90,6 +93,11 @@ private static void validateStringPropertyRules(String propertyName, PropertyRul ); } + // Validate regex pattern is valid + if (rules.regex() != null && !rules.regex().isBlank()) { + validateRegexPattern(propertyName, rules.regex()); + } + // Validate min_length is below max_length if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { throw new PropertyRulesConflictException( @@ -193,4 +201,22 @@ private static void validateBooleanPropertyRules(String propertyName, PropertyRu ); } } + + /// Validates that the provided regex pattern is syntactically valid. + /// + /// @param propertyName name of the property (for error reporting) + /// @param regexPattern the regex pattern to validate + /// @throws PropertyRulesConflictException if the pattern is syntactically invalid + private static void validateRegexPattern(String propertyName, String regexPattern) { + try { + Pattern.compile(regexPattern); + } catch (PatternSyntaxException e) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.STRING, + "Invalid regex pattern: " + e.getMessage() + ); + } + } + } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java index 7b35bce..e9eac3e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java @@ -14,7 +14,7 @@ import org.springframework.data.domain.Pageable; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.EntityTemplateDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.core.jackson.ModelResolver; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java index b4e975d..fa0f947 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java @@ -48,10 +48,10 @@ import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.TemplatePageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateUpdateDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.EntityTemplateDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; -import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entitytemplate.EntityTemplateMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_template.EntityTemplateMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/EntityTemplateDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java similarity index 98% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/EntityTemplateDtoOut.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java index c38f8b9..ea0ad86 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/EntityTemplateDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate; +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/PropertyDefinitionDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java similarity index 98% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/PropertyDefinitionDtoOut.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java index 2582625..b26f00d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/PropertyDefinitionDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate; +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template; import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/PropertyRulesDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java similarity index 99% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/PropertyRulesDtoOut.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java index c04d4f1..8190ee4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/PropertyRulesDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate; +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_RULES_ENUM_VALUES; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_RULES_FORMAT; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/RelationDefinitionDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java similarity index 98% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/RelationDefinitionDtoOut.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java index 42cb9ff..fe5f39a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entitytemplate/RelationDefinitionDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate; +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_RELATION_NAME; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_RELATION_REQUIRED; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entitytemplate/EntityTemplateMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java similarity index 98% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entitytemplate/EntityTemplateMapper.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java index aa7074a..5b4a61b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entitytemplate/EntityTemplateMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entitytemplate; +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_template; import java.util.List; @@ -13,10 +13,10 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.PropertyDefinitionDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.PropertyRulesDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.RelationDefinitionDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.EntityTemplateDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.PropertyDefinitionDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.PropertyRulesDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.RelationDefinitionDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.PropertyDefinitionDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.PropertyRulesDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.RelationDefinitionDtoOut; /// Mapper component for converting between [EntityTemplate] DTOs and domain entities. /// diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c35720b..8349adc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,6 +11,9 @@ spring: jackson: # Serializes all JSON fields as snake_case (for example templateIdentifier → template_identifier). property-naming-strategy: SNAKE_CASE + deserialization: + # Fails deserialization if JSON contains unknown fields not mapped to Java properties. + fail-on-unknown-properties: true profiles: # Activates a Spring profile from the env var SPRING_PROFILE (defaults to none). diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java index 83f5a45..4aa6fec 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java @@ -243,6 +243,36 @@ void testStringWithNegativeMinLength() { assertTrue(ex.getMessage().contains("min_length")); assertTrue(ex.getMessage().contains("0")); } + + @Test + @DisplayName("Error: STRING with invalid regex pattern") + void testStringWithInvalidRegexPattern() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "[invalid-regex", + 255, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> PropertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("regex")); + assertTrue(ex.getMessage().contains("[invalid-regex")); + } } @Nested diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index d691134..da0d1a9 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -588,7 +588,6 @@ void putTemplate_updateRelations_200() throws Exception { String updateJson = """ { - "identifier": "template-rel-test", "name": "Template Rel Test", "description": "Updated template with new relation", "properties_definitions": [ diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java index 137e5c5..e852d5f 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java @@ -21,11 +21,11 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.PropertyDefinitionDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.PropertyRulesDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.RelationDefinitionDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.EntityTemplateDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.PropertyDefinitionDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.PropertyRulesDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entitytemplate.RelationDefinitionDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entitytemplate.EntityTemplateMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.PropertyDefinitionDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.PropertyRulesDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.RelationDefinitionDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_template.EntityTemplateMapper; @DisplayName("EntityTemplateMapper Tests") class EntityTemplateMapperTest { From b84801320f686b1e08003f21167ea10f74f19eb7 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Tue, 28 Apr 2026 15:48:26 +0200 Subject: [PATCH 05/15] refactor: modify business validations and tests --- .../domain/constant/ValidationMessages.java | 3 +- .../EntityTemplateService.java | 3 +- .../entity_template/PropertyRulesService.java | 62 +++--- .../PropertyRulesServiceTest.java | 189 +++++++++++++++--- 4 files changed, 203 insertions(+), 54 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 2c6a155..9c04dbf 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -31,7 +31,8 @@ public class ValidationMessages { // Property Rules validation messages - templates and specific constraints public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; - public static final String PROPERTY_RULES_MIN_VALUE_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java index be8c3c2..4237057 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java @@ -43,6 +43,7 @@ public class EntityTemplateService { private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final PropertyRulesService propertyRulesService; /// Retrieves paginated entity templates for management interface display. /// @@ -154,7 +155,7 @@ public EntityTemplate updateEntityTemplate(String identifier, @Valid EntityTempl private void validateTemplateRules(@Valid EntityTemplate entityTemplate) { if (entityTemplate.propertiesDefinitions() != null) { for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { - PropertyRulesService.validatePropertyRules(property); + propertyRulesService.validatePropertyRules(property); } } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java index 044d6b6..5684b17 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java @@ -1,7 +1,8 @@ package com.decathlon.idp_core.domain.service.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_BOOLEAN_NOT_ALLOWED; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_VALUE_NON_NEGATIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MAX_LENGTH_POSITIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED; import static com.decathlon.idp_core.domain.constant.ValidationMessages.minMaxConstraintViolated; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; @@ -9,6 +10,7 @@ import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.PropertyRulesConflictException; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; @@ -16,16 +18,13 @@ /// Domain service for validating property rule compatibility with property types. /// -/// /// **Business rules:** /// - STRING: Allows format, enum_values, regex, max_length, min_length. Rejects numeric rules. /// - NUMBER: Allows max_value, min_value. Rejects string and format rules. /// - BOOLEAN: Rejects all rules; rules field must be null or empty. /// -public final class PropertyRulesService { - - // Property type constants - public static final String PROPERTY_TYPE_NUMBER = "NUMBER"; +@Service +public class PropertyRulesService { // Rule name constants public static final String REGEX = "regex"; @@ -38,8 +37,6 @@ public final class PropertyRulesService { public static final String MAX_VALUE = "max_value"; public static final String MIN_VALUE = "min_value"; - private PropertyRulesService() {} - /// Validates property rules are compatible with the property's data type. /// /// **Contract:** Performs comprehensive validation including: @@ -49,7 +46,7 @@ private PropertyRulesService() {} /// /// @param propertyDefinition the property definition containing type and rules /// @throws PropertyRulesConflictException when rules violate business invariants - public static void validatePropertyRules(PropertyDefinition propertyDefinition) { + public void validatePropertyRules(PropertyDefinition propertyDefinition) { if (propertyDefinition.rules() == null) { return; } @@ -82,7 +79,7 @@ public static void validatePropertyRules(PropertyDefinition propertyDefinition) /// @param rules the property rules to validate /// @throws PropertyRulesConflictException when numeric rules are present /// or min/max length constraints are violated or regex is invalid - private static void validateStringPropertyRules(String propertyName, PropertyRules rules) { + private void validateStringPropertyRules(String propertyName, PropertyRules rules) { // Reject numeric rules for STRING type if (rules.maxValue() != null || rules.minValue() != null) { String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; @@ -98,21 +95,28 @@ private static void validateStringPropertyRules(String propertyName, PropertyRul validateRegexPattern(propertyName, rules.regex()); } - // Validate min_length is below max_length - if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { + // Validate min_length is non-negative + if (rules.minLength() != null && rules.minLength() < 0) { throw new PropertyRulesConflictException( propertyName, PropertyType.STRING, - minMaxConstraintViolated(LENGTH) + PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE ); } - - // Validate min_length is non-negative - if (rules.minLength() != null && rules.minLength() < 0) { + // Validate max_length is not zero or negative + if (rules.maxLength() != null && rules.maxLength() <= 0) { throw new PropertyRulesConflictException( propertyName, PropertyType.STRING, - PROPERTY_RULES_MIN_VALUE_NON_NEGATIVE + PROPERTY_RULES_MAX_LENGTH_POSITIVE + ); + } + // Validate min_length is below or equal to max_length + if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { + throw new PropertyRulesConflictException( + propertyName, + PropertyType.STRING, + minMaxConstraintViolated(LENGTH) ); } } @@ -127,28 +131,28 @@ private static void validateStringPropertyRules(String propertyName, PropertyRul /// @param rules the property rules to validate /// @throws PropertyRulesConflictException when string rules are present /// or min/max value constraints are violated - private static void validateNumberPropertyRules(String propertyName, PropertyRules rules) { + private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { if (rules.format() != null) { throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed(FORMAT, PROPERTY_TYPE_NUMBER) + ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) ); } - if (rules.enumValues() != null && !rules.enumValues().isEmpty()) { + if (rules.enumValues() != null) { throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed(ENUM_VALUES, PROPERTY_TYPE_NUMBER) + ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) ); } - if (rules.regex() != null && !rules.regex().isBlank()) { + if (rules.regex() != null) { throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed(REGEX, PROPERTY_TYPE_NUMBER) + ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) ); } @@ -156,7 +160,7 @@ private static void validateNumberPropertyRules(String propertyName, PropertyRul throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed(MIN_LENGTH, PROPERTY_TYPE_NUMBER) + ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) ); } @@ -164,7 +168,7 @@ private static void validateNumberPropertyRules(String propertyName, PropertyRul throw new PropertyRulesConflictException( propertyName, PropertyType.NUMBER, - ruleNotAllowed(MAX_LENGTH, PROPERTY_TYPE_NUMBER) + ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) ); } @@ -185,10 +189,10 @@ private static void validateNumberPropertyRules(String propertyName, PropertyRul /// @param propertyName name of the property (for error reporting) /// @param rules the property rules to validate /// @throws PropertyRulesConflictException when any rule is set for BOOLEAN - private static void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { if (rules.format() != null || - (rules.enumValues() != null && !rules.enumValues().isEmpty()) || - (rules.regex() != null && !rules.regex().isBlank()) || + rules.enumValues() != null || + rules.regex() != null || rules.maxLength() != null || rules.minLength() != null || rules.maxValue() != null || @@ -207,7 +211,7 @@ private static void validateBooleanPropertyRules(String propertyName, PropertyRu /// @param propertyName name of the property (for error reporting) /// @param regexPattern the regex pattern to validate /// @throws PropertyRulesConflictException if the pattern is syntactically invalid - private static void validateRegexPattern(String propertyName, String regexPattern) { + private void validateRegexPattern(String propertyName, String regexPattern) { try { Pattern.compile(regexPattern); } catch (PatternSyntaxException e) { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java index 4aa6fec..f03b858 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -20,6 +21,13 @@ @DisplayName("PropertyRulesService Tests") class PropertyRulesServiceTest { + private PropertyRulesService propertyRulesService; + + @BeforeEach + void setUp() { + propertyRulesService = new PropertyRulesService(); + } + @Nested @DisplayName("STRING Property Type") class StringPropertyTypeTests { @@ -46,7 +54,7 @@ void testStringWithValidRules() { rules ); - assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); } @Test @@ -71,7 +79,7 @@ void testStringWithLengthConstraints() { rules ); - assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); } @Test @@ -96,7 +104,7 @@ void testStringWithEnumValues() { rules ); - assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); } @Test @@ -121,7 +129,7 @@ void testStringWithRegex() { rules ); - assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); } @Test @@ -148,7 +156,7 @@ void testStringRejectsMaxValue() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("name")); assertTrue(ex.getMessage().contains("STRING")); @@ -178,7 +186,7 @@ void testStringRejectsMinValue() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("counter")); assertTrue(ex.getMessage().contains("STRING")); @@ -208,7 +216,7 @@ void testStringWithInvalidLengthConstraints() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("min_length")); assertTrue(ex.getMessage().contains("max_length")); @@ -238,7 +246,7 @@ void testStringWithNegativeMinLength() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("min_length")); assertTrue(ex.getMessage().contains("0")); @@ -268,11 +276,81 @@ void testStringWithInvalidRegexPattern() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("regex")); assertTrue(ex.getMessage().contains("[invalid-regex")); } + + @Test + @DisplayName("Happy path: STRING with null rules") + void testStringWithNullRules() { + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + null + ); + + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with min_length = 0 and max_length > 0") + void testStringWithZeroMinLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 100, + 0, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "optional_field", + "An optional field", + PropertyType.STRING, + false, + rules + ); + + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Error: STRING with max_length <= 0") + void testStringWithNonPositiveMaxLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + 0, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyRulesConflictException ex = assertThrows( + PropertyRulesConflictException.class, + () -> propertyRulesService.validatePropertyRules(property) + ); + assertTrue(ex.getMessage().contains("max_length")); + assertTrue(ex.getMessage().contains("greater than 0")); + } } @Nested @@ -301,7 +379,7 @@ void testNumberWithValidRules() { rules ); - assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); } @Test @@ -326,7 +404,7 @@ void testNumberWithOnlyMaxValue() { rules ); - assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); } @Test @@ -353,7 +431,7 @@ void testNumberRejectsFormat() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("value")); assertTrue(ex.getMessage().contains("NUMBER")); @@ -384,7 +462,7 @@ void testNumberRejectsEnumValues() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("enum_values")); } @@ -413,7 +491,7 @@ void testNumberRejectsRegex() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("regex")); } @@ -442,7 +520,7 @@ void testNumberRejectsMinLength() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("min_length")); } @@ -471,7 +549,7 @@ void testNumberRejectsMaxLength() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("max_length")); } @@ -500,11 +578,76 @@ void testNumberWithInvalidValueConstraints() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("min_value")); assertTrue(ex.getMessage().contains("max_value")); } + + @Test + @DisplayName("Happy path: NUMBER with only min_value") + void testNumberWithOnlyMinValue() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + null, + 10 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "minimum_age", + "Minimum age", + PropertyType.NUMBER, + false, + rules + ); + + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with negative min_value and max_value") + void testNumberWithNegativeValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + null, + null, + null, + 100, + -100 + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "temperature", + "Temperature", + PropertyType.NUMBER, + false, + rules + ); + + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with null rules") + void testNumberWithNullRules() { + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "count", + "A count", + PropertyType.NUMBER, + true, + null + ); + + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + } } @Nested @@ -523,7 +666,7 @@ void testBooleanWithNullRules() { null ); - assertDoesNotThrow(() -> PropertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); } @Test @@ -550,7 +693,7 @@ void testBooleanRejectsFormat() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); assertTrue(ex.getMessage().contains("rules")); @@ -580,7 +723,7 @@ void testBooleanRejectsEnumValues() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); } @@ -609,7 +752,7 @@ void testBooleanRejectsRegex() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); } @@ -638,7 +781,7 @@ void testBooleanRejectsMinValue() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); } @@ -667,7 +810,7 @@ void testBooleanRejectsMaxValue() { PropertyRulesConflictException ex = assertThrows( PropertyRulesConflictException.class, - () -> PropertyRulesService.validatePropertyRules(property) + () -> propertyRulesService.validatePropertyRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); } From ea5d170d00415206edfa4fe9ffd9ed6f3ec76e91 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Wed, 29 Apr 2026 18:28:02 +0200 Subject: [PATCH 06/15] refactor(entity-template): separate validation services from orchestrating ones --- .../domain/constant/ValidationMessages.java | 1 - .../EntityTemplateAlreadyExistsException.java | 2 +- ...ityTemplateNameAlreadyExistsException.java | 2 +- .../EntityTemplateNotFoundException.java | 2 +- ...ertyDefinitionRulesConflictException.java} | 6 +- .../domain/service/EntityService.java | 2 +- .../EntityTemplateService.java | 85 +++-------- .../EntityTemplateValidationService.java | 99 +++++++++++++ ... PropertyDefinitionValidationService.java} | 40 +++--- .../entity_template/PropertyRulesDtoOut.java | 6 - .../api/handler/ApiExceptionHandler.java | 56 +++++--- .../entity_template/EntityTemplateMapper.java | 5 +- src/main/resources/application.yml | 3 + ...pertyDefinitionValidationServiceTest.java} | 136 +++++++++--------- .../api/handler/ApiExceptionHandlerTest.java | 4 +- .../api/mapper/EntityTemplateMapperTest.java | 21 ++- 16 files changed, 280 insertions(+), 190 deletions(-) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateAlreadyExistsException.java (96%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateNameAlreadyExistsException.java (94%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateNotFoundException.java (97%) rename src/main/java/com/decathlon/idp_core/domain/exception/{PropertyRulesConflictException.java => entity_template/PropertyDefinitionRulesConflictException.java} (76%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java rename src/main/java/com/decathlon/idp_core/domain/service/entity_template/{PropertyRulesService.java => PropertyDefinitionValidationService.java} (84%) rename src/test/java/com/decathlon/idp_core/domain/service/entity_template/{PropertyRulesServiceTest.java => PropertyDefinitionValidationServiceTest.java} (79%) diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 9c04dbf..b1836e4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -9,7 +9,6 @@ public class ValidationMessages { // Entity Template validation messages public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; - public static final String PROPERTY_DEFINITIONS_MANDATORY = "Entity Template property definitions are mandatory and cannot be empty"; public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java similarity index 96% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java index 6477ccf..adbb597 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_EXISTS; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java similarity index 94% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java index a07201a..b690d2a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_NAME_ALREADY_EXISTS; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java similarity index 97% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java index bca9ccd..c765a4f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import java.util.UUID; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java similarity index 76% rename from src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java index 379222b..650637d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/PropertyRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import com.decathlon.idp_core.domain.model.enums.PropertyType; @@ -11,14 +11,14 @@ /// **Usage patterns:** /// - Property template creation with invalid rules /// - Property template updates introducing rule conflicts -public class PropertyRulesConflictException extends RuntimeException { +public class PropertyDefinitionRulesConflictException extends RuntimeException { /// Constructs a new exception for rule type conflict. /// /// @param propertyName the name of the property with invalid rules /// @param propertyType the data type of the property /// @param violationMessage detailed explanation of what rule is invalid - public PropertyRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java index b09cff9..b21d871 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java index 4237057..5d37df9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java @@ -3,7 +3,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; @@ -12,9 +11,9 @@ import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; @@ -43,7 +42,7 @@ public class EntityTemplateService { private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; - private final PropertyRulesService propertyRulesService; + private final EntityTemplateValidationService entityTemplateValidationService; /// Retrieves paginated entity templates for management interface display. /// @@ -88,15 +87,7 @@ public EntityTemplate getEntityTemplateByIdentifier(String identifier) { /// @throws EntityTemplateNameAlreadyExistsException when name already exists @Transactional public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) { - if (entityTemplate.identifier() != null && - entityTemplateRepositoryPort.existsByIdentifier(entityTemplate.identifier())) { - throw new EntityTemplateAlreadyExistsException(entityTemplate.identifier()); - } - if (entityTemplate.name() != null && - entityTemplateRepositoryPort.existsByName(entityTemplate.name())) { - throw new EntityTemplateNameAlreadyExistsException(entityTemplate.name()); - } - validateTemplateRules(entityTemplate); + entityTemplateValidationService.validateForCreate(entityTemplate); return entityTemplateRepositoryPort.save(entityTemplate); } @@ -125,18 +116,6 @@ public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) @Transactional public EntityTemplate updateEntityTemplate(String identifier, @Valid EntityTemplate entityTemplate) { EntityTemplate existingTemplate = getEntityTemplateByIdentifier(identifier); - - if (!identifier.equals(entityTemplate.identifier()) && - entityTemplateRepositoryPort.existsByIdentifier(entityTemplate.identifier())) { - throw new EntityTemplateAlreadyExistsException(entityTemplate.identifier()); - } - - if (entityTemplate.name() != null && - !Objects.equals(existingTemplate.name(), entityTemplate.name()) && - entityTemplateRepositoryPort.existsByName(entityTemplate.name())) { - throw new EntityTemplateNameAlreadyExistsException(entityTemplate.name()); - } - EntityTemplate mergedTemplate = new EntityTemplate( existingTemplate.id(), entityTemplate.identifier(), @@ -147,17 +126,22 @@ public EntityTemplate updateEntityTemplate(String identifier, @Valid EntityTempl mergeRelationDefinitions(existingTemplate.relationsDefinitions(), entityTemplate.relationsDefinitions()) ); - validateTemplateRules(mergedTemplate); - + entityTemplateValidationService.validateForUpdate(identifier, existingTemplate.name(), mergedTemplate); return entityTemplateRepositoryPort.save(mergedTemplate); } - private void validateTemplateRules(@Valid EntityTemplate entityTemplate) { - if (entityTemplate.propertiesDefinitions() != null) { - for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { - propertyRulesService.validatePropertyRules(property); - } - } + /// Deletes an entity template by business identifier with existence validation. + /// + /// **Contract:** Validates template existence before deletion to ensure referential + /// integrity. Deletion cascades through persistence layer according to configured + /// relationships. This operation is irreversible once committed. + /// + /// @param identifier unique business identifier of template to delete + /// @throws EntityTemplateNotFoundException when template doesn't exist + @Transactional + public void deleteEntityTemplate(String identifier) { + entityTemplateValidationService.validateForDelete(identifier); + entityTemplateRepositoryPort.deleteByIdentifier(identifier); } private List mergePropertyDefinitions( @@ -175,16 +159,14 @@ private List mergePropertyDefinitions( for (PropertyDefinition prop : updated) { PropertyDefinition existingProp = existingMap.get(prop.name()); if (existingProp != null) { - // Records are immutable - create a new instance - PropertyDefinition merged = new PropertyDefinition( + result.add(new PropertyDefinition( existingProp.id(), prop.name(), prop.description(), prop.type(), prop.required(), mergePropertyRules(existingProp.rules(), prop.rules()) - ); - result.add(merged); + )); } else { result.add(prop); } @@ -201,7 +183,6 @@ private PropertyRules mergePropertyRules(PropertyRules existingRules, PropertyRu return newRules; } - // Records are immutable - create a new instance return new PropertyRules( existingRules.id(), newRules.format(), @@ -229,15 +210,13 @@ private List mergeRelationDefinitions( for (RelationDefinition rel : updated) { RelationDefinition existingRel = existingMap.get(rel.name()); if (existingRel != null) { - // Records are immutable - create a new instance - RelationDefinition merged = new RelationDefinition( + result.add(new RelationDefinition( existingRel.id(), rel.name(), rel.targetTemplateIdentifier(), rel.required(), rel.toMany() - ); - result.add(merged); + )); } else { result.add(rel); } @@ -246,24 +225,4 @@ private List mergeRelationDefinitions( return result; } - - /// Deletes an entity template by business identifier with existence validation. - /// - /// **Contract:** Validates template existence before deletion to ensure referential - /// integrity. Deletion cascades through persistence layer according to configured - /// relationships. This operation is irreversible once committed. - /// - /// @param identifier unique business identifier of template to delete - /// @throws EntityTemplateNotFoundException when template doesn't exist - @Transactional - public void deleteEntityTemplate(String identifier) { - if (identifier == null) { - throw new IllegalArgumentException("Template identifier must not be null"); - } - if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { - throw new EntityTemplateNotFoundException("identifier", identifier); - } - entityTemplateRepositoryPort.deleteByIdentifier(identifier); - } - } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java new file mode 100644 index 0000000..8c5b9aa --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -0,0 +1,99 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import java.util.Objects; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; + +import lombok.RequiredArgsConstructor; + +/// Domain service to centralize all functional validation rules for [EntityTemplate] operations. +/// +/// **Key responsibilities:** +/// - Identifier and name uniqueness enforcement for create and update operations +/// - Property-rule compatibility validation (type vs. rule constraints) delegated to [PropertyDefinitionValidationService] +/// - Template existence verification before deletion +@Service +@RequiredArgsConstructor +public class EntityTemplateValidationService { + + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final PropertyDefinitionValidationService propertyDefinitionValidationService; + + /// Validates all business rules before creating a new entity template. + /// + /// **Business rules enforced:** + /// - If `identifier` is provided it must not already exist in the system. + /// - If `name` is provided it must not already exist in the system. + /// - Property rules must be compatible with their declared property type. + /// + /// @param entityTemplate the template candidate to validate + /// @throws EntityTemplateAlreadyExistsException when identifier is already taken + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + public void validateForCreate(EntityTemplate entityTemplate) { + if (entityTemplate.identifier() != null && + entityTemplateRepositoryPort.existsByIdentifier(entityTemplate.identifier())) { + throw new EntityTemplateAlreadyExistsException(entityTemplate.identifier()); + } + if (entityTemplate.name() != null && + entityTemplateRepositoryPort.existsByName(entityTemplate.name())) { + throw new EntityTemplateNameAlreadyExistsException(entityTemplate.name()); + } + validatePropertyRules(entityTemplate); + } + + /// Validates all business rules before persisting an updated entity template. + /// + /// **Business rules enforced:** + /// - If the identifier changed, the new value must not collide with another template. + /// - If the name changed, the new value must not collide with another template. + /// - Property rules in the merged template must be compatible with their declared type. + /// + /// @param currentIdentifier the identifier of the template being replaced + /// @param existingName the current name of the template being replaced + /// @param mergedTemplate the fully-merged template carrying the desired state + /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken + /// @throws EntityTemplateNameAlreadyExistsException when the new name is already taken + public void validateForUpdate(String currentIdentifier, String existingName, EntityTemplate mergedTemplate) { + if (!currentIdentifier.equals(mergedTemplate.identifier()) && + entityTemplateRepositoryPort.existsByIdentifier(mergedTemplate.identifier())) { + throw new EntityTemplateAlreadyExistsException(mergedTemplate.identifier()); + } + if (mergedTemplate.name() != null && + !Objects.equals(existingName, mergedTemplate.name()) && + entityTemplateRepositoryPort.existsByName(mergedTemplate.name())) { + throw new EntityTemplateNameAlreadyExistsException(mergedTemplate.name()); + } + validatePropertyRules(mergedTemplate); + } + + /// Validates that a template identifier is non-null and refers to an existing template. + /// + /// @param identifier the identifier of the template to delete + /// @throws IllegalArgumentException when `identifier` is null + /// @throws EntityTemplateNotFoundException when no template matches `identifier` + public void validateForDelete(String identifier) { + if (identifier == null) { + throw new IllegalArgumentException("Template identifier must not be null"); + } + if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateNotFoundException("identifier", identifier); + } + } + + private void validatePropertyRules(EntityTemplate entityTemplate) { + if (entityTemplate.propertiesDefinitions() == null) { + return; + } + for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { + propertyDefinitionValidationService.validatePropertyDefinitionRules(property); + } + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java similarity index 84% rename from src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java rename to src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index 5684b17..df8a23f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -10,8 +10,8 @@ import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import org.springframework.stereotype.Service; -import com.decathlon.idp_core.domain.exception.PropertyRulesConflictException; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; @@ -24,7 +24,7 @@ /// - BOOLEAN: Rejects all rules; rules field must be null or empty. /// @Service -public class PropertyRulesService { +public class PropertyDefinitionValidationService { // Rule name constants public static final String REGEX = "regex"; @@ -45,8 +45,8 @@ public class PropertyRulesService { /// - Boolean properties reject all rules /// /// @param propertyDefinition the property definition containing type and rules - /// @throws PropertyRulesConflictException when rules violate business invariants - public void validatePropertyRules(PropertyDefinition propertyDefinition) { + /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants + public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { if (propertyDefinition.rules() == null) { return; } @@ -77,13 +77,13 @@ public void validatePropertyRules(PropertyDefinition propertyDefinition) { /// /// @param propertyName name of the property (for error reporting) /// @param rules the property rules to validate - /// @throws PropertyRulesConflictException when numeric rules are present + /// @throws PropertyDefinitionRulesConflictException when numeric rules are present /// or min/max length constraints are violated or regex is invalid private void validateStringPropertyRules(String propertyName, PropertyRules rules) { // Reject numeric rules for STRING type if (rules.maxValue() != null || rules.minValue() != null) { String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.STRING, PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) @@ -97,7 +97,7 @@ private void validateStringPropertyRules(String propertyName, PropertyRules rule // Validate min_length is non-negative if (rules.minLength() != null && rules.minLength() < 0) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.STRING, PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE @@ -105,7 +105,7 @@ private void validateStringPropertyRules(String propertyName, PropertyRules rule } // Validate max_length is not zero or negative if (rules.maxLength() != null && rules.maxLength() <= 0) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.STRING, PROPERTY_RULES_MAX_LENGTH_POSITIVE @@ -113,7 +113,7 @@ private void validateStringPropertyRules(String propertyName, PropertyRules rule } // Validate min_length is below or equal to max_length if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.STRING, minMaxConstraintViolated(LENGTH) @@ -129,11 +129,11 @@ private void validateStringPropertyRules(String propertyName, PropertyRules rule /// /// @param propertyName name of the property (for error reporting) /// @param rules the property rules to validate - /// @throws PropertyRulesConflictException when string rules are present + /// @throws PropertyDefinitionRulesConflictException when string rules are present /// or min/max value constraints are violated private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { if (rules.format() != null) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.NUMBER, ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) @@ -141,7 +141,7 @@ private void validateNumberPropertyRules(String propertyName, PropertyRules rule } if (rules.enumValues() != null) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.NUMBER, ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) @@ -149,7 +149,7 @@ private void validateNumberPropertyRules(String propertyName, PropertyRules rule } if (rules.regex() != null) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.NUMBER, ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) @@ -157,7 +157,7 @@ private void validateNumberPropertyRules(String propertyName, PropertyRules rule } if (rules.minLength() != null) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.NUMBER, ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) @@ -165,7 +165,7 @@ private void validateNumberPropertyRules(String propertyName, PropertyRules rule } if (rules.maxLength() != null) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.NUMBER, ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) @@ -173,7 +173,7 @@ private void validateNumberPropertyRules(String propertyName, PropertyRules rule } if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.NUMBER, minMaxConstraintViolated(VALUE) @@ -188,7 +188,7 @@ private void validateNumberPropertyRules(String propertyName, PropertyRules rule /// /// @param propertyName name of the property (for error reporting) /// @param rules the property rules to validate - /// @throws PropertyRulesConflictException when any rule is set for BOOLEAN + /// @throws PropertyDefinitionRulesConflictException when any rule is set for BOOLEAN private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { if (rules.format() != null || rules.enumValues() != null || @@ -198,7 +198,7 @@ private void validateBooleanPropertyRules(String propertyName, PropertyRules rul rules.maxValue() != null || rules.minValue() != null) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.BOOLEAN, PROPERTY_RULES_BOOLEAN_NOT_ALLOWED @@ -210,12 +210,12 @@ private void validateBooleanPropertyRules(String propertyName, PropertyRules rul /// /// @param propertyName name of the property (for error reporting) /// @param regexPattern the regex pattern to validate - /// @throws PropertyRulesConflictException if the pattern is syntactically invalid + /// @throws PropertyDefinitionRulesConflictException if the pattern is syntactically invalid private void validateRegexPattern(String propertyName, String regexPattern) { try { Pattern.compile(regexPattern); } catch (PatternSyntaxException e) { - throw new PropertyRulesConflictException( + throw new PropertyDefinitionRulesConflictException( propertyName, PropertyType.STRING, "Invalid regex pattern: " + e.getMessage() diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java index 8190ee4..c754eea 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java @@ -2,7 +2,6 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_RULES_ENUM_VALUES; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_RULES_FORMAT; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_RULES_ID; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_RULES_MAX_LENGTH; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_RULES_MAX_VALUE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_RULES_MIN_LENGTH; @@ -10,8 +9,6 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_RULES_REGEX; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_PROPERTY_RULES_OUT; -import java.util.UUID; - import com.decathlon.idp_core.domain.model.enums.PropertyFormat; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -30,9 +27,6 @@ @Schema(description = SCHEMA_PROPERTY_RULES_OUT) public class PropertyRulesDtoOut { - @Schema(description = FIELD_PROPERTY_RULES_ID, example = "123e4567-e89b-12d3-a456-426614174000") - private UUID id; - @Schema(description = FIELD_PROPERTY_RULES_FORMAT, example = "STRING") private PropertyFormat format; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index f6eb713..48c1af4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -5,7 +5,7 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; -import com.decathlon.idp_core.domain.exception.PropertyRulesConflictException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -14,9 +14,9 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -86,11 +86,11 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept /// Handles domain exception for wrong entity template property rules. /// - /// **HTTP mapping:** Maps domain PropertyRulesConflictException to HTTP 400 + /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to HTTP 400 /// status indicating validation error for wrong property rules. - @ExceptionHandler(PropertyRulesConflictException.class) + @ExceptionHandler(PropertyDefinitionRulesConflictException.class) public ResponseEntity handleWrongPropertyRulesException( - PropertyRulesConflictException ex) { + PropertyDefinitionRulesConflictException ex) { log.warn("Wrong Entity template property rules: {}", ex.getMessage()); ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); @@ -173,12 +173,43 @@ private String parseDeserializationError(String originalMessage) { if (originalMessage.contains("not one of the values accepted for Enum class")) { return parseEnumDeserializationError(originalMessage); } + return parseTypeDeserializationError(originalMessage); + } + + private String parseTypeDeserializationError(String originalMessage) { + String targetType = extractTargetType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); + + if (!targetType.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property, expected " + targetType; + } else if (!targetType.isEmpty()) { + return "Invalid type: expected " + targetType; + } return "Cannot deserialize request body property"; } + private String extractTargetType(String message) { + Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); + Matcher matcher = typePattern.matcher(message); + if (matcher.find()) { + String fullType = matcher.group(1); + return fullType.substring(fullType.lastIndexOf('.') + 1); + } + return ""; + } + + private String extractInvalidValueFromString(String message) { + Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); + Matcher matcher = valuePattern.matcher(message); + if (matcher.find()) { + return matcher.group(1); + } + return ""; + } + private String parseEnumDeserializationError(String originalMessage) { String enumTypeName = getPropertyNameFromEnumType(originalMessage); - String invalidValue = extractInvalidValue(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; @@ -203,15 +234,6 @@ private String getPropertyNameFromEnumType(String message) { return ""; } - private String extractInvalidValue(String message) { - int valueStart = message.indexOf("from String \"") + 13; - int valueEnd = message.indexOf("\"", valueStart); - if (valueStart > 12 && valueEnd > valueStart) { - return message.substring(valueStart, valueEnd); - } - return ""; - } - /// Handles all unexpected exceptions as safety fallback. /// /// **Security consideration:** Returns generic error message to prevent information diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java index 5b4a61b..3dac279 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java @@ -196,7 +196,9 @@ public PropertyRules toPropertyRules(PropertyRulesDtoIn dto) { return new PropertyRules( null, dto.getFormat(), - dto.getEnumValues() != null ? List.of(dto.getEnumValues()) : null, + dto.getEnumValues() != null + ? List.of(dto.getEnumValues()).stream().map(String::toUpperCase).toList() + : null, dto.getRegex(), dto.getMaxLength(), dto.getMinLength(), @@ -211,7 +213,6 @@ public PropertyRulesDtoOut toDto(PropertyRules entity) { } return PropertyRulesDtoOut.builder() - .id(entity.id()) .format(entity.format()) .enumValues(entity.enumValues() != null ? entity.enumValues().toArray(new String[0]) : null) .regex(entity.regex()) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8349adc..4b9adad 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,6 +14,9 @@ spring: deserialization: # Fails deserialization if JSON contains unknown fields not mapped to Java properties. fail-on-unknown-properties: true + mapper: + # Allow enum values to be read case-insensitively (e.g., "string", "STRING", "String" all map to STRING). + accept-case-insensitive-enums: true profiles: # Activates a Spring profile from the env var SPRING_PROFILE (defaults to none). diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java similarity index 79% rename from src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java rename to src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java index f03b858..8d5c067 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRulesServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java @@ -7,25 +7,25 @@ import java.util.List; import java.util.UUID; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import com.decathlon.idp_core.domain.exception.PropertyRulesConflictException; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyFormat; import com.decathlon.idp_core.domain.model.enums.PropertyType; -@DisplayName("PropertyRulesService Tests") -class PropertyRulesServiceTest { +@DisplayName("PropertyDefinitionValidationService Tests") +class PropertyDefinitionValidationServiceTest { - private PropertyRulesService propertyRulesService; + private PropertyDefinitionValidationService propertyDefinitionValidationService; @BeforeEach void setUp() { - propertyRulesService = new PropertyRulesService(); + propertyDefinitionValidationService = new PropertyDefinitionValidationService(); } @Nested @@ -54,7 +54,7 @@ void testStringWithValidRules() { rules ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -79,7 +79,7 @@ void testStringWithLengthConstraints() { rules ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -104,7 +104,7 @@ void testStringWithEnumValues() { rules ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -129,7 +129,7 @@ void testStringWithRegex() { rules ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -154,9 +154,9 @@ void testStringRejectsMaxValue() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("name")); assertTrue(ex.getMessage().contains("STRING")); @@ -184,9 +184,9 @@ void testStringRejectsMinValue() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("counter")); assertTrue(ex.getMessage().contains("STRING")); @@ -214,9 +214,9 @@ void testStringWithInvalidLengthConstraints() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("min_length")); assertTrue(ex.getMessage().contains("max_length")); @@ -244,9 +244,9 @@ void testStringWithNegativeMinLength() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("min_length")); assertTrue(ex.getMessage().contains("0")); @@ -274,9 +274,9 @@ void testStringWithInvalidRegexPattern() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("regex")); assertTrue(ex.getMessage().contains("[invalid-regex")); @@ -294,7 +294,7 @@ void testStringWithNullRules() { null ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -319,7 +319,7 @@ void testStringWithZeroMinLength() { rules ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -344,9 +344,9 @@ void testStringWithNonPositiveMaxLength() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("max_length")); assertTrue(ex.getMessage().contains("greater than 0")); @@ -379,7 +379,7 @@ void testNumberWithValidRules() { rules ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -404,7 +404,7 @@ void testNumberWithOnlyMaxValue() { rules ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -429,9 +429,9 @@ void testNumberRejectsFormat() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("value")); assertTrue(ex.getMessage().contains("NUMBER")); @@ -460,9 +460,9 @@ void testNumberRejectsEnumValues() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("enum_values")); } @@ -489,9 +489,9 @@ void testNumberRejectsRegex() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("regex")); } @@ -518,9 +518,9 @@ void testNumberRejectsMinLength() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("min_length")); } @@ -547,9 +547,9 @@ void testNumberRejectsMaxLength() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("max_length")); } @@ -576,9 +576,9 @@ void testNumberWithInvalidValueConstraints() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("min_value")); assertTrue(ex.getMessage().contains("max_value")); @@ -606,7 +606,7 @@ void testNumberWithOnlyMinValue() { rules ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -631,7 +631,7 @@ void testNumberWithNegativeValues() { rules ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -646,7 +646,7 @@ void testNumberWithNullRules() { null ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } } @@ -666,7 +666,7 @@ void testBooleanWithNullRules() { null ); - assertDoesNotThrow(() -> propertyRulesService.validatePropertyRules(property)); + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); } @Test @@ -691,9 +691,9 @@ void testBooleanRejectsFormat() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); assertTrue(ex.getMessage().contains("rules")); @@ -721,9 +721,9 @@ void testBooleanRejectsEnumValues() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); } @@ -750,9 +750,9 @@ void testBooleanRejectsRegex() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); } @@ -779,9 +779,9 @@ void testBooleanRejectsMinValue() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); } @@ -808,9 +808,9 @@ void testBooleanRejectsMaxValue() { rules ); - PropertyRulesConflictException ex = assertThrows( - PropertyRulesConflictException.class, - () -> propertyRulesService.validatePropertyRules(property) + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) ); assertTrue(ex.getMessage().contains("BOOLEAN")); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 4d2a73b..880ade3 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -25,8 +25,8 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import jakarta.validation.ConstraintViolation; diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java index e852d5f..0771e99 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java @@ -319,7 +319,6 @@ void shouldMapPropertyEntityToDtoOut() { assertThat(result.getType()).isEqualTo(PropertyType.STRING); assertThat(result.isRequired()).isTrue(); assertThat(result.getRules()).isNotNull(); - assertThat(result.getRules().getId()).isEqualTo(rules.id()); } @Test @@ -343,7 +342,7 @@ void shouldMapRulesDtoInToEntity() { // Given var dto = PropertyRulesDtoIn.builder() .format(PropertyFormat.URL) - .enumValues(new String[]{"http", "https"}) + .enumValues(new String[]{"HTTP", "HTTPS"}) .regex("^https?://.*") .maxLength(500) .minLength(10) @@ -357,7 +356,7 @@ void shouldMapRulesDtoInToEntity() { // Then assertThat(result).isNotNull(); assertThat(result.format()).isEqualTo(PropertyFormat.URL); - assertThat(result.enumValues()).containsExactly("http", "https"); + assertThat(result.enumValues()).containsExactly("HTTP", "HTTPS"); assertThat(result.regex()).isEqualTo("^https?://.*"); assertThat(result.maxLength()).isEqualTo(500); assertThat(result.minLength()).isEqualTo(10); @@ -365,6 +364,21 @@ void shouldMapRulesDtoInToEntity() { assertThat(result.minValue()).isEqualTo(1); } + @Test + @DisplayName("Should normalize enum_values to uppercase") + void shouldNormalizeEnumValuesToUppercase() { + // Given + var dto = PropertyRulesDtoIn.builder() + .enumValues(new String[]{"EMAil", "postal_code", "ACTIVE"}) + .build(); + + // When + PropertyRules result = mapper.toPropertyRules(dto); + + // Then + assertThat(result.enumValues()).containsExactly("EMAIL", "POSTAL_CODE", "ACTIVE"); + } + @Test @DisplayName("Should handle null PropertyRulesDtoIn") void shouldHandleNullRulesDtoIn() { @@ -395,7 +409,6 @@ void shouldMapRulesEntityToDtoOut() { // Then assertThat(result).isNotNull(); - assertThat(result.getId()).isEqualTo(entity.id()); assertThat(result.getFormat()).isEqualTo(PropertyFormat.URL); assertThat(result.getEnumValues()).containsExactly("http", "https"); assertThat(result.getRegex()).isEqualTo("^https?://.*"); From 071ee12e29dc9ce656f7fb5f3cef627534ac7c9b Mon Sep 17 00:00:00 2001 From: evebrnd Date: Thu, 30 Apr 2026 11:26:24 +0200 Subject: [PATCH 07/15] feat(propertyRules): add STRING rules incompatibility checks --- .../domain/constant/ValidationMessages.java | 9 ++ .../PropertyDefinitionValidationService.java | 94 +++++++++-- ...opertyDefinitionValidationServiceTest.java | 150 ++++++++++++++++++ ...mplateWithoutRelationsDefinitions_201.json | 4 +- .../v1/postEntityTemplate_201.json | 4 +- 5 files changed, 245 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index b1836e4..cdd8358 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -35,6 +35,15 @@ public class ValidationMessages { public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + + // Helper method to construct rules incompatibility message + public static String rulesAreIncompatible(String rule1, String rule2) { + return PROPERTY_RULES_MUTUALLY_EXCLUSIVE + .replace("{rule1}", rule1) + .replace("{rule2}", rule2); + } + // Helper method to construct rule-not-allowed message public static String ruleNotAllowed(String rule, String propertyType) { return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index df8a23f..3ba6207 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -6,12 +6,14 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED; import static com.decathlon.idp_core.domain.constant.ValidationMessages.minMaxConstraintViolated; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.rulesAreIncompatible; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; @@ -73,28 +75,34 @@ public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinitio /// /// **Allowed rules:** format, enum_values, regex, max_length, min_length /// **Rejected rules:** max_value, min_value (numeric) + /// **Conflicting rules:** format, regex, and enum_values are mutually exclusive; + /// enum_values is also mutually exclusive with max_length and min_length /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid /// /// @param propertyName name of the property (for error reporting) /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when numeric rules are present - /// or min/max length constraints are violated or regex is invalid + /// @throws PropertyDefinitionRulesConflictException when rules defined violate any of the above constraints private void validateStringPropertyRules(String propertyName, PropertyRules rules) { - // Reject numeric rules for STRING type - if (rules.maxValue() != null || rules.minValue() != null) { - String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) - ); - } + validateStringIncompatibleRules(propertyName, rules); + validateStringConstraints(propertyName, rules); // Validate regex pattern is valid if (rules.regex() != null && !rules.regex().isBlank()) { validateRegexPattern(propertyName, rules.regex()); } + } + /// Validates numeric constraints for STRING property rules. + /// + /// **Constraints enforced:** + /// - min_length must be non-negative (≥ 0) + /// - max_length must be positive (> 0) + /// - min_length must be less than or equal to max_length + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any constraint is violated + private void validateStringConstraints(String propertyName, PropertyRules rules) { // Validate min_length is non-negative if (rules.minLength() != null && rules.minLength() < 0) { throw new PropertyDefinitionRulesConflictException( @@ -121,6 +129,68 @@ private void validateStringPropertyRules(String propertyName, PropertyRules rule } } + /// Validates rule compatibility and mutual exclusivity for STRING property rules. + /// + /// **Incompatibility rules enforced:** + /// - Numeric rules (max_value, min_value) are not allowed for STRING type + /// - format, regex, and enum_values are mutually exclusive + /// - enum_values and length constraints (max_length, min_length) are mutually exclusive + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when incompatible rules are both present + private void validateStringIncompatibleRules(String propertyName, PropertyRules rules){ + // Reject numeric rules for STRING type + if (rules.maxValue() != null || rules.minValue() != null) { + String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) + ); + } + + // format, regex, and enum_values are incompatible with each other + if (rules.format() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(FORMAT, ENUM_VALUES) + ); + } + if (rules.format() != null && rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(FORMAT, REGEX) + ); + } + if (rules.regex() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(REGEX, ENUM_VALUES) + ); + } + + // enum_values and length constraints are incompatible with each other + if (rules.enumValues() != null && rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH) + ); + } + if (rules.enumValues() != null && rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH) + ); + } + + } + /// Validates rules for NUMBER property type. /// /// **Allowed rules:** max_value, min_value diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java index 8d5c067..0339f86 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java @@ -351,6 +351,156 @@ void testStringWithNonPositiveMaxLength() { assertTrue(ex.getMessage().contains("max_length")); assertTrue(ex.getMessage().contains("greater than 0")); } + + @Test + @DisplayName("Error: STRING with format and enum_values combined") + void testStringRejectsFormatWithEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + List.of("EMAIL", "POSTAL_CODE"), + null, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "contact", + "Contact field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("format")); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: STRING with format and regex combined") + void testStringRejectsFormatWithRegex() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + PropertyFormat.EMAIL, + null, + "^[a-zA-Z]+$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "contact", + "Contact field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("format")); + assertTrue(ex.getMessage().contains("regex")); + } + + @Test + @DisplayName("Error: STRING with regex and enum_values combined") + void testStringRejectsRegexWithEnumValues() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("ACTIVE", "INACTIVE"), + "^[A-Z]+$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "status", + "Status field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("regex")); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: STRING with enum_values and max_length combined") + void testStringRejectsEnumValuesWithMaxLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("EMAIL", "POSTAL_CODE"), + null, + 12, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "contact_type", + "Contact type field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("enum_values")); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: STRING with enum_values and min_length combined") + void testStringRejectsEnumValuesWithMinLength() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + List.of("EMAIL", "POSTAL_CODE"), + null, + null, + 3, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "contact_type", + "Contact type field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("enum_values")); + assertTrue(ex.getMessage().contains("min_length")); + } } @Nested diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json index 07bb170..d7b2bd1 100644 --- a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplateWithoutRelationsDefinitions_201.json @@ -10,8 +10,8 @@ "type": "STRING", "rules": { "format": "URL", - "enum_values": [], - "regex": "", + "enum_values": null, + "regex": null, "max_length": 200, "min_length": 1 } diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json index 5426461..95dc35f 100644 --- a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_201.json @@ -10,8 +10,8 @@ "type": "STRING", "rules": { "format": "URL", - "enum_values": [], - "regex": "", + "enum_values": null, + "regex": null, "max_length": 200, "min_length": 1, "max_value": null, From 00be44c1aca3f8bb3ca0cccf70d002036ef68bfa Mon Sep 17 00:00:00 2001 From: evebrnd Date: Thu, 30 Apr 2026 15:09:12 +0200 Subject: [PATCH 08/15] fix: fix NullPointer exception of entities mapper --- .../api/mapper/entity/EntityDtoOutMapper.java | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 4b7bbc9..2bd2b04 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -159,29 +159,33 @@ private Map mapPropertiesDto(Entity entity, EntityTemplate entit .collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); return entity.properties().stream() + .filter(prop -> prop.value() != null) .collect(Collectors.toMap( Property::name, - prop -> { - PropertyDefinition def = propertiesDefinitions.get(prop.name()); - if (def != null) { - PropertyType type = def.type(); - String value = prop.value(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(value); - } catch (NumberFormatException _) { - return null; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(value); - } - // Default to string - return value; - } else { - // Fallback if propertyDefinition is missing - return prop.value(); - } - })); + prop -> convertPropertyValue(prop, propertiesDefinitions.get(prop.name())))); + } + + /// Converts a property value to its typed representation based on the property definition. + /// + /// @param property the property to convert + /// @param definition the property definition for type information, may be null + /// @return the typed value, falling back to the raw string value + private Object convertPropertyValue(Property property, PropertyDefinition definition) { + String value = property.value(); + if (definition == null) { + return value; + } + PropertyType type = definition.type(); + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(value); + } catch (NumberFormatException _) { + return value; + } + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(value); + } + return value; } /// Maps the relations of an entity to a map of relation names to lists of target From 66d2e88e74d42a795bad02ef7107f1a88c6bb37d Mon Sep 17 00:00:00 2001 From: evebrnd Date: Thu, 30 Apr 2026 17:29:20 +0200 Subject: [PATCH 09/15] refactor: validation functions --- .../EntityTemplateValidationService.java | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 8c5b9aa..2a8f43f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -37,13 +37,11 @@ public class EntityTemplateValidationService { /// @throws EntityTemplateAlreadyExistsException when identifier is already taken /// @throws EntityTemplateNameAlreadyExistsException when name is already taken public void validateForCreate(EntityTemplate entityTemplate) { - if (entityTemplate.identifier() != null && - entityTemplateRepositoryPort.existsByIdentifier(entityTemplate.identifier())) { - throw new EntityTemplateAlreadyExistsException(entityTemplate.identifier()); + if (entityTemplate.identifier() != null) { + validateIdentifierUniqueness(entityTemplate.identifier()); } - if (entityTemplate.name() != null && - entityTemplateRepositoryPort.existsByName(entityTemplate.name())) { - throw new EntityTemplateNameAlreadyExistsException(entityTemplate.name()); + if (entityTemplate.name() != null) { + validateNameUniqueness(entityTemplate.name()); } validatePropertyRules(entityTemplate); } @@ -61,14 +59,11 @@ public void validateForCreate(EntityTemplate entityTemplate) { /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken /// @throws EntityTemplateNameAlreadyExistsException when the new name is already taken public void validateForUpdate(String currentIdentifier, String existingName, EntityTemplate mergedTemplate) { - if (!currentIdentifier.equals(mergedTemplate.identifier()) && - entityTemplateRepositoryPort.existsByIdentifier(mergedTemplate.identifier())) { - throw new EntityTemplateAlreadyExistsException(mergedTemplate.identifier()); + if (!currentIdentifier.equals(mergedTemplate.identifier())) { + validateIdentifierUniqueness(mergedTemplate.identifier()); } - if (mergedTemplate.name() != null && - !Objects.equals(existingName, mergedTemplate.name()) && - entityTemplateRepositoryPort.existsByName(mergedTemplate.name())) { - throw new EntityTemplateNameAlreadyExistsException(mergedTemplate.name()); + if (mergedTemplate.name() != null && !Objects.equals(existingName, mergedTemplate.name())) { + validateNameUniqueness(mergedTemplate.name()); } validatePropertyRules(mergedTemplate); } @@ -87,6 +82,26 @@ public void validateForDelete(String identifier) { } } + /// Checks that no other template already uses the given identifier. + /// + /// @param identifier the identifier to check for uniqueness + /// @throws EntityTemplateAlreadyExistsException when identifier is already taken + private void validateIdentifierUniqueness(String identifier) { + if (entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateAlreadyExistsException(identifier); + } + } + + /// Checks that no other template already uses the given name. + /// + /// @param name the name to check for uniqueness + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + private void validateNameUniqueness(String name) { + if (entityTemplateRepositoryPort.existsByName(name)) { + throw new EntityTemplateNameAlreadyExistsException(name); + } + } + private void validatePropertyRules(EntityTemplate entityTemplate) { if (entityTemplate.propertiesDefinitions() == null) { return; From e02937705c1e753ec9851bf3309dcd32dd5b476b Mon Sep 17 00:00:00 2001 From: evebrnd Date: Mon, 4 May 2026 16:10:15 +0200 Subject: [PATCH 10/15] refactor: add new checkTemplateExists function --- .../EntityTemplateValidationService.java | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 2a8f43f..980a95c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -37,12 +37,8 @@ public class EntityTemplateValidationService { /// @throws EntityTemplateAlreadyExistsException when identifier is already taken /// @throws EntityTemplateNameAlreadyExistsException when name is already taken public void validateForCreate(EntityTemplate entityTemplate) { - if (entityTemplate.identifier() != null) { - validateIdentifierUniqueness(entityTemplate.identifier()); - } - if (entityTemplate.name() != null) { - validateNameUniqueness(entityTemplate.name()); - } + validateIdentifierUniqueness(entityTemplate.identifier()); + validateNameUniqueness(entityTemplate.name()); validatePropertyRules(entityTemplate); } @@ -62,7 +58,7 @@ public void validateForUpdate(String currentIdentifier, String existingName, Ent if (!currentIdentifier.equals(mergedTemplate.identifier())) { validateIdentifierUniqueness(mergedTemplate.identifier()); } - if (mergedTemplate.name() != null && !Objects.equals(existingName, mergedTemplate.name())) { + if (!Objects.equals(existingName, mergedTemplate.name())) { validateNameUniqueness(mergedTemplate.name()); } validatePropertyRules(mergedTemplate); @@ -77,6 +73,14 @@ public void validateForDelete(String identifier) { if (identifier == null) { throw new IllegalArgumentException("Template identifier must not be null"); } + checkTemplateExists(identifier); + } + + /// Checks that the entity template exists. + /// + /// @param identifier the identifier to check for existence + /// @throws EntityTemplateNotFoundException when no template matches `identifier` + public void checkTemplateExists(String identifier) { if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { throw new EntityTemplateNotFoundException("identifier", identifier); } @@ -86,7 +90,7 @@ public void validateForDelete(String identifier) { /// /// @param identifier the identifier to check for uniqueness /// @throws EntityTemplateAlreadyExistsException when identifier is already taken - private void validateIdentifierUniqueness(String identifier) { + public void validateIdentifierUniqueness(String identifier) { if (entityTemplateRepositoryPort.existsByIdentifier(identifier)) { throw new EntityTemplateAlreadyExistsException(identifier); } @@ -96,13 +100,13 @@ private void validateIdentifierUniqueness(String identifier) { /// /// @param name the name to check for uniqueness /// @throws EntityTemplateNameAlreadyExistsException when name is already taken - private void validateNameUniqueness(String name) { + public void validateNameUniqueness(String name) { if (entityTemplateRepositoryPort.existsByName(name)) { throw new EntityTemplateNameAlreadyExistsException(name); } } - private void validatePropertyRules(EntityTemplate entityTemplate) { + public void validatePropertyRules(EntityTemplate entityTemplate) { if (entityTemplate.propertiesDefinitions() == null) { return; } From 30b27d95cbfa10f6a39c74e7f4b950bdd3e80846 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Tue, 5 May 2026 11:50:04 +0200 Subject: [PATCH 11/15] feat: add validation of user-provided regex --- .../domain/constant/ValidationMessages.java | 1 + ...mplateIdentifierCannotChangeException.java | 25 +++ .../EntityTemplateService.java | 4 +- .../EntityTemplateValidationService.java | 14 +- .../PropertyDefinitionValidationService.java | 97 +++++++++- .../api/handler/ApiExceptionHandler.java | 13 ++ ...opertyDefinitionValidationServiceTest.java | 180 ++++++++++++++++++ 7 files changed, 320 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index cdd8358..2bac95d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -9,6 +9,7 @@ public class ValidationMessages { // Entity Template validation messages public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; + public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java new file mode 100644 index 0000000..3d0a149 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_CANNOT_CHANGE; + +/// Exception thrown when attempting to change an [EntityTemplate] identifier after creation. +/// +/// **Why this exception exists:** +/// - Entity template identifiers are immutable once the template is created +/// - Prevents accidental or malicious modifications to template identity +/// - Maintains separation of concerns between domain rules and HTTP status codes +/// +/// **Usage patterns:** +/// - Thrown from [EntityTemplateService] when identifier modification is attempted +/// - Caught by [ApiExceptionHandler] and mapped to HTTP 400 status +/// - Contains the identifier that was attempted to be changed for debugging +public class EntityTemplateIdentifierCannotChangeException extends RuntimeException { + + public EntityTemplateIdentifierCannotChangeException(String identifier) { + super(TEMPLATE_IDENTIFIER_CANNOT_CHANGE + identifier); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java index 5d37df9..06690e2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java @@ -87,7 +87,7 @@ public EntityTemplate getEntityTemplateByIdentifier(String identifier) { /// @throws EntityTemplateNameAlreadyExistsException when name already exists @Transactional public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) { - entityTemplateValidationService.validateForCreate(entityTemplate); + entityTemplateValidationService.validateForCreation(entityTemplate); return entityTemplateRepositoryPort.save(entityTemplate); } @@ -140,7 +140,7 @@ public EntityTemplate updateEntityTemplate(String identifier, @Valid EntityTempl /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public void deleteEntityTemplate(String identifier) { - entityTemplateValidationService.validateForDelete(identifier); + entityTemplateValidationService.validateForDeletion(identifier); entityTemplateRepositoryPort.deleteByIdentifier(identifier); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 980a95c..897cd95 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -2,6 +2,8 @@ import java.util.Objects; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; @@ -36,7 +38,8 @@ public class EntityTemplateValidationService { /// @param entityTemplate the template candidate to validate /// @throws EntityTemplateAlreadyExistsException when identifier is already taken /// @throws EntityTemplateNameAlreadyExistsException when name is already taken - public void validateForCreate(EntityTemplate entityTemplate) { + /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants + public void validateForCreation(EntityTemplate entityTemplate) { validateIdentifierUniqueness(entityTemplate.identifier()); validateNameUniqueness(entityTemplate.name()); validatePropertyRules(entityTemplate); @@ -54,9 +57,10 @@ public void validateForCreate(EntityTemplate entityTemplate) { /// @param mergedTemplate the fully-merged template carrying the desired state /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken /// @throws EntityTemplateNameAlreadyExistsException when the new name is already taken + /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants public void validateForUpdate(String currentIdentifier, String existingName, EntityTemplate mergedTemplate) { if (!currentIdentifier.equals(mergedTemplate.identifier())) { - validateIdentifierUniqueness(mergedTemplate.identifier()); + throw new EntityTemplateIdentifierCannotChangeException(mergedTemplate.identifier()); } if (!Objects.equals(existingName, mergedTemplate.name())) { validateNameUniqueness(mergedTemplate.name()); @@ -69,18 +73,18 @@ public void validateForUpdate(String currentIdentifier, String existingName, Ent /// @param identifier the identifier of the template to delete /// @throws IllegalArgumentException when `identifier` is null /// @throws EntityTemplateNotFoundException when no template matches `identifier` - public void validateForDelete(String identifier) { + public void validateForDeletion(String identifier) { if (identifier == null) { throw new IllegalArgumentException("Template identifier must not be null"); } - checkTemplateExists(identifier); + validateTemplateExists(identifier); } /// Checks that the entity template exists. /// /// @param identifier the identifier to check for existence /// @throws EntityTemplateNotFoundException when no template matches `identifier` - public void checkTemplateExists(String identifier) { + public void validateTemplateExists(String identifier) { if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { throw new EntityTemplateNotFoundException("identifier", identifier); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index 3ba6207..50599f6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -8,6 +8,12 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; import static com.decathlon.idp_core.domain.constant.ValidationMessages.rulesAreIncompatible; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -28,6 +34,18 @@ @Service public class PropertyDefinitionValidationService { + // Static and thread-safe regex validator executor + private static final ExecutorService VALIDATION_EXECUTOR = Executors.newFixedThreadPool( + Runtime.getRuntime().availableProcessors(), + runnable -> { + Thread thread = new Thread(runnable, "regex-validator-thread"); + thread.setDaemon(true); + return thread; + } + ); + // Validation ReDoS probe string designed to trigger backtracking in vulnerable patterns + private static final String STRESS_PROBE = "a".repeat(50) + "!"; + // Rule name constants public static final String REGEX = "regex"; public static final String LENGTH = "length"; @@ -276,21 +294,86 @@ private void validateBooleanPropertyRules(String propertyName, PropertyRules rul } } - /// Validates that the provided regex pattern is syntactically valid. + /// Validates the user-provided regex pattern against ReDoS and injection risks. + /// + /// **Security checks:** + /// 1. Rejects patterns exceeding 1,000 characters. + /// 2. Rejects known dangerous regex patterns. + /// 3. Ensures the pattern is valid Java regex. + /// 4. Detects ReDoS by executing pattern matching within 100ms timeout. /// /// @param propertyName name of the property (for error reporting) /// @param regexPattern the regex pattern to validate - /// @throws PropertyDefinitionRulesConflictException if the pattern is syntactically invalid + /// @throws PropertyDefinitionRulesConflictException if any security check fails private void validateRegexPattern(String propertyName, String regexPattern) { + if (regexPattern.length() > 1000) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern too long (max 1,000 characters)"); + } + + if (containsDangerousPatterns(regexPattern)) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern contains potentially unsafe constructs"); + } + + Pattern compiledRegexPattern; try { - Pattern.compile(regexPattern); + compiledRegexPattern = Pattern.compile(regexPattern); } catch (PatternSyntaxException e) { throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - "Invalid regex pattern: " + e.getMessage() - ); + propertyName, PropertyType.STRING, "Invalid regex pattern: " + e.getMessage()); + } + + validatePatternWithTimeout(propertyName, compiledRegexPattern); + } + + /// Validates pattern matching with a timeout to detect ReDoS (Regular Expression Denial of Service) vulnerabilities. + /// + /// Executes a pattern match against a stress probe within a 100 ms timeout using a shared, bounded executor + /// If the pattern takes longer than the timeout, it is rejected as potentially vulnerable to catastrophic backtracking. + /// + /// @param propertyName name of the property (for error reporting) + /// @param pattern the compiled pattern to test + /// @throws PropertyDefinitionRulesConflictException if the pattern times out or validation fails + private void validatePatternWithTimeout(String propertyName, Pattern pattern) { + Future future = VALIDATION_EXECUTOR.submit(() -> pattern.matcher(STRESS_PROBE).matches()); + try { + future.get(100, TimeUnit.MILLISECONDS); + } catch (TimeoutException _) { + future.cancel(true); + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern validation was interrupted"); + } catch (ExecutionException e) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex validation failed: " + e.getCause().getMessage()); } } + /// Checks for known dangerous regex constructs. + /// + /// - Nested quantifiers: `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// - Quantified alternation groups: `(a|b)+`, `(a|b)*. + /// - Unbounded repetition upper bounds greater than 1 000 (e.g. `{5,9999}`). + /// + /// @param pattern the raw regex string to analyse + /// @return `true` if the pattern contains at least one dangerous construct + private boolean containsDangerousPatterns(String pattern) { + // Nested quantifiers + if (pattern.matches(".*\\([^)]*[+*]\\)[+*].*")) { + return true; + } + + // Quantified alternation groups + if (pattern.matches(".*\\([^)]*\\|[^)]*\\)[+*].*")) { + return true; + } + + // Repetition upper bound > 1000 + return pattern.matches(".*\\{\\d*,(\\d{4,}|[1-9]\\d{3,})\\}.*"); + } + } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 48c1af4..ab13133 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -15,6 +15,7 @@ import com.decathlon.idp_core.domain.exception.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; @@ -84,6 +85,18 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } + /// Handles domain exception when attempting to change an entity template identifier. + /// + /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException to HTTP 400 + /// status indicating validation error for immutable identifier field. + @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) + public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( + EntityTemplateIdentifierCannotChangeException ex) { + log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + /// Handles domain exception for wrong entity template property rules. /// /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to HTTP 400 diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java index 0339f86..1e78e12 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java @@ -12,6 +12,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; @@ -501,6 +503,184 @@ void testStringRejectsEnumValuesWithMinLength() { assertTrue(ex.getMessage().contains("enum_values")); assertTrue(ex.getMessage().contains("min_length")); } + + @Nested + @DisplayName("Regex Validation Security Tests") + class RegexSecurityTests { + + @Test + @DisplayName("Happy path: STRING with simple valid regex") + void testRegexWithSimpleValidPattern() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "^[a-z0-9]+@[a-z0-9]+\\.[a-z]{2,}$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "email", + "Email field", + PropertyType.STRING, + true, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: Regex pattern exceeds maximum length (1000 chars)") + void testRegexPatternTooLong() { + String longPattern = "a".repeat(1001); + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + longPattern, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("too long")); + assertTrue(ex.getMessage().contains("1,000")); + } + + @ParameterizedTest + @ValueSource(strings = { + "(a+)+", // nested quantifiers with + + "(a*)*", // nested quantifiers with * + "(a+)*", // mixed nested quantifiers + "(a|b)+", // quantified alternation with + + "(foo|bar)*", // quantified alternation with * + "a{1,5000}" // excessive repetition bound + }) + @DisplayName("Error: Regex patterns with ReDoS vulnerabilities") + void testRegexWithDangerousPatterns(String dangerousPattern) { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + dangerousPattern, + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("unsafe"), + "Expected 'unsafe' in error message for pattern: " + dangerousPattern); + } + + @Test + @DisplayName("Happy path: Regex with safe alternation (not quantified)") + void testRegexWithSafeAlternation() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "^(foo|bar)$", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: Regex with safe repetition bounds") + void testRegexWithSafeRepetitionBounds() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "a{1,999}", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: Regex with invalid syntax") + void testRegexWithInvalidSyntax() { + PropertyRules rules = new PropertyRules( + UUID.randomUUID(), + null, + null, + "[unclosed-bracket", + null, + null, + null, + null + ); + PropertyDefinition property = new PropertyDefinition( + UUID.randomUUID(), + "field", + "A field", + PropertyType.STRING, + true, + rules + ); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) + ); + assertTrue(ex.getMessage().contains("Invalid regex")); + } + } } @Nested From 23d2ad25ae1390d9cc8369e0060f41399701a8c1 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Tue, 5 May 2026 16:43:24 +0200 Subject: [PATCH 12/15] feat: add static string analysis of user-provided regex --- .../PropertyDefinitionValidationService.java | 227 +++++++++++++++++- 1 file changed, 215 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index 50599f6..66e007f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -14,6 +14,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -338,7 +339,7 @@ private void validateRegexPattern(String propertyName, String regexPattern) { private void validatePatternWithTimeout(String propertyName, Pattern pattern) { Future future = VALIDATION_EXECUTOR.submit(() -> pattern.matcher(STRESS_PROBE).matches()); try { - future.get(100, TimeUnit.MILLISECONDS); + future.get(10, TimeUnit.MILLISECONDS); } catch (TimeoutException _) { future.cancel(true); throw new PropertyDefinitionRulesConflictException( @@ -353,27 +354,229 @@ private void validatePatternWithTimeout(String propertyName, Pattern pattern) { } } - /// Checks for known dangerous regex constructs. + /// Checks for known dangerous regex constructs using static string analysis. /// + /// **Patterns detected:** /// - Nested quantifiers: `(a+)+`, `(a*)*`, `(a+)*`, etc. - /// - Quantified alternation groups: `(a|b)+`, `(a|b)*. - /// - Unbounded repetition upper bounds greater than 1 000 (e.g. `{5,9999}`). + /// - Quantified alternation groups: `(a|b)+`, `(a|b)*` + /// - Unbounded repetition upper bounds greater than 1,000 (e.g. `{5,9999}`) + /// - Lookarounds with quantifiers: `(?=a+)`, `(?!a*)` + /// + /// **Implementation:** Uses static string analysis without regex matching + /// to avoid ReDoS vulnerabilities in the validator itself. /// /// @param pattern the raw regex string to analyse - /// @return `true` if the pattern contains at least one dangerous construct + /// @return `true` if potentially dangerous constructs are detected private boolean containsDangerousPatterns(String pattern) { - // Nested quantifiers - if (pattern.matches(".*\\([^)]*[+*]\\)[+*].*")) { - return true; + return hasNestedQuantifiers(pattern) || + hasQuantifiedAlternation(pattern) || + hasLargeRepetitionBounds(pattern) || + hasLookaroundsWithQuantifiers(pattern); + } + + /// Detects nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if nested quantifiers are found + private boolean hasNestedQuantifiers(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, this::containsQuantifier)) { + return true; + } + } + return false; + } + + /// Checks if a group starting at index i matches the quantified pattern criteria. + /// The pattern must have a closing paren followed by a quantifier (+, *, ?, {), + /// and the group content must match the provided test. + /// + /// @param pattern the regex pattern string + /// @param groupStartIndex the index of the opening parenthesis + /// @param test the test to apply to group content + /// @return true if the group matches the criteria + private boolean matchesQuantifiedGroup(String pattern, int groupStartIndex, Predicate test) { + int closeIdx = findMatchingCloseParenthesis(pattern, groupStartIndex); + if (closeIdx == -1 || closeIdx + 1 >= pattern.length()) { + return false; + } + + char nextChar = pattern.charAt(closeIdx + 1); + if (!isQuantifier(nextChar)) { + return false; + } + + String groupContent = pattern.substring(groupStartIndex + 1, closeIdx); + return test.test(groupContent); + } + + /// Detects quantified alternation groups like `(a|b)+` or `(a|b)*`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if quantified alternation is found + private boolean hasQuantifiedAlternation(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, groupContent -> groupContent.contains("|"))) { + return true; + } + } + return false; + } + + /// Detects repetition bounds with excessively large upper limits like `{5,9999}`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if large repetition bounds are found + private boolean hasLargeRepetitionBounds(String pattern) { + int i = 0; + while (i < pattern.length()) { + if (pattern.charAt(i) == '{') { + if (isLargeRepetitionBound(pattern, i)) { + return true; + } + int closeIdx = pattern.indexOf('}', i); + i = closeIdx != -1 ? closeIdx + 1 : i + 1; + } else { + i++; + } + } + return false; + } + + /// Checks if the repetition bound starting at position i exceeds the safe limit. + /// + /// @param pattern the regex pattern string + /// @param startIndex the index of the opening brace + /// @return true if the upper bound is greater than 1000 + private boolean isLargeRepetitionBound(String pattern, int startIndex) { + int closeIdx = pattern.indexOf('}', startIndex); + if (closeIdx == -1) { + return false; + } + + String bounds = pattern.substring(startIndex + 1, closeIdx); + return hasExcessiveUpperBound(bounds); + } + + /// Parses a repetition bound string and checks if the upper limit exceeds 1000. + /// + /// @param bounds the bounds string (e.g., "5,9999" or "1,100") + /// @return true if upper bound is greater than 1000 + private boolean hasExcessiveUpperBound(String bounds) { + if (!bounds.contains(",")) { + return false; } - // Quantified alternation groups - if (pattern.matches(".*\\([^)]*\\|[^)]*\\)[+*].*")) { + String[] parts = bounds.split(","); + if (parts.length != 2 || parts[1].trim().isEmpty()) { + return false; + } + + try { + int upper = Integer.parseInt(parts[1].trim()); + return upper > 1000; + } catch (NumberFormatException _) { + return false; + } + } + + /// Detects lookarounds with quantifiers like `(?=a+)`, `(?!a*)`, etc. + /// These can amplify backtracking behavior and pose ReDoS risks. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if lookarounds with quantifiers are found + private boolean hasLookaroundsWithQuantifiers(String pattern) { + for (int i = 0; i < pattern.length() - 3; i++) { + if (isLookaroundAt(pattern, i)) { + int closeIdx = findMatchingCloseParenthesis(pattern, i); + if (closeIdx != -1) { + String lookaroundContent = pattern.substring(i, closeIdx + 1); + if (containsQuantifier(lookaroundContent)) { + return true; + } + } + } + } + return false; + } + + /// Checks if position i in pattern is the start of a lookaround construct. + /// Lookarounds are: `(?=...)`, `(?!...)`, `(?<=...)`, `(? 1000 - return pattern.matches(".*\\{\\d*,(\\d{4,}|[1-9]\\d{3,})\\}.*"); + // Lookbehind: (?<= or (? Date: Tue, 5 May 2026 18:03:18 +0200 Subject: [PATCH 13/15] refactor: add dedicated PropertyRegexValidationService --- .../PropertyDefinitionValidationService.java | 328 +----------------- .../PropertyRegexValidationService.java | 324 +++++++++++++++++ ...opertyDefinitionValidationServiceTest.java | 182 +--------- .../PropertyRegexValidationServiceTest.java | 83 +++++ 4 files changed, 423 insertions(+), 494 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index 66e007f..9ce60f1 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -1,5 +1,12 @@ package com.decathlon.idp_core.domain.service.entity_template; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_BOOLEAN_NOT_ALLOWED; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MAX_LENGTH_POSITIVE; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE; @@ -8,23 +15,6 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; import static com.decathlon.idp_core.domain.constant.ValidationMessages.rulesAreIncompatible; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.Predicate; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; - -import org.springframework.stereotype.Service; - -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; -import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; -import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; -import com.decathlon.idp_core.domain.model.enums.PropertyType; - /// Domain service for validating property rule compatibility with property types. /// /// **Business rules:** @@ -32,20 +22,16 @@ /// - NUMBER: Allows max_value, min_value. Rejects string and format rules. /// - BOOLEAN: Rejects all rules; rules field must be null or empty. /// +/// **Key responsibilities:** +/// - Type-to-rule compatibility validation +/// - Constraint ordering validation (min ≤ max) +/// - Regex pattern validation (delegated to [PropertyRegexValidationService]) +/// @Service +@RequiredArgsConstructor public class PropertyDefinitionValidationService { - // Static and thread-safe regex validator executor - private static final ExecutorService VALIDATION_EXECUTOR = Executors.newFixedThreadPool( - Runtime.getRuntime().availableProcessors(), - runnable -> { - Thread thread = new Thread(runnable, "regex-validator-thread"); - thread.setDaemon(true); - return thread; - } - ); - // Validation ReDoS probe string designed to trigger backtracking in vulnerable patterns - private static final String STRESS_PROBE = "a".repeat(50) + "!"; + private final PropertyRegexValidationService propertyRegexValidationService; // Rule name constants public static final String REGEX = "regex"; @@ -107,7 +93,7 @@ private void validateStringPropertyRules(String propertyName, PropertyRules rule // Validate regex pattern is valid if (rules.regex() != null && !rules.regex().isBlank()) { - validateRegexPattern(propertyName, rules.regex()); + propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); } } @@ -295,288 +281,4 @@ private void validateBooleanPropertyRules(String propertyName, PropertyRules rul } } - /// Validates the user-provided regex pattern against ReDoS and injection risks. - /// - /// **Security checks:** - /// 1. Rejects patterns exceeding 1,000 characters. - /// 2. Rejects known dangerous regex patterns. - /// 3. Ensures the pattern is valid Java regex. - /// 4. Detects ReDoS by executing pattern matching within 100ms timeout. - /// - /// @param propertyName name of the property (for error reporting) - /// @param regexPattern the regex pattern to validate - /// @throws PropertyDefinitionRulesConflictException if any security check fails - private void validateRegexPattern(String propertyName, String regexPattern) { - if (regexPattern.length() > 1000) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern too long (max 1,000 characters)"); - } - - if (containsDangerousPatterns(regexPattern)) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern contains potentially unsafe constructs"); - } - - Pattern compiledRegexPattern; - try { - compiledRegexPattern = Pattern.compile(regexPattern); - } catch (PatternSyntaxException e) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Invalid regex pattern: " + e.getMessage()); - } - - validatePatternWithTimeout(propertyName, compiledRegexPattern); - } - - /// Validates pattern matching with a timeout to detect ReDoS (Regular Expression Denial of Service) vulnerabilities. - /// - /// Executes a pattern match against a stress probe within a 100 ms timeout using a shared, bounded executor - /// If the pattern takes longer than the timeout, it is rejected as potentially vulnerable to catastrophic backtracking. - /// - /// @param propertyName name of the property (for error reporting) - /// @param pattern the compiled pattern to test - /// @throws PropertyDefinitionRulesConflictException if the pattern times out or validation fails - private void validatePatternWithTimeout(String propertyName, Pattern pattern) { - Future future = VALIDATION_EXECUTOR.submit(() -> pattern.matcher(STRESS_PROBE).matches()); - try { - future.get(10, TimeUnit.MILLISECONDS); - } catch (TimeoutException _) { - future.cancel(true); - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); - } catch (InterruptedException _) { - Thread.currentThread().interrupt(); - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern validation was interrupted"); - } catch (ExecutionException e) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex validation failed: " + e.getCause().getMessage()); - } - } - - /// Checks for known dangerous regex constructs using static string analysis. - /// - /// **Patterns detected:** - /// - Nested quantifiers: `(a+)+`, `(a*)*`, `(a+)*`, etc. - /// - Quantified alternation groups: `(a|b)+`, `(a|b)*` - /// - Unbounded repetition upper bounds greater than 1,000 (e.g. `{5,9999}`) - /// - Lookarounds with quantifiers: `(?=a+)`, `(?!a*)` - /// - /// **Implementation:** Uses static string analysis without regex matching - /// to avoid ReDoS vulnerabilities in the validator itself. - /// - /// @param pattern the raw regex string to analyse - /// @return `true` if potentially dangerous constructs are detected - private boolean containsDangerousPatterns(String pattern) { - return hasNestedQuantifiers(pattern) || - hasQuantifiedAlternation(pattern) || - hasLargeRepetitionBounds(pattern) || - hasLookaroundsWithQuantifiers(pattern); - } - - /// Detects nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`, etc. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if nested quantifiers are found - private boolean hasNestedQuantifiers(String pattern) { - for (int i = 0; i < pattern.length(); i++) { - if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, this::containsQuantifier)) { - return true; - } - } - return false; - } - - /// Checks if a group starting at index i matches the quantified pattern criteria. - /// The pattern must have a closing paren followed by a quantifier (+, *, ?, {), - /// and the group content must match the provided test. - /// - /// @param pattern the regex pattern string - /// @param groupStartIndex the index of the opening parenthesis - /// @param test the test to apply to group content - /// @return true if the group matches the criteria - private boolean matchesQuantifiedGroup(String pattern, int groupStartIndex, Predicate test) { - int closeIdx = findMatchingCloseParenthesis(pattern, groupStartIndex); - if (closeIdx == -1 || closeIdx + 1 >= pattern.length()) { - return false; - } - - char nextChar = pattern.charAt(closeIdx + 1); - if (!isQuantifier(nextChar)) { - return false; - } - - String groupContent = pattern.substring(groupStartIndex + 1, closeIdx); - return test.test(groupContent); - } - - /// Detects quantified alternation groups like `(a|b)+` or `(a|b)*`. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if quantified alternation is found - private boolean hasQuantifiedAlternation(String pattern) { - for (int i = 0; i < pattern.length(); i++) { - if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, groupContent -> groupContent.contains("|"))) { - return true; - } - } - return false; - } - - /// Detects repetition bounds with excessively large upper limits like `{5,9999}`. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if large repetition bounds are found - private boolean hasLargeRepetitionBounds(String pattern) { - int i = 0; - while (i < pattern.length()) { - if (pattern.charAt(i) == '{') { - if (isLargeRepetitionBound(pattern, i)) { - return true; - } - int closeIdx = pattern.indexOf('}', i); - i = closeIdx != -1 ? closeIdx + 1 : i + 1; - } else { - i++; - } - } - return false; - } - - /// Checks if the repetition bound starting at position i exceeds the safe limit. - /// - /// @param pattern the regex pattern string - /// @param startIndex the index of the opening brace - /// @return true if the upper bound is greater than 1000 - private boolean isLargeRepetitionBound(String pattern, int startIndex) { - int closeIdx = pattern.indexOf('}', startIndex); - if (closeIdx == -1) { - return false; - } - - String bounds = pattern.substring(startIndex + 1, closeIdx); - return hasExcessiveUpperBound(bounds); - } - - /// Parses a repetition bound string and checks if the upper limit exceeds 1000. - /// - /// @param bounds the bounds string (e.g., "5,9999" or "1,100") - /// @return true if upper bound is greater than 1000 - private boolean hasExcessiveUpperBound(String bounds) { - if (!bounds.contains(",")) { - return false; - } - - String[] parts = bounds.split(","); - if (parts.length != 2 || parts[1].trim().isEmpty()) { - return false; - } - - try { - int upper = Integer.parseInt(parts[1].trim()); - return upper > 1000; - } catch (NumberFormatException _) { - return false; - } - } - - /// Detects lookarounds with quantifiers like `(?=a+)`, `(?!a*)`, etc. - /// These can amplify backtracking behavior and pose ReDoS risks. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if lookarounds with quantifiers are found - private boolean hasLookaroundsWithQuantifiers(String pattern) { - for (int i = 0; i < pattern.length() - 3; i++) { - if (isLookaroundAt(pattern, i)) { - int closeIdx = findMatchingCloseParenthesis(pattern, i); - if (closeIdx != -1) { - String lookaroundContent = pattern.substring(i, closeIdx + 1); - if (containsQuantifier(lookaroundContent)) { - return true; - } - } - } - } - return false; - } - - /// Checks if position i in pattern is the start of a lookaround construct. - /// Lookarounds are: `(?=...)`, `(?!...)`, `(?<=...)`, `(? { + Thread thread = new Thread(runnable, "regex-validator-thread"); + thread.setDaemon(true); + return thread; + } + ); + // Validation ReDoS probe string designed to trigger backtracking in vulnerable patterns + private static final String STRESS_PROBE = "a".repeat(50) + "!"; + + /// Validates the user-provided regex pattern against ReDoS and injection risks. + /// + /// **Security checks:** + /// 1. Rejects patterns exceeding 1,000 characters. + /// 2. Rejects known dangerous regex patterns. + /// 3. Ensures the pattern is valid Java regex. + /// 4. Detects ReDoS by executing pattern matching within 10ms timeout. + /// + /// @param propertyName name of the property (for error reporting) + /// @param regexPattern the regex pattern to validate + /// @throws PropertyDefinitionRulesConflictException if any security check fails + public void validateRegexPattern(String propertyName, String regexPattern) { + if (regexPattern.length() > 1000) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern too long (max 1,000 characters)"); + } + + if (containsDangerousPatterns(regexPattern)) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern contains potentially unsafe constructs"); + } + + Pattern compiledRegexPattern; + try { + compiledRegexPattern = Pattern.compile(regexPattern); + } catch (PatternSyntaxException e) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Invalid regex pattern: " + e.getMessage()); + } + + validatePatternWithTimeout(propertyName, compiledRegexPattern); + } + + /// Validates pattern matching with a timeout to detect ReDoS (Regular Expression Denial of Service) vulnerabilities. + /// + /// Executes a pattern match against a stress probe within a 10 ms timeout using a shared, bounded executor + /// If the pattern takes longer than the timeout, it is rejected as potentially vulnerable to catastrophic backtracking. + /// + /// @param propertyName name of the property (for error reporting) + /// @param pattern the compiled pattern to test + /// @throws PropertyDefinitionRulesConflictException if the pattern times out or validation fails + private void validatePatternWithTimeout(String propertyName, Pattern pattern) { + Future future = VALIDATION_EXECUTOR.submit(() -> pattern.matcher(STRESS_PROBE).matches()); + try { + future.get(10, TimeUnit.MILLISECONDS); + } catch (TimeoutException _) { + future.cancel(true); + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex pattern validation was interrupted"); + } catch (ExecutionException e) { + throw new PropertyDefinitionRulesConflictException( + propertyName, PropertyType.STRING, "Regex validation failed: " + e.getCause().getMessage()); + } + } + + /// Checks for known dangerous regex constructs using static string analysis. + /// + /// **Patterns detected:** + /// - Nested quantifiers: `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// - Quantified alternation groups: `(a|b)+`, `(a|b)*` + /// - Unbounded repetition upper bounds greater than 1,000 (e.g. `{5,9999}`) + /// - Lookarounds with quantifiers: `(?=a+)`, `(?!a*)` + /// + /// **Implementation:** Uses static string analysis without regex matching + /// to avoid ReDoS vulnerabilities in the validator itself. + /// + /// @param pattern the raw regex string to analyse + /// @return `true` if potentially dangerous constructs are detected + private boolean containsDangerousPatterns(String pattern) { + return hasNestedQuantifiers(pattern) || + hasQuantifiedAlternation(pattern) || + hasLargeRepetitionBounds(pattern) || + hasLookaroundsWithQuantifiers(pattern); + } + + /// Detects nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if nested quantifiers are found + private boolean hasNestedQuantifiers(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, this::containsQuantifier)) { + return true; + } + } + return false; + } + + /// Checks if a group starting at index i matches the quantified pattern criteria. + /// The pattern must have a closing paren followed by a quantifier (+, *, ?, {), + /// and the group content must match the provided test. + /// + /// @param pattern the regex pattern string + /// @param groupStartIndex the index of the opening parenthesis + /// @param test the test to apply to group content + /// @return true if the group matches the criteria + private boolean matchesQuantifiedGroup(String pattern, int groupStartIndex, Predicate test) { + int closeIdx = findMatchingCloseParenthesis(pattern, groupStartIndex); + if (closeIdx == -1 || closeIdx + 1 >= pattern.length()) { + return false; + } + + char nextChar = pattern.charAt(closeIdx + 1); + if (!isQuantifier(nextChar)) { + return false; + } + + String groupContent = pattern.substring(groupStartIndex + 1, closeIdx); + return test.test(groupContent); + } + + /// Detects quantified alternation groups like `(a|b)+` or `(a|b)*`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if quantified alternation is found + private boolean hasQuantifiedAlternation(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, groupContent -> groupContent.contains("|"))) { + return true; + } + } + return false; + } + + /// Detects repetition bounds with excessively large upper limits like `{5,9999}`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if large repetition bounds are found + private boolean hasLargeRepetitionBounds(String pattern) { + int i = 0; + while (i < pattern.length()) { + if (pattern.charAt(i) == '{') { + if (isLargeRepetitionBound(pattern, i)) { + return true; + } + int closeIdx = pattern.indexOf('}', i); + i = closeIdx != -1 ? closeIdx + 1 : i + 1; + } else { + i++; + } + } + return false; + } + + /// Checks if the repetition bound starting at position i exceeds the safe limit. + /// + /// @param pattern the regex pattern string + /// @param startIndex the index of the opening brace + /// @return true if the upper bound is greater than 1000 + private boolean isLargeRepetitionBound(String pattern, int startIndex) { + int closeIdx = pattern.indexOf('}', startIndex); + if (closeIdx == -1) { + return false; + } + + String bounds = pattern.substring(startIndex + 1, closeIdx); + return hasExcessiveUpperBound(bounds); + } + + /// Parses a repetition bound string and checks if the upper limit exceeds 1000. + /// + /// @param bounds the bounds string (e.g., "5,9999" or "1,100") + /// @return true if upper bound is greater than 1000 + private boolean hasExcessiveUpperBound(String bounds) { + if (!bounds.contains(",")) { + return false; + } + + String[] parts = bounds.split(","); + if (parts.length != 2 || parts[1].trim().isEmpty()) { + return false; + } + + try { + int upper = Integer.parseInt(parts[1].trim()); + return upper > 1000; + } catch (NumberFormatException _) { + return false; + } + } + + /// Detects lookarounds with quantifiers like `(?=a+)`, `(?!a*)`, etc. + /// These can amplify backtracking behavior and pose ReDoS risks. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if lookarounds with quantifiers are found + private boolean hasLookaroundsWithQuantifiers(String pattern) { + for (int i = 0; i < pattern.length() - 3; i++) { + if (isLookaroundAt(pattern, i)) { + int closeIdx = findMatchingCloseParenthesis(pattern, i); + if (closeIdx != -1) { + String lookaroundContent = pattern.substring(i, closeIdx + 1); + if (containsQuantifier(lookaroundContent)) { + return true; + } + } + } + } + return false; + } + + /// Checks if position i in pattern is the start of a lookaround construct. + /// Lookarounds are: `(?=...)`, `(?!...)`, `(?<=...)`, `(? propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: Regex pattern exceeds maximum length (1000 chars)") - void testRegexPatternTooLong() { - String longPattern = "a".repeat(1001); - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - longPattern, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("too long")); - assertTrue(ex.getMessage().contains("1,000")); - } - - @ParameterizedTest - @ValueSource(strings = { - "(a+)+", // nested quantifiers with + - "(a*)*", // nested quantifiers with * - "(a+)*", // mixed nested quantifiers - "(a|b)+", // quantified alternation with + - "(foo|bar)*", // quantified alternation with * - "a{1,5000}" // excessive repetition bound - }) - @DisplayName("Error: Regex patterns with ReDoS vulnerabilities") - void testRegexWithDangerousPatterns(String dangerousPattern) { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - dangerousPattern, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("unsafe"), - "Expected 'unsafe' in error message for pattern: " + dangerousPattern); - } - - @Test - @DisplayName("Happy path: Regex with safe alternation (not quantified)") - void testRegexWithSafeAlternation() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - "^(foo|bar)$", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: Regex with safe repetition bounds") - void testRegexWithSafeRepetitionBounds() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - "a{1,999}", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: Regex with invalid syntax") - void testRegexWithInvalidSyntax() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - "[unclosed-bracket", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("Invalid regex")); - } - } } @Nested diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java new file mode 100644 index 0000000..2ee7da0 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java @@ -0,0 +1,83 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; + +@DisplayName("PropertyRegexValidationService Tests") +class PropertyRegexValidationServiceTest { + + private PropertyRegexValidationService propertyRegexValidationService; + + @BeforeEach + void setUp() { + propertyRegexValidationService = new PropertyRegexValidationService(); + } + + @ParameterizedTest + @ValueSource(strings = { + "^[a-z0-9]+@[a-z0-9]+\\.[a-z]{2,}$", // email-like pattern + "^(foo|bar)$", // safe alternation, not quantified + "a{1,999}", // safe repetition bound + "^[a-zA-Z0-9_-]+$", // alphanumeric slug + "^\\d{4}-\\d{2}-\\d{2}$" // ISO date + }) + @DisplayName("Happy path: safe regex patterns are accepted") + void testSafeRegexPatternsAccepted(String safePattern) { + assertDoesNotThrow(() -> propertyRegexValidationService.validateRegexPattern("field", safePattern)); + } + + @Test + @DisplayName("Error: Regex pattern exceeds maximum length (1000 chars)") + void testRegexPatternTooLong() { + String longPattern = "a".repeat(1001); + String propertyName = "field"; + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern(propertyName, longPattern) + ); + assertTrue(ex.getMessage().contains("too long")); + assertTrue(ex.getMessage().contains("1,000")); + } + + @ParameterizedTest + @ValueSource(strings = { + "(a+)+", // nested quantifiers with + + "(a*)*", // nested quantifiers with * + "(a+)*", // mixed nested quantifiers + "(a|b)+", // quantified alternation with + + "(foo|bar)*", // quantified alternation with * + "a{1,5000}" // excessive repetition bound + }) + @DisplayName("Error: Regex patterns with ReDoS vulnerabilities") + void testRegexWithDangerousPatterns(String dangerousPattern) { + String propertyName = "field"; + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern(propertyName, dangerousPattern) + ); + assertTrue(ex.getMessage().contains("unsafe"), + "Expected 'unsafe' in error message for pattern: " + dangerousPattern); + } + + @Test + @DisplayName("Error: Regex with invalid syntax") + void testRegexWithInvalidSyntax() { + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern("field", "[unclosed-bracket") + ); + assertTrue(ex.getMessage().contains("Invalid regex")); + } + +} From 762b7ca1eda9998c99be99b6e65c89ab6de6c88e Mon Sep 17 00:00:00 2001 From: evebrnd Date: Wed, 6 May 2026 10:04:14 +0200 Subject: [PATCH 14/15] feat: modify timeout to 30ms --- .../PropertyRegexValidationService.java | 34 ++++++++++--------- .../PropertyRegexValidationServiceTest.java | 2 +- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java index 9e41884..2d70bc1 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java @@ -35,6 +35,8 @@ public class PropertyRegexValidationService { return thread; } ); + private static final int MAX_REGEX_LENGTH = 1000; + private static final int VALIDATION_TIMEOUT_MS = 30; // Validation ReDoS probe string designed to trigger backtracking in vulnerable patterns private static final String STRESS_PROBE = "a".repeat(50) + "!"; @@ -50,9 +52,9 @@ public class PropertyRegexValidationService { /// @param regexPattern the regex pattern to validate /// @throws PropertyDefinitionRulesConflictException if any security check fails public void validateRegexPattern(String propertyName, String regexPattern) { - if (regexPattern.length() > 1000) { + if (regexPattern.length() > MAX_REGEX_LENGTH) { throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern too long (max 1,000 characters)"); + propertyName, PropertyType.STRING, "Regex pattern too long (max " + MAX_REGEX_LENGTH + " characters)"); } if (containsDangerousPatterns(regexPattern)) { @@ -82,7 +84,7 @@ public void validateRegexPattern(String propertyName, String regexPattern) { private void validatePatternWithTimeout(String propertyName, Pattern pattern) { Future future = VALIDATION_EXECUTOR.submit(() -> pattern.matcher(STRESS_PROBE).matches()); try { - future.get(10, TimeUnit.MILLISECONDS); + future.get(VALIDATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); } catch (TimeoutException _) { future.cancel(true); throw new PropertyDefinitionRulesConflictException( @@ -140,17 +142,17 @@ private boolean hasNestedQuantifiers(String pattern) { /// @param test the test to apply to group content /// @return true if the group matches the criteria private boolean matchesQuantifiedGroup(String pattern, int groupStartIndex, Predicate test) { - int closeIdx = findMatchingCloseParenthesis(pattern, groupStartIndex); - if (closeIdx == -1 || closeIdx + 1 >= pattern.length()) { + int closeIndex = findMatchingCloseParenthesis(pattern, groupStartIndex); + if (closeIndex == -1 || closeIndex + 1 >= pattern.length()) { return false; } - char nextChar = pattern.charAt(closeIdx + 1); + char nextChar = pattern.charAt(closeIndex + 1); if (!isQuantifier(nextChar)) { return false; } - String groupContent = pattern.substring(groupStartIndex + 1, closeIdx); + String groupContent = pattern.substring(groupStartIndex + 1, closeIndex); return test.test(groupContent); } @@ -180,8 +182,8 @@ private boolean hasLargeRepetitionBounds(String pattern) { if (isLargeRepetitionBound(pattern, i)) { return true; } - int closeIdx = pattern.indexOf('}', i); - i = closeIdx != -1 ? closeIdx + 1 : i + 1; + int closeIndex = pattern.indexOf('}', i); + i = closeIndex != -1 ? closeIndex + 1 : i + 1; } else { i++; } @@ -195,12 +197,12 @@ private boolean hasLargeRepetitionBounds(String pattern) { /// @param startIndex the index of the opening brace /// @return true if the upper bound is greater than 1000 private boolean isLargeRepetitionBound(String pattern, int startIndex) { - int closeIdx = pattern.indexOf('}', startIndex); - if (closeIdx == -1) { + int closeIndex = pattern.indexOf('}', startIndex); + if (closeIndex == -1) { return false; } - String bounds = pattern.substring(startIndex + 1, closeIdx); + String bounds = pattern.substring(startIndex + 1, closeIndex); return hasExcessiveUpperBound(bounds); } @@ -235,9 +237,9 @@ private boolean hasExcessiveUpperBound(String bounds) { private boolean hasLookaroundsWithQuantifiers(String pattern) { for (int i = 0; i < pattern.length() - 3; i++) { if (isLookaroundAt(pattern, i)) { - int closeIdx = findMatchingCloseParenthesis(pattern, i); - if (closeIdx != -1) { - String lookaroundContent = pattern.substring(i, closeIdx + 1); + int closeIndex = findMatchingCloseParenthesis(pattern, i); + if (closeIndex != -1) { + String lookaroundContent = pattern.substring(i, closeIndex + 1); if (containsQuantifier(lookaroundContent)) { return true; } @@ -300,7 +302,7 @@ private boolean containsQuantifier(String str) { return false; } - /// Helper: Finds the matching closing parenthesis for an opening paren at index startIdx. + /// Helper: Finds the matching closing parenthesis for an opening paren at index startIndex. /// Handles nested parentheses correctly. Returns -1 if no matching close paren exists. /// /// @param pattern the pattern string diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java index 2ee7da0..1e9827f 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java @@ -46,7 +46,7 @@ void testRegexPatternTooLong() { () -> propertyRegexValidationService.validateRegexPattern(propertyName, longPattern) ); assertTrue(ex.getMessage().contains("too long")); - assertTrue(ex.getMessage().contains("1,000")); + assertTrue(ex.getMessage().contains("1000")); } @ParameterizedTest From f8762e938d76bf0480c8e5b598576d6eefdbb4b7 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Wed, 6 May 2026 14:54:19 +0200 Subject: [PATCH 15/15] fix: remove technical exception --- .../entity_template/EntityTemplateValidationService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 897cd95..afd5377 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -71,11 +71,11 @@ public void validateForUpdate(String currentIdentifier, String existingName, Ent /// Validates that a template identifier is non-null and refers to an existing template. /// /// @param identifier the identifier of the template to delete - /// @throws IllegalArgumentException when `identifier` is null + /// @throws EntityTemplateNotFoundException when `identifier` is null /// @throws EntityTemplateNotFoundException when no template matches `identifier` public void validateForDeletion(String identifier) { if (identifier == null) { - throw new IllegalArgumentException("Template identifier must not be null"); + throw new EntityTemplateNotFoundException("identifier", "null"); } validateTemplateExists(identifier); }