From d807c697d72b8e25806481443238f01f242185bf Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Fri, 24 Apr 2026 14:05:10 +0200 Subject: [PATCH 01/51] feat(core): add post entity --- docker-compose.yml | 3 +- docs/src/concepts/entities.md | 264 +++++++++++++---- .../code/domain-infrastructure.md | 7 +- docs/src/static/swagger.yaml | 272 +++++++++++++++++- .../domain/constant/ValidationMessages.java | 35 +++ .../EntityAlreadyExistsException.java | 17 ++ .../exception/EntityNotFoundException.java | 6 +- .../exception/EntityValidationException.java | 30 ++ .../idp_core/domain/model/entity/Entity.java | 6 +- .../domain/port/EntityRepositoryPort.java | 4 +- .../service/{ => entity}/EntityService.java | 66 +++-- .../entity/EntityValidationService.java | 152 ++++++++++ .../domain/service/entity/Violations.java | 35 +++ .../property/PropertyValidationService.java | 116 ++++++++ .../configuration/SecurityConfiguration.java | 1 + .../api/configuration/SwaggerDescription.java | 16 ++ .../api/controller/EntityController.java | 47 ++- .../adapters/api/dto/in/EntityDtoIn.java | 51 +++- .../api/handler/ApiExceptionHandler.java | 44 ++- .../api/mapper/entity/EntityDtoInMapper.java | 53 ++-- .../api/mapper/entity/EntityDtoOutMapper.java | 2 +- .../persistence/PostgresEntityAdapter.java | 14 +- .../repository/JpaEntityRepository.java | 2 + .../service/entity/EntityServiceTest.java | 158 ++++++++++ .../entity/EntityValidationServiceTest.java | 235 +++++++++++++++ .../PropertyValidationServiceTest.java | 80 ++++++ .../api/controller/EntityControllerTest.java | 142 +++++++-- .../EntityTemplateControllerTest.java | 4 - .../api/handler/ApiExceptionHandlerTest.java | 90 ++++-- ...stEntityTemplate_400_properties_empty.json | 6 + ...late_400_withoutPropertiesDefinitions.json | 6 + .../json/entity/v1/postEntity_201.json | 16 +- .../entity/v1/postEntity_201_minimal.json | 4 + .../v1/postEntity_201_with_relations.json | 14 + .../v1/postEntity_400_identifier_missing.json | 7 + .../v1/postEntity_400_name_missing.json | 7 + .../postEntity_400_property_value_blank.json | 8 + .../postEntity_400_relation_name_blank.json | 11 + .../entity/v1/postEntity_409_duplicate.json | 6 + 39 files changed, 1816 insertions(+), 221 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java rename src/main/java/com/decathlon/idp_core/domain/service/{ => entity}/EntityService.java (59%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java create mode 100644 src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json create mode 100644 src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json diff --git a/docker-compose.yml b/docker-compose.yml index be4859d..139089d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,8 @@ --- -version: "3.8" services: postgres: - image: postgres:14 + image: postgres:18 environment: POSTGRES_USER: idpcore POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-idpcore_password} diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 4a78945..92598c0 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -3,17 +3,17 @@ title: Entities description: Understand Entities - instances of Entity Templates with actual data --- -Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity is the house built from that blueprint. +Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity +is the house built from that blueprint. ## Overview An Entity contains: -- **Identity** - Unique identifier and title +- **Identity** - Unique identifier and name - **Template Reference** - Which template it instantiates - **Properties** - Actual values for the template's property definitions - **Relations** - Links to other entities -- **Audit Fields** - Creation/modification timestamps and actors ```mermaid flowchart LR @@ -36,26 +36,26 @@ flowchart LR ### Complete Example -Here's an entity instantiated from the `sonar_project` template: +Here's an entity instantiated from the `web-service` template: ```json { - "identifier": "decathlon_my-backend-project", - "title": "My Backend Project", - "template": "sonar_project", + "identifier": "my-web-service", + "name": "my-web-service", + "template_identifier": "web-service", "properties": { - "project_name": "My Backend Project", - "last_analysis_date": "2025-11-28T12:20:38+0000", - "issues_number": 137, - "loc": 20000 + "port": "8080", + "environment": "dev" }, "relations": { - "github_repository": "my-backend-repo" + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + } + ] }, - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt" + "relations_as_target": {} } ``` @@ -63,17 +63,104 @@ Here's an entity instantiated from the `sonar_project` template: ## Core Fields -| Field | Type | Description | -| ------------ | -------- | -------------------------------------------- | -| `identifier` | String | Unique identifier for this entity | -| `title` | String | Human-readable name | -| `template` | String | The Entity Template this entity instantiates | -| `properties` | Object | Key-value pairs of property data | -| `relations` | Object | Links to other entities | -| `created_at` | DateTime | When the entity was created | -| `created_by` | String | Who created the entity | -| `updated_at` | DateTime | Last modification time | -| `updated_by` | String | Who last modified the entity | +| Field | Type | Description | +|-----------------------|----------|----------------------------------------------| +| `identifier` | String | Unique identifier within the template scope | +| `name` | String | Human-readable name | +| `template_identifier` | String | The Entity Template this entity instantiates | +| `properties` | Object | Key-value pairs of property data | +| `relations` | Object | Links to other entities (grouped by name) | + +--- + +## Creating an Entity + +You create an entity by sending a `POST` request to the entities endpoint, specifying the template identifier in the URL +path. + +### Endpoint + +```text +POST /api/v1/entities/{templateIdentifier} +``` + +### Request Body + +```json +{ + "name": "my-web-service", + "identifier": "my-web-service", + "properties": { + "port": "8080", + "environment": "dev" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + } + ] +} +``` + +### Validation + +IDP-Core validates entities at two levels: **syntactic validation** at the API boundary and **semantic validation** +against the template definition. + +#### Syntactic Validation (API Layer) + +The API enforces basic structural rules on the request body before any business logic runs: + +| Field | Rule | Error Message | +|-----------------------------------------|---------------------|--------------------------------------| +| `name` | Required, not blank | Entity name is mandatory | +| `identifier` | Required, not blank | Entity identifier is mandatory | +| `relations[].name` | Required, not blank | Relation name is mandatory | +| `relations[].target_entity_identifiers` | Required, not null | Relation target identifiers required | + +If any rule fails, the API returns `400 Bad Request` with a description of the violation. + +#### Semantic Validation (Domain Layer) + +After syntactic checks pass, the domain service validates the entity against its template definition: + +- **Template existence** - The template identifier must match an existing template. Returns `404 Not Found` if the + template does not exist. +- **Property value types** - Values must conform to the property definition type (STRING, NUMBER, BOOLEAN). +- **Property rules** - Values must satisfy the template's property rules (min/max length, format, regex, enum). +- **Required properties** - All properties marked as required in the template must be present. +- **Duplicate check** - An entity with the same identifier must not already exist for the template. Returns + `409 Conflict` if it does. + +### Response Codes + +| Code | Description | +|-------|----------------------------------------------------------------| +| `201` | Entity created successfully | +| `400` | Invalid request body or validation failure | +| `401` | Missing or invalid authentication token | +| `403` | Insufficient permissions | +| `404` | Template not found for the given identifier | +| `409` | An entity with this identifier already exists for the template | +| `500` | Unexpected server error | + +### Minimal Example + +You can create an entity with only the required fields: + +```json +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} +``` + +Properties and relations are optional in the request body. The domain layer validates that all *required* properties (as +defined in the template) are present. --- @@ -84,17 +171,17 @@ Properties contain the actual data values. The structure follows the template's ```json { "properties": { - "project_name": "My Backend Project", // STRING - "issues_number": 137, // NUMBER - "loc": 20000, // NUMBER - "last_analysis_date": "2025-11-28..." // STRING (date-time) + "project_name": "My Backend Project", + "issues_number": 137, + "loc": 20000, + "last_analysis_date": "2025-11-28..." } } ``` -### Validation +### Validation of properties -System validates values against the template's property rules: +The system validates values against the template's property rules: - Required properties must be present - Types must match: STRING, NUMBER, or BOOLEAN @@ -104,51 +191,119 @@ System validates values against the template's property rules: ## Relations -Relations link entities together, forming a graph. It references the entity identifiers of related entities. +Relations link entities together, forming a graph. Each relation references the entity identifiers of related entities. -### One-to-One Relations (`to_many: false`) +### Creating Relations -For consistency, even single relations are represented as arrays: +When creating an entity, you specify relations as an array of objects, each with a `name` and a list of +`target_entity_identifiers`: ```json { - "relations": { - "owned_by": ["platform-team"] - } + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + }, + { + "name": "owned-by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -### One-to-Many Relations (`to_many: true`) +### Relations in Responses -When multiple related entities are allowed, you can list several identifiers in the relation array: +In API responses, relations are grouped by name and include summary information about each target entity: ```json { "relations": { - "components": ["frontend", "backend", "database"] + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + }, + { + "identifier": "web-api-2", + "name": "Web API 2" + } + ] + }, + "relations_as_target": { + "depends-on": [ + { + "identifier": "frontend-app", + "name": "Frontend App" + } + ] } } ``` ---- +The `relations_as_target` field shows reverse relationships—other entities that reference this entity. -## Audit Fields +### One-to-One Relations (`to_many: false`) -Every entity tracks who created/modified it and when: +For consistency, even single relations are represented as arrays: ```json { - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "auth0|65c1d23377c9bea7d7adc415", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "webhook_integration_sonar" + "relations": [ + { + "name": "owned_by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -The `created_by` and `updated_by` fields contain: +### One-to-Many Relations (`to_many: true`) -- User IDs for manual operations -- Integration IDs for automated data ingestion +When multiple related entities are allowed, list several identifiers: + +```json +{ + "relations": [ + { + "name": "components", + "target_entity_identifiers": [ + "frontend", + "backend", + "database" + ] + } + ] +} +``` + +--- + +## Retrieving Entities + +### List Entities by Template + +Retrieve a paginated list of entities for a given template: + +```text +GET /api/v1/entities/{templateIdentifier}?page=0&size=20&sort=identifier,asc +``` + +### Get Entity by Identifier + +Retrieve a specific entity using its template and entity identifiers: + +```text +GET /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier} +``` --- @@ -157,7 +312,8 @@ The `created_by` and `updated_by` fields contain: Because templates are configured at runtime, the entity structure is **dynamic**: > [!WARNING] -> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure depends on the template configuration. +> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure +> depends on the template configuration. > > This means: > @@ -168,4 +324,4 @@ Because templates are configured at runtime, the entity structure is **dynamic** - **[Properties](properties.md)** - Property types and validation rules - **[Relations](relations.md)** - How entities connect -- **[Calculated Properties](calculated-properties.md)** - Automatic computations +- **[API Reference](../api/index.md)** - Interactive Swagger UI documentation diff --git a/docs/src/contributing/code/domain-infrastructure.md b/docs/src/contributing/code/domain-infrastructure.md index 8b6a25c..ad1dbf9 100644 --- a/docs/src/contributing/code/domain-infrastructure.md +++ b/docs/src/contributing/code/domain-infrastructure.md @@ -33,7 +33,12 @@ domain/ │ ├── EntityTemplateRepositoryPort │ └── RelationRepositoryPort └── service/ # Domain services logic orchestration - ├── EntityService + ├── entity/ + │ ├── EntityService # Orchestrates entity CRUD with validation + │ ├── EntityValidationService # Entity validation pipeline (template, uniqueness, structure, rules) + │ └── Violations # Mutable accumulator of validation violation messages + ├── property/ + │ └── PropertyValidationService # Validates property values against type and rules (STRING, NUMBER, BOOLEAN) ├── EntityTemplateService └── RelationService ``` diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 37c9d48..accb23c 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -9,6 +9,8 @@ security: - clientId: [] - bearer: [] tags: + - name: Entities Management + description: Operations related to entity management - name: Entities Templates Management description: Operations related to entity template management paths: @@ -160,6 +162,143 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/{templateIdentifier}: + get: + tags: + - Entities Management + summary: Get entities by template identifier + description: Retrieve a paginated list of entities with optional sorting + operationId: getEntities + parameters: + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + '*/*': + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' + content: + '*/*': + schema: + type: string + default: identifier,asc + responses: + '200': + description: Paginated entities retrieved successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityPageResponse' + '400': + description: Invalid pagination parameters + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Entities Management + summary: Create a new entity + description: Create a new entity in the system with the provided information + operationId: createEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EntityDtoIn' + required: true + responses: + '201': + description: Entity created successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '400': + description: Invalid entity data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '409': + description: Entity already exists in this template + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Template not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + get: + tags: + - Entities Management + summary: Get entity by entity template and identifier + description: Retrieve a specific entity using its string identifier and its template identifier + operationId: getEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string + responses: + '200': + description: Entity found + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' components: schemas: EntityTemplateCreateDtoIn: @@ -173,7 +312,7 @@ components: minLength: 1 name: type: string - description: Unique Entity Template name + description: Entity Template name example: Service maxLength: 255 minLength: 1 @@ -201,10 +340,10 @@ components: properties: name: type: string - description: Entity Template name + description: Unique Entity Template name example: Service maxLength: 255 - minLength: 1 + minLength: 0 pattern: "^[a-zA-Z0-9 _-]+$" description: type: string @@ -356,11 +495,6 @@ components: type: object description: Output DTO for property definition properties: - id: - type: string - format: uuid - description: Unique identifier of the property definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Property name @@ -437,11 +571,6 @@ components: type: object description: Output DTO for relation definition properties: - id: - type: string - format: uuid - description: Unique identifier of the relation definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Name of the relation @@ -535,6 +664,86 @@ components: - 511 NETWORK_AUTHENTICATION_REQUIRED errorDescription: type: string + EntityDtoIn: + type: object + description: Input DTO for creating or updating an entity + properties: + name: + type: string + description: Name of the entity + example: my-web-service + minLength: 1 + identifier: + type: string + description: Unique identifier of the entity within the template scope + example: my-web-service + minLength: 1 + properties: + type: object + additionalProperties: {} + description: Map of property name to value for this entity + example: + port: '8080' + environment: dev + relations: + type: array + description: List of relations for this entity + items: + $ref: '#/components/schemas/RelationDtoIn' + required: + - identifier + - name + RelationDtoIn: + type: object + description: Input DTO for an entity relation instance + properties: + name: + type: string + description: Name of the relation (must match a template relation definition) + example: depends-on + minLength: 1 + target_entity_identifiers: + type: array + description: List of target entity identifiers for this relation + example: + - web-api-1 + - web-api-2 + items: + type: string + required: + - name + - target_entity_identifiers + EntityDtoOut: + type: object + properties: + template_identifier: + type: string + name: + type: string + identifier: + type: string + properties: + type: object + additionalProperties: {} + relations: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + relations_as_target: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + EntitySummaryDto: + type: object + properties: + identifier: + type: string + name: + type: string PageableObject: type: object properties: @@ -572,15 +781,46 @@ components: $ref: '#/components/schemas/EntityTemplateDtoOut' pageable: $ref: '#/components/schemas/PageableObject' + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 last: type: boolean - totalPages: + size: + type: integer + format: int32 + number: type: integer format: int32 + sort: + $ref: '#/components/schemas/SortObject' + first: + type: boolean + numberOfElements: + type: integer + format: int32 + empty: + type: boolean + EntityPageResponse: + type: object + description: Paginated response containing Entity objects + properties: + content: + type: array + items: + $ref: '#/components/schemas/EntityDtoOut' + pageable: + $ref: '#/components/schemas/PageableObject' totalElements: type: integer format: int64 - first: + totalPages: + type: integer + format: int32 + last: type: boolean size: type: integer @@ -590,6 +830,8 @@ components: format: int32 sort: $ref: '#/components/schemas/SortObject' + first: + type: boolean numberOfElements: type: integer format: int32 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..369f434 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 @@ -20,6 +20,24 @@ public class ValidationMessages { public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; + public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; + public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; + public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; + public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; + public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; + public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; + public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; + public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; + public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + 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_MIN_LENGTH_NON_NEGATIVE = + "Rule 'min_length' must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = + "Rule 'max_length' must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = + "BOOLEAN properties do not allow validation rules"; + public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; @@ -27,4 +45,21 @@ 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"; + + // Entity input validation messages + public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + + // Entity creation validation messages + public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; + public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; + public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + + public static String minMaxConstraintViolated(String ruleName) { + return "Rule 'min_" + ruleName + "' must be lower than or equal to 'max_" + ruleName + "'"; + } + + public static String ruleNotAllowed(String ruleName, String propertyType) { + return "Rule '" + ruleName + "' is not allowed for " + propertyType + " properties"; + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java new file mode 100644 index 0000000..bd76169 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java @@ -0,0 +1,17 @@ +package com.decathlon.idp_core.domain.exception; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_ALREADY_EXISTS; + +import com.decathlon.idp_core.domain.model.entity.Entity; + +/// Domain exception for duplicate [Entity] business entities within a template scope. +public class EntityAlreadyExistsException extends RuntimeException { + + /// Constructs a new exception with template and entity identifiers. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityName the duplicate entity name + public EntityAlreadyExistsException(String templateIdentifier, String entityName) { + super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java index 2942d91..cc7d4a8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java @@ -1,5 +1,9 @@ package com.decathlon.idp_core.domain.exception; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NOT_FOUND; + +import com.decathlon.idp_core.domain.model.entity.Entity; + /// Domain exception for missing [Entity] business entities. /// /// **Business purpose:** Represents the business rule violation when attempting @@ -20,7 +24,7 @@ public class EntityNotFoundException extends RuntimeException { /// @param templateIdentifier the identifier of the template /// @param entityIdentifier the identifier of the entity public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { - super(String.format("Entity not found with template identifier %s and entity identifier '%s'", templateIdentifier, entityIdentifier)); + super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java new file mode 100644 index 0000000..ca9da64 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java @@ -0,0 +1,30 @@ +package com.decathlon.idp_core.domain.exception; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_VALIDATION_FAILED; + +import java.util.List; + +import lombok.Getter; + +/// Domain exception for entity schema validation failures +@Getter +public class EntityValidationException extends RuntimeException { + + /** + * -- GETTER -- + * Returns the list of individual validation violation messages. + * /// + * /// + * @return immutable list of violation messages + */ + private final List violations; + + /// Constructs a new exception with a list of validation violation messages. + /// + /// @param violations the list of validation error messages + public EntityValidationException(List violations) { + super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); + this.violations = List.copyOf(violations); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2ec901e..6250a5a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.domain.model.entity; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY; import java.util.List; @@ -24,9 +26,9 @@ public record Entity( @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, - + @NotBlank(message = ENTITY_NAME_MANDATORY) String name, - + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, List properties, diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 1c80cb8..0b2c4b8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -30,7 +30,9 @@ public interface EntityRepositoryPort { Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); + + Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable); List findByIdentifierIn(List identifiers); 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/EntityService.java similarity index 59% 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/EntityService.java index b09cff9..3f5de08 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -1,21 +1,21 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.entity; import java.util.List; +import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; 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; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; - import jakarta.transaction.Transactional; import jakarta.validation.Valid; -import lombok.AllArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. /// @@ -26,39 +26,37 @@ /// **Key responsibilities:** /// - Entity retrieval with template validation /// - Entity creation with business rule enforcement +/// - Entity data integrity validation (entity, properties, relations) /// - Entity summary generation for efficient queries -/// - Relationship integrity validation @Service @AllArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; + private final EntityValidationService entityValidationService; /// Retrieves entities filtered by template with existence validation. /// /// **Contract:** Returns paginated entities that conform to the specified template. /// Template existence is validated first to ensure meaningful results. /// - /// @param pageable pagination configuration for large entity sets + /// @param pageable pagination configuration for large entity sets /// @param templateIdentifier business identifier of the entity template /// @return paginated entities matching the template /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable) + .orElseThrow(() -> new EntityTemplateNotFoundException(templateIdentifier)); } - /// Provides lightweight entity summaries for efficient bulk operations. - /// - /// **Contract:** Returns summary projections without full entity data, - /// optimized for UI lists and relationship resolution scenarios. - /// - /// @param identifiers business identifiers of entities to summarize - /// @return lightweight entity summaries for the specified identifiers + /// Provides lightweight entity summaries for efficient bulk operations. + /// + /// **Contract:** Returns summary projections without full entity data, + /// optimized for UI lists and relationship resolution scenarios. + /// + /// @param identifiers business identifiers of entities to summarize + /// @return lightweight entity summaries for the specified identifiers public List getEntitiesSummariesByIndentifiers(List identifiers) { return entityRepository.findByIdentifierIn(identifiers); } @@ -69,29 +67,35 @@ public List getEntitiesSummariesByIndentifiers(List ident /// Validates template existence first, then entity existence, ensuring referential integrity. /// /// @param templateIdentifier business identifier of the entity template - /// @param entityIdentifier unique business identifier of the entity within template + /// @param entityIdentifier unique business identifier of the entity within template /// @return the entity matching both identifiers /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when entity doesn't exist + /// @throws EntityNotFoundException when entity doesn't exist @Transactional public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifier, String entityIdentifier) { - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } + entityValidationService.checkTemplateExist(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); } - /// Creates and persists a new entity with business validation. - /// - /// **Contract:** Validates entity structure against template rules and persists - /// the entity. Future enhancement will include comprehensive business rule validation. - /// - /// @param entity validated entity to create and persist - /// @return the persisted entity with generated identifiers + /// Creates and persists a new entity with business validation. + /// + /// **Contract:** Validates template existence, entity identifier uniqueness within + /// the template scope, and entity/property/relation data integrity before persisting. + /// + /// @param entity validated entity to create and persist + /// @return the persisted entity with generated identifiers + /// @throws EntityTemplateNotFoundException when the referenced template doesn't exist + /// @throws EntityAlreadyExistsException when an entity with the same identifier already exists for this template + /// @throws EntityValidationException when entity, property, or relation data is invalid + @Transactional public Entity createEntity(@Valid Entity entity) { - // Add validations + entityValidationService.checkTemplateExist(entity.templateIdentifier()); + entityValidationService.checkEntityAlreadyExist(entity); + entityValidationService.validateEntity(entity); return entityRepository.save(entity); } + + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java new file mode 100644 index 0000000..f535e7d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -0,0 +1,152 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +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.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +/// Domain validator for [Entity] aggregates. +/// +/// Validation pipeline: +/// 1. Existence checks (template found, entity not duplicated). +/// 2. Syntactic checks on the entity itself (name/identifier, nested properties, relations). +/// 3. Template-driven semantic checks (required, type, rules). +@Service +@AllArgsConstructor +public class EntityValidationService { + + private final EntityRepositoryPort entityRepository; + private final EntityTemplateRepositoryPort entityTemplateRepository; + private final PropertyValidationService propertyValidationService; + + /// Check entity template existence to ensure valid template reference before deeper validations. + /// @param entity the entity whose template existence is to be checked + /// @throws EntityTemplateNotFoundException if the template referenced by the entity does not exist + void checkTemplateExist(final String entity) { + if (!entityTemplateRepository.existsByIdentifier(entity)) { + throw new EntityTemplateNotFoundException("identifier", entity); + } + } + + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// @param entity the entity to validate + /// @throws EntityValidationException when one or more validation rules are violated + /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template + /// @throws EntityTemplateNotFoundException if the referenced template does not exist + void validateEntity(Entity entity) { + checkEntityAlreadyExist(entity); + EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); + + Violations violations = new Violations(); + + validateEntityHeader(entity, violations); + validatePropertiesShape(entity.properties(), violations); + validateRelationsShape(entity.relations(), violations); + validateAgainstTemplate(template, entity.properties(), violations); + + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); + } + } + + private void validateEntityHeader(Entity entity, Violations violations) { + violations.addIfBlank(entity.name(), ENTITY_NAME_MANDATORY); + violations.addIfBlank(entity.identifier(), ENTITY_IDENTIFIER_MANDATORY); + } + + private void validatePropertiesShape(List properties, Violations violations) { + if (properties == null) { + return; + } + for (int i = 0; i < properties.size(); i++) { + Property prop = properties.get(i); + if (prop.name() == null || prop.name().isBlank()) { + violations.addIndexed("Property", i, PROPERTY_NAME_MANDATORY); + } + if (prop.value() == null || prop.value().isBlank()) { + violations.addIndexed("Property", i, PROPERTY_VALUE_MANDATORY); + } + } + } + + private void validateRelationsShape(List relations, Violations violations) { + if (relations == null) { + return; + } + for (int i = 0; i < relations.size(); i++) { + Relation rel = relations.get(i); + if (rel.name() == null || rel.name().isBlank()) { + violations.addIndexed("Relation", i, RELATION_NAME_MANDATORY_SIMPLE); + } + if (rel.targetEntityIdentifiers() == null) { + violations.addIndexed("Relation", i, RELATION_TARGET_IDENTIFIERS_NOT_NULL); + } + } + } + + /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. + /// @param template the entity template whose property definitions are used for validation + /// @param properties the list of properties from the entity to validate + /// @param violations the accumulator for validation violation messages + private void validateAgainstTemplate(EntityTemplate template, + List properties, + Violations violations) { + List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); + Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() + .filter(p -> p.name() != null) + .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); + + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null || property.value() == null || property.value().isBlank(); + + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); + } + continue; + } + + propertyValidationService + .validatePropertyValue(definition, property.value()) + .forEach(violations::add); + } + } + + /// Checks for existing entity with same template and identifier to prevent duplicates. + /// @param entity the entity to check for existence + /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists + void checkEntityAlreadyExist(final Entity entity) { + if (entity.identifier() != null + && entityRepository + .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) + .isPresent()) { + throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java new file mode 100644 index 0000000..92a3dd6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -0,0 +1,35 @@ +package com.decathlon.idp_core.domain.service.entity; +import java.util.ArrayList; +import java.util.List; + +/// Mutable accumulator of validation violation messages. +/// +/// Centralises message formatting and indexed-prefix handling so domain +/// validators stay focused on the rule they enforce rather than on string +/// concatenation. Not thread-safe; intended for short-lived per-request use. +final class Violations { + private final List messages = new ArrayList<>(); + void add(String message) { + messages.add(message); + } + void add(String template, Object... args) { + messages.add(template.formatted(args)); + } + void addIfBlank(String value, String message) { + if (value == null || value.isBlank()) { + messages.add(message); + } + } + + /// Adds a violation prefixed with the indexed collection name, e.g. + /// `Property[2]: Property name is mandatory`. + void addIndexed(String collection, int index, String message) { + messages.add("%s[%d]: %s".formatted(collection, index, message)); + } + boolean isEmpty() { + return messages.isEmpty(); + } + List asList() { + return List.copyOf(messages); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java new file mode 100644 index 0000000..983e1e3 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -0,0 +1,116 @@ +package com.decathlon.idp_core.domain.service.property; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_ENUM_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_FORMAT_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REGEX_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_MISMATCH; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; + +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; + +/** + * Domain service validating entity property values against template definitions. + */ +@Service +public class PropertyValidationService { + + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + + /** + * Validates a concrete property value against its property definition. + * + * @param propertyDefinition property definition with expected type and optional rules + * @param rawValue raw property value + * @return list of violations for this value; empty when valid + */ + public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue) { + return switch (propertyDefinition.type()) { + case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); + }; + } + + private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + if (rawValue == null) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minLength() != null && rawValue.length() < rules.minLength()) { + violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); + } + if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { + violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); + } + if (rules.regex() != null && !Pattern.matches(rules.regex(), rawValue)) { + violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); + } + if (rules.enumValues() != null && !rules.enumValues().isEmpty() + && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(rawValue))) { + violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); + } + if (rules.format() != null && !matchesFormat(rules.format(), rawValue)) { + violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); + } + + return List.copyOf(violations); + } + + private List validateNumberPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + final BigDecimal parsedValue; + try { + parsedValue = new BigDecimal(rawValue); + } catch (RuntimeException exception) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.minValue())) < 0) { + violations.add(PROPERTY_MIN_VALUE_VIOLATION.formatted(propertyName, rules.minValue())); + } + if (rules.maxValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.maxValue())) > 0) { + violations.add(PROPERTY_MAX_VALUE_VIOLATION.formatted(propertyName, rules.maxValue())); + } + + return List.copyOf(violations); + } + + private List validateBooleanPropertyValue(String propertyName, String rawValue) { + if ("true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue)) { + return List.of(); + } + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + } + + private boolean matchesFormat(PropertyFormat format, String value) { + return switch (format) { + case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); + case URL -> URL_PATTERN.matcher(value).matches(); + }; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index 8105a5d..b882f5b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 53aacc8..d645725 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -26,9 +26,12 @@ public class SwaggerDescription { public static final String NO_CONTENT_CODE = "204"; public static final String PARTIAL_CONTENT_CODE = "206"; public static final String BAD_REQUEST_CODE = "400"; + public static final String UNAUTHORIZED_CODE = "401"; + public static final String FORBIDDEN_CODE = "403"; public static final String NOT_FOUND_CODE = "404"; public static final String CONFLICT_CODE = "409"; public static final String SERVICE_UNAVAILABLE_CODE = "503"; + public static final String INTERNAL_SERVER_ERROR_CODE = "500"; /// Entity Template API endpoint constants public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; @@ -78,11 +81,15 @@ public class SwaggerDescription { public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; + public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; public static final String RESPONSE_ENTITY_FOUND = "Entity found"; public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; + public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; + public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; // --- Schema (class) descriptions --- @@ -95,6 +102,8 @@ public class SwaggerDescription { public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; + public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; + public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; // --- Field descriptions (shared) --- public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; @@ -104,6 +113,13 @@ public class SwaggerDescription { public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; + public static final String FIELD_ENTITY_NAME = "Name of the entity"; + public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; + public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; + public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; + public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; + public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; + public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; public static final String FIELD_PROPERTY_NAME = "Property name"; public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; 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..ad37b94 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 @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.BAD_REQUEST_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CONFLICT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CREATED_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_SUMMARY; @@ -8,20 +9,29 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PAGE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SIZE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SORT_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITIES_PAGINATED_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CONFLICT; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CREATED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_FOUND; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INSUFFICIENT_RIGHTS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_ENTITY_DATA; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_PAGINATION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNAUTHORIZED; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; +import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -35,7 +45,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.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; @@ -43,7 +53,6 @@ import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoInMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -51,7 +60,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.AllArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; /// REST API adapter providing entity management endpoints. /// @@ -77,14 +88,14 @@ public class EntityController { /// Supports standard REST pagination parameters and returns appropriate HTTP status codes. /// Template validation is handled by the domain service layer. /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control /// @param templateIdentifier template filter for entity scope limitation /// @return paginated entity DTOs optimized for API consumers @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @@ -105,13 +116,13 @@ public Page getEntities( /// Returns HTTP 404 if either template or entity doesn't exist, maintaining REST semantics. /// /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context + /// @param entityIdentifier unique business identifier within template context /// @return entity DTO with full property and relationship data @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut getEntity( @@ -128,16 +139,22 @@ public EntityDtoOut getEntity( /// and returns HTTP 201 on success, HTTP 400 for validation errors. /// /// @param templateIdentifier target template identifier for entity creation context - /// @param entityDtoIn entity creation payload with properties and relationships + /// @param entityDtoIn entity creation payload with properties and relationships /// @return created entity DTO with server-generated identifiers @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) @PostMapping("/{templateIdentifier}") @ResponseStatus(CREATED) - public EntityDtoOut createEntity(@PathVariable String templateIdentifier, @RequestBody EntityDtoIn entityDtoIn) { + public EntityDtoOut createEntity( + @NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityDtoIn entityDtoIn) { + Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); Entity savedEntity = entityService.createEntity(entity); return entityDtoOutMapper.fromEntity(savedEntity); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 33bd356..0531655 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -1,34 +1,79 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_PROPERTIES; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATIONS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_TARGETS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_IN; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_RELATION_IN; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; + import java.util.List; import java.util.Map; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +/// Input DTO for creating a new entity within a template scope. +/// +/// **Infrastructure validation:** Performs syntactic validation at the API boundary +/// using Jakarta Validation annotations. Semantic validation (schema conformance +/// against template definitions) is handled by the domain service layer. @Data +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = SCHEMA_ENTITY_IN) public class EntityDtoIn { + + @NotBlank(message = ENTITY_NAME_MANDATORY) + @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") private String name; + + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") private String identifier; + + @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") private Map properties; + + @Valid + @Schema(description = FIELD_ENTITY_RELATIONS) private List relations; + /// Input DTO for an entity relation instance. + /// + /// **Infrastructure validation:** Validates relation name presence and target + /// identifiers at the API boundary before domain-level schema checks. @Data + @Builder @NoArgsConstructor @AllArgsConstructor - - @Builder @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = SCHEMA_ENTITY_RELATION_IN) public static class RelationDtoIn { + + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) + @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") private String name; + + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) + @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") private List targetEntityIdentifiers; } } 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..1cfbf69 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 @@ -11,11 +11,14 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; 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.EntityValidationException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -23,7 +26,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.servlet.NoHandlerFoundException; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -83,6 +85,40 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } + /// Handles validation exceptions from Spring MVC handler method parameters. + /// + /// **Error aggregation:** Combines multiple validation error messages into a single + /// user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + log.warn("Handler method validation error: {}", ex.getMessage()); + String errorMessage = ex.getAllErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities already exist. + /// + /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 + /// status indicating business rule conflict for duplicate entities. + @ExceptionHandler(EntityAlreadyExistsException.class) + public ResponseEntity handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { + log.warn("Entity already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity validation fails. + /// + /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status with aggregated + /// validation error messages for client correction. + @ExceptionHandler(EntityValidationException.class) + public ResponseEntity handleEntityValidationException(EntityValidationException ex) { + log.warn("Entity validation failed: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles Bean Validation constraint violations from domain model validation. /// /// **Error aggregation:** Combines multiple constraint violation messages into @@ -134,12 +170,6 @@ public ResponseEntity handleEntityNotFoundException(EntityNotFoun ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); return ResponseEntity.status(NOT_FOUND).body(errorResponse); } - - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity handleNotFound(NoHandlerFoundException e) { - return createErrorResponse(NOT_FOUND, "Resource not found: " + e.getRequestURL()); - } - private String parseHttpMessageNotReadableError(String originalMessage) { if (originalMessage == null) { return "Invalid request body format"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index a5e6b8f..1f6ad3a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; +import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -11,8 +12,6 @@ import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting API request DTOs to domain [Entity] objects. /// /// **Infrastructure mapping responsibilities:** @@ -28,38 +27,43 @@ /// /// **API contract support:** Enables clean separation between API request format /// and internal domain model structure for maintainable API evolution. - @Component @AllArgsConstructor public class EntityDtoInMapper { + + /// Converts an entity creation request DTO to a domain entity. + /// + /// @param entityDtoIn the entity creation request payload + /// @param entityTemplateIdentifier the target template identifier + /// @return the mapped domain entity with audit fields populated public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemplateIdentifier) { List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> { - String value; - if (entry.getValue() != null) { - value = String.valueOf(entry.getValue()); - } else { - value = null; - } - return new Property( - null, - entry.getKey(), - value - ); - }) - .toList(); + .map((Map.Entry entry) -> { + String value; + if (entry.getValue() != null) { + value = String.valueOf(entry.getValue()); + } else { + value = null; + } + return new Property( + null, + entry.getKey(), + value + ); + }) + .toList(); List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() : entityDtoIn.getRelations().stream() - .map(relDto -> new Relation( - null, - relDto.getName(), - null, // targetTemplateIdentifier not available in DTO - relDto.getTargetEntityIdentifiers() - )) - .toList(); + .map(relDto -> new Relation( + null, + relDto.getName(), + null, + relDto.getTargetEntityIdentifiers() + )) + .toList(); return new Entity( null, @@ -70,5 +74,4 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp relations ); } - } 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..0072134 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,7 +20,7 @@ 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.entity.EntityService; import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.RelationService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 27ed5ed..0319c66 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -4,6 +4,7 @@ import java.util.Optional; import java.util.UUID; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -14,8 +15,6 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; -import lombok.RequiredArgsConstructor; - @Component @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { @@ -40,8 +39,15 @@ public Optional findByTemplateIdentifierAndIdentifier(String templateIde } @Override - public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { - return jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable).map(mapper::toDomain); + public Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName) { + return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) + .map(mapper::toDomain); + } + + @Override + public Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return Optional.of(pageableEntity.map(mapper::toDomain)); } @Override diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 1debeca..fcabfcb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -24,5 +24,7 @@ public interface JpaEntityRepository extends JpaRepository findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java new file mode 100644 index 0000000..a0a2d15 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -0,0 +1,158 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityService Tests") +class EntityServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityValidationService entityValidationService; + + @InjectMocks + private EntityService entityService; + + @Test + @DisplayName("Should return entities page by template identifier") + void shouldReturnEntitiesByTemplateIdentifier() { + var pageable = Pageable.ofSize(10); + var entity = entity("template-a", "entity-a", "Entity A"); + var page = new PageImpl<>(List.of(entity)); + + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(Optional.of(page)); + + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); + + assertSame(page, result); + verify(entityRepository).findByTemplateIdentifier("template-a", pageable); + } + + @Test + @DisplayName("Should throw when template has no entities page") + void shouldThrowWhenTemplatePageNotFound() { + var pageable = Pageable.ofSize(10); + when(entityRepository.findByTemplateIdentifier("missing-template", pageable)).thenReturn(Optional.empty()); + + assertThrows(EntityTemplateNotFoundException.class, + () -> entityService.getEntitiesByTemplateIdentifier(pageable, "missing-template")); + } + + @Test + @DisplayName("Should return entity summaries by identifiers") + void shouldReturnEntitySummariesByIdentifiers() { + var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); + when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); + + var result = entityService.getEntitiesSummariesByIndentifiers(List.of("service-a")); + + assertEquals(summaries, result); + verify(entityRepository).findByIdentifierIn(List.of("service-a")); + } + + @Test + @DisplayName("Should return entity by template and identifier") + void shouldReturnEntityByTemplateAndIdentifier() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + var result = entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "catalog-api"); + + assertSame(entity, result); + verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + } + + @Test + @DisplayName("Should throw when entity is not found for template") + void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "missing-entity")); + } + + @Test + @DisplayName("Should create entity when validations pass") + void shouldCreateEntityWhenValidationsPass() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.save(entity)).thenReturn(entity); + + var result = entityService.createEntity(entity); + + assertSame(entity, result); + + InOrder inOrder = inOrder(entityValidationService, entityRepository); + inOrder.verify(entityValidationService).checkTemplateExist("web-service"); + inOrder.verify(entityValidationService).checkEntityAlreadyExist(entity); + inOrder.verify(entityValidationService).validateEntity(entity); + inOrder.verify(entityRepository).save(entity); + } + + @Test + @DisplayName("Should not save when entity already exists") + void shouldNotSaveWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); + + org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkEntityAlreadyExist(entity); + + assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); + + verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityValidationService).checkEntityAlreadyExist(entity); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should stop immediately when template does not exist") + void shouldStopWhenTemplateDoesNotExistOnCreate() { + var entity = entity("missing-template", "catalog-api", "Catalog API"); + var templateNotFound = new EntityTemplateNotFoundException("identifier", "missing-template"); + + org.mockito.Mockito.doThrow(templateNotFound) + .when(entityValidationService) + .checkTemplateExist("missing-template"); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); + + verify(entityValidationService).checkTemplateExist("missing-template"); + verifyNoInteractions(entityRepository); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java new file mode 100644 index 0000000..4cdd394 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -0,0 +1,235 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +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; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityValidationService Tests") +class EntityValidationServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityTemplateRepositoryPort entityTemplateRepository; + + @Mock + private PropertyValidationService propertyValidationService; + + @InjectMocks + private EntityValidationService entityValidationService; + + @Test + @DisplayName("Should pass checkTemplateExist when template exists") + void shouldPassCheckTemplateExistWhenTemplateExists() { + when(entityTemplateRepository.existsByIdentifier("web-service")).thenReturn(true); + + assertDoesNotThrow(() -> entityValidationService.checkTemplateExist("web-service")); + } + + @Test + @DisplayName("Should throw checkTemplateExist when template does not exist") + void shouldThrowCheckTemplateExistWhenTemplateDoesNotExist() { + when(entityTemplateRepository.existsByIdentifier("missing-template")).thenReturn(false); + + assertThrows(EntityTemplateNotFoundException.class, + () -> entityValidationService.checkTemplateExist("missing-template")); + } + + @Test + @DisplayName("Should throw when entity with same identifier already exists") + void shouldThrowWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkEntityAlreadyExist(entity)); + } + + @Test + @DisplayName("Should not query repository when identifier is null") + void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.checkEntityAlreadyExist(entity)); + + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("Should throw when template is missing during validateEntity") + void shouldThrowWhenTemplateMissingDuringValidateEntity() { + var entity = entity("missing-template", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityValidationService.validateEntity(entity)); + } + + @Test + @DisplayName("Should aggregate entity, property, relation, required and rule violations") + void shouldAggregateAllViolationsDuringValidateEntity() { + var portDefinition = new PropertyDefinition( + UUID.randomUUID(), + "port", + "Port", + PropertyType.NUMBER, + true, + new PropertyRules(null, null, null, null, null, null, 65535, 1024)); + + var requiredDefinition = new PropertyDefinition( + UUID.randomUUID(), + "ownerEmail", + "Owner email", + PropertyType.STRING, + true, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(requiredDefinition, portDefinition), + List.of()); + + var mockedRelation = org.mockito.Mockito.mock(Relation.class); + when(mockedRelation.name()).thenReturn(" "); + when(mockedRelation.targetEntityIdentifiers()).thenReturn(null); + + var entity = entity( + "web-service", + " ", + " ", + List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), + List.of(mockedRelation)); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); + + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); + + assertEquals(8, exception.getViolations().size()); + assertEquals(ENTITY_NAME_MANDATORY, exception.getViolations().get(0)); + assertEquals(ENTITY_IDENTIFIER_MANDATORY, exception.getViolations().get(1)); + assertEquals("Property[0]: " + PROPERTY_NAME_MANDATORY, exception.getViolations().get(2)); + assertEquals("Property[0]: " + PROPERTY_VALUE_MANDATORY, exception.getViolations().get(3)); + assertEquals("Relation[0]: " + RELATION_NAME_MANDATORY_SIMPLE, exception.getViolations().get(4)); + assertEquals("Relation[0]: " + RELATION_TARGET_IDENTIFIERS_NOT_NULL, exception.getViolations().get(5)); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); + assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); + + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + } + + @Test + @DisplayName("Should validate entity successfully when no violations") + void shouldValidateEntitySuccessfullyWhenNoViolations() { + var versionDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(versionDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), + null); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.empty()); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + } + + @Test + @DisplayName("Should skip property rule validation for missing optional property") + void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { + var optionalDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(optionalDefinition), + List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + verifyNoInteractions(propertyValidationService); + } + + private Entity entity( + String templateIdentifier, + String identifier, + String name, + List properties, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, relations); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java new file mode 100644 index 0000000..f8416e6 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -0,0 +1,80 @@ +package com.decathlon.idp_core.domain.service.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.constant.ValidationMessages; +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("PropertyValidationService Tests") +class PropertyValidationServiceTest { + + private final PropertyValidationService service = new PropertyValidationService(); + + @Test + @DisplayName("Should report type mismatch for non numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "not-a-number"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + + @Test + @DisplayName("Should report string constraint violations") + void shouldReportStringRuleViolations() { + var definition = propertyDefinition("name", PropertyType.STRING, new PropertyRules( + null, + PropertyFormat.EMAIL, + List.of("prod", "dev"), + "^[a-z]+$", + 5, + 3, + null, + null)); + + var violations = service.validatePropertyValue(definition, "AA"); + + assertEquals(4, violations.size()); + } + + @Test + @DisplayName("Should report number bound violations") + void shouldReportNumberBoundViolations() { + var definition = propertyDefinition("size", PropertyType.NUMBER, new PropertyRules( + null, + null, + null, + null, + null, + null, + 10, + 5)); + + var violations = service.validatePropertyValue(definition, "3"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + } + + @Test + @DisplayName("Should accept valid boolean value") + void shouldAcceptBooleanValues() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "true"); + + assertEquals(List.of(), violations); + } + + private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { + return new PropertyDefinition(null, name, "description", type, true, rules); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 9675a33..ccee22d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -17,21 +17,19 @@ import com.decathlon.idp_core.AbstractIntegrationTest; - /// Integration tests for the EntityController REST API endpoints. - /// These tests verify the behavior of entity retrieval endpoints, including - /// pagination, authentication, and lookup by template identifier and entity - /// identifier. +/// Integration tests for the EntityController REST API endpoints. +/// These tests verify the behavior of entity retrieval endpoints, including +/// pagination, authentication, and lookup by template identifier and entity +/// identifier. public class EntityControllerTest extends AbstractIntegrationTest { - @Autowired - private MockMvc mockMvc; - private static final String TEMPLATE_IDENTIFIER = "web-service"; private static final String ENTITY_IDENTIFIER = "web-api-2"; private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; - + @Autowired + private MockMvc mockMvc; /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated /// retrieval). @@ -44,9 +42,9 @@ class GetEntitiesByTemplateIdentifierTests { @WithMockUser void getEntities_paginated_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("page", "0") - .param("size", "15") - .accept(APPLICATION_JSON)) + .param("page", "0") + .param("size", "15") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -63,7 +61,7 @@ void getEntities_paginated_200() throws Exception { @WithMockUser void getEntities_paginated_404_when_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -71,7 +69,7 @@ void getEntities_paginated_404_when_non_existent_template() throws Exception { @DisplayName("Should return 401 without authentication") void getTemplates_paginated_401_without_user_token() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isUnauthorized()); } @@ -81,10 +79,10 @@ void getTemplates_paginated_401_without_user_token() throws Exception { void getEntities_paginated_200_custom() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) + .param("page", "1") + .param("size", "5") + .param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content.length()").value(1)) @@ -100,7 +98,7 @@ void getEntities_paginated_200_custom() throws Exception { @WithMockUser void getEntities_invalid_pagination_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -124,7 +122,7 @@ class GetEntitiesByTemplateAndEntityIdentifierTests { @WithMockUser void getEntityByTemplateAndIdentifier_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) @@ -136,7 +134,7 @@ void getEntityByTemplateAndIdentifier_200() throws Exception { @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -145,7 +143,7 @@ void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } } @@ -159,14 +157,108 @@ class PostEntitiesTests { @DisplayName("Should create entity and return 201") void postEntity_201() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) .andExpect(status().isCreated()) .andReturn(); } + @Test + @WithMockUser() + @DisplayName("Should return 400 when required template properties are missing") + void postEntity_400_when_required_properties_missing() throws Exception { + var payload = """ + { + "name": "web-service-missing-required", + "identifier": "web-service-missing-required", + "properties": { + "port": "8080" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property type does not match template") + void postEntity_400_when_property_type_mismatch() throws Exception { + var payload = """ + { + "name": "web-service-invalid-type", + "identifier": "web-service-invalid-type", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "not-a-number", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property rules are not respected") + void postEntity_400_when_property_rules_not_respected() throws Exception { + var payload = """ + { + "name": "web-service-invalid-rules", + "identifier": "web-service-invalid-rules", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "invalid-email", + "port": "80", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "invalid-url", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match required format EMAIL"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match required format URL"), + org.hamcrest.Matchers.containsString("Property 'port' value must be greater than or equal to 1024") + ))); + } + } } 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..d204659 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 @@ -311,10 +311,6 @@ void postTemplate_400_name_invalid_pattern() throws Exception { /// This test verifies that: /// - Validation error message indicates property definitions are /// @throws Exception if the MockMvc request fails - /// Tests the POST /api/v1/entity-templates endpoint when property name field is - /// missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @DisplayName("Returns 400 when property name is missing") 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..c8503d7 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,10 +25,11 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; - import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -105,6 +106,45 @@ void shouldHandleEntityTemplateAlreadyExistsException() { assertEquals(HttpStatus.CONFLICT.name(), body.getError()); assertEquals(expectedMessage, body.getErrorDescription()); } + + /// Tests the handling of [EntityAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the original domain exception message + @Test + @DisplayName("Should handle EntityAlreadyExistsException with 409 status") + void shouldHandleEntityAlreadyExistsException() { + // Given + EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", "api-gateway"); + + // When + ResponseEntity response = exceptionHandler.handleEntityAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle EntityValidationException with 400 status") + void shouldHandleEntityValidationException() { + EntityValidationException exception = new EntityValidationException(java.util.List.of("Invalid property")); + + ResponseEntity response = exceptionHandler.handleEntityValidationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } } @Nested @@ -226,6 +266,29 @@ private ConstraintViolation createMockConstraintViolation(String message @DisplayName("HTTP Message Exception Handling") class HttpMessageExceptionTests { + /// Provides test data for [HttpMessageNotReadableException] scenarios. + /// Each argument contains: input message and expected error description. + static Stream httpMessageNotReadableExceptionTestData() { + return Stream.of( + Arguments.of( + "Required request body is missing: public ResponseEntity", + "Request body is required" + ), + Arguments.of( + "JSON parse error: Unexpected character", + "Invalid JSON format in request body" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_TYPE' for property 'type'" + ), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body" + ) + ); + } + /// Tests the handling of [HttpMessageNotReadableException] when exception message is null. /// /// **This test verifies that:** @@ -252,29 +315,6 @@ void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { assertEquals("Invalid request body format", body.getErrorDescription()); } - /// Provides test data for [HttpMessageNotReadableException] scenarios. - /// Each argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of( - "Required request body is missing: public ResponseEntity", - "Request body is required" - ), - Arguments.of( - "JSON parse error: Unexpected character", - "Invalid JSON format in request body" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'" - ), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body" - ) - ); - } - /// Parameterized test for handling [HttpMessageNotReadableException] with various error scenarios. /// /// **This test verifies that different types of HttpMessageNotReadableException are properly @@ -290,7 +330,7 @@ static Stream httpMessageNotReadableExceptionTestData() { /// - User-friendly error description is provided /// - Error response structure is consistent /// - /// @param originalMessage the original exception message to be processed + /// @param originalMessage the original exception message to be processed /// @param expectedErrorDescription the expected user-friendly error description @ParameterizedTest @MethodSource("httpMessageNotReadableExceptionTestData") diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json new file mode 100644 index 0000000..4c00a50 --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json @@ -0,0 +1,6 @@ +{ + "identifier": "temp-test-0", + "description": "This is a test template", + "properties_definitions": [], + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json new file mode 100644 index 0000000..996e560 --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json @@ -0,0 +1,6 @@ +{ + "identifier": "web-service", + "name": "web-service", + "description": "This is a test template", + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json index 82367a2..3593858 100644 --- a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json @@ -1,9 +1,15 @@ { - "name": "microservice-2", - "identifier": "microservice-2", + "name": "web-service-valid-1", + "identifier": "web-service-valid-1", "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", "port": "8080", - "environment": "dev" - }, - "relations": [] + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } } diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json new file mode 100644 index 0000000..678a6bf --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json @@ -0,0 +1,4 @@ +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json new file mode 100644 index 0000000..8643862 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json @@ -0,0 +1,14 @@ +{ + "name": "microservice-with-relations", + "identifier": "microservice-with-relations", + "properties": { + "port": "9090", + "environment": "staging" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": ["web-api-1"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json new file mode 100644 index 0000000..20e6fe2 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json @@ -0,0 +1,7 @@ +{ + "name": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json new file mode 100644 index 0000000..7c0b057 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json @@ -0,0 +1,7 @@ +{ + "identifier": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json new file mode 100644 index 0000000..570184e --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json @@ -0,0 +1,8 @@ +{ + "name": "entity-prop-no-value", + "identifier": "entity-prop-no-value", + "properties": { + "applicationName": "" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json new file mode 100644 index 0000000..9bb5cbc --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json @@ -0,0 +1,11 @@ +{ + "name": "entity-rel-no-name", + "identifier": "entity-rel-no-name", + "properties": {}, + "relations": [ + { + "name": "", + "target_entity_identifiers": ["some-target"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json new file mode 100644 index 0000000..e850f2b --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json @@ -0,0 +1,6 @@ +{ + "name": "Web API 1 duplicate", + "identifier": "web-api-1", + "properties": {}, + "relations": [] +} From e26e26c56affc05d2b5172dd594fbe2621fa1699 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 09:23:01 +0200 Subject: [PATCH 02/51] feat(core): fix sonar issue and copilot review --- .../service/property/PropertyValidationService.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index 983e1e3..e769e0d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -12,6 +12,8 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import org.springframework.stereotype.Service; @@ -30,6 +32,10 @@ public class PropertyValidationService { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + /// Cache of compiled regex patterns keyed by their source string. + /// Avoids recompiling the same pattern on every property validation call. + private final Map patternCache = new ConcurrentHashMap<>(); + /** * Validates a concrete property value against its property definition. * @@ -62,7 +68,8 @@ private List validateStringPropertyValue(String propertyName, String raw if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); } - if (rules.regex() != null && !Pattern.matches(rules.regex(), rawValue)) { + if (rules.regex() != null + && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(rawValue).matches()) { violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); } if (rules.enumValues() != null && !rules.enumValues().isEmpty() @@ -80,7 +87,7 @@ private List validateNumberPropertyValue(String propertyName, String raw final BigDecimal parsedValue; try { parsedValue = new BigDecimal(rawValue); - } catch (RuntimeException exception) { + } catch (NumberFormatException _) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); } From fd1b3542c653c45bc7d6a5e52ba707e44ac3b951 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 09:47:05 +0200 Subject: [PATCH 03/51] feat(core): fix sonar review --- .../adapters/api/configuration/SecurityConfiguration.java | 1 - .../infrastructure/adapters/api/controller/EntityController.java | 1 - 2 files changed, 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index b882f5b..8105a5d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -5,7 +5,6 @@ import java.util.List; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; 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 ad37b94..c221534 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 @@ -62,7 +62,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; /// REST API adapter providing entity management endpoints. /// From a85955429a888822eea7e8d8409990c573d72aa6 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 11:44:56 +0200 Subject: [PATCH 04/51] feat(core): fix sonar qube and test --- .../domain/constant/ValidationMessages.java | 17 - .../model/entity/EntityJpaEntity.java | 4 +- ..._entity_identifier_unique_to_composite.sql | 9 + .../PropertyValidationServiceTest.java | 354 +++++++++++++++--- .../api/handler/ApiExceptionHandlerTest.java | 67 ++++ 5 files changed, 388 insertions(+), 63 deletions(-) create mode 100644 src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql 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 369f434..a5c0d0f 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 @@ -29,15 +29,6 @@ public class ValidationMessages { public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; - 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_MIN_LENGTH_NON_NEGATIVE = - "Rule 'min_length' must be greater than or equal to 0"; - public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = - "Rule 'max_length' must be greater than 0"; - public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = - "BOOLEAN properties do not allow validation rules"; - public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; @@ -54,12 +45,4 @@ public class ValidationMessages { public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; - - public static String minMaxConstraintViolated(String ruleName) { - return "Rule 'min_" + ruleName + "' must be lower than or equal to 'max_" + ruleName + "'"; - } - - public static String ruleNotAllowed(String ruleName, String propertyType) { - return "Rule '" + ruleName + "' is not allowed for " + propertyType + " properties"; - } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index 75c3337..9e4e0e2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -21,7 +21,9 @@ @jakarta.persistence.Entity @Data -@Table(name = "entity") +@Table(name = "entity", uniqueConstraints = { + @UniqueConstraint(columnNames = {"identifier", "template_identifier"}) +}) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql b/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql new file mode 100644 index 0000000..11255aa --- /dev/null +++ b/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql @@ -0,0 +1,9 @@ +-- Change unique constraint on entity table: +-- Drop the unique constraint on identifier alone +-- Add a composite unique constraint on (identifier, template_identifier) +-- This allows the same identifier to exist across different templates + +ALTER TABLE entity DROP CONSTRAINT entity_identifier_key; + +ALTER TABLE entity ADD CONSTRAINT entity_identifier_template_identifier_key + UNIQUE (identifier, template_identifier); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index f8416e6..3f35fc0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -5,6 +5,7 @@ import java.util.List; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import com.decathlon.idp_core.domain.constant.ValidationMessages; @@ -18,60 +19,323 @@ class PropertyValidationServiceTest { private final PropertyValidationService service = new PropertyValidationService(); - @Test - @DisplayName("Should report type mismatch for non numeric NUMBER value") - void shouldReportTypeMismatchWhenNumberValueIsInvalid() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); + @Nested + @DisplayName("STRING validation") + class StringValidationTests { - var violations = service.validatePropertyValue(definition, "not-a-number"); + @Test + @DisplayName("Should report type mismatch when STRING value is null") + void shouldReportTypeMismatchWhenStringValueIsNull() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + var violations = service.validatePropertyValue(definition, null); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } + + @Test + @DisplayName("Should return no violations when STRING has no rules") + void shouldReturnNoViolationsWhenStringHasNoRules() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, "hello"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when STRING value satisfies all rules") + void shouldReturnNoViolationsWhenStringPassesAllRules() { + var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); + var definition = propertyDefinition("env", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "dev"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minLength violation") + void shouldReportMinLengthViolation() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "ab"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report maxLength violation") + void shouldReportMaxLengthViolation() { + var rules = new PropertyRules(null, null, null, null, 5, null, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "too-long-value"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report regex violation") + void shouldReportRegexViolation() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "abc"); + + assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); + } + + @Test + @DisplayName("Should accept value matching regex") + void shouldAcceptValueMatchingRegex() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "12345"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report enum violation when value not in allowed list") + void shouldReportEnumViolation() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "UNKNOWN"); + + assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); + } + + @Test + @DisplayName("Should accept enum value with case-insensitive match") + void shouldAcceptEnumValueCaseInsensitive() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "active"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should skip enum check when enumValues is empty") + void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { + var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "anything"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid EMAIL") + void shouldReportFormatViolationForInvalidEmail() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-an-email"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); + } + + @Test + @DisplayName("Should accept valid EMAIL format") + void shouldAcceptValidEmailFormat() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "user@example.com"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid URL") + void shouldReportFormatViolationForInvalidUrl() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-a-url"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); + } + + @Test + @DisplayName("Should accept valid URL format") + void shouldAcceptValidUrlFormat() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); - @Test - @DisplayName("Should report string constraint violations") - void shouldReportStringRuleViolations() { - var definition = propertyDefinition("name", PropertyType.STRING, new PropertyRules( - null, - PropertyFormat.EMAIL, - List.of("prod", "dev"), - "^[a-z]+$", - 5, - 3, - null, - null)); - - var violations = service.validatePropertyValue(definition, "AA"); - - assertEquals(4, violations.size()); + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report multiple violations at once") + void shouldReportMultipleStringViolations() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "AA"); + + assertEquals(4, violations.size()); + } + + @Test + @DisplayName("Should use cached pattern for repeated regex validations") + void shouldUseCachedPatternForRepeatedRegex() { + var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + // Validate twice with the same regex to exercise the cache + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); + + assertEquals(List.of(), violations1); + assertEquals(List.of(), violations2); + } } - @Test - @DisplayName("Should report number bound violations") - void shouldReportNumberBoundViolations() { - var definition = propertyDefinition("size", PropertyType.NUMBER, new PropertyRules( - null, - null, - null, - null, - null, - null, - 10, - 5)); - - var violations = service.validatePropertyValue(definition, "3"); - - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + @Nested + @DisplayName("NUMBER validation") + class NumberValidationTests { + + @Test + @DisplayName("Should report type mismatch for non-numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "not-a-number"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER has no rules") + void shouldReturnNoViolationsWhenNumberHasNoRules() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "42"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER is within bounds") + void shouldReturnNoViolationsWhenNumberIsWithinBounds() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("score", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "50"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minValue violation") + void shouldReportMinValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "3"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + } + + @Test + @DisplayName("Should report maxValue violation") + void shouldReportMaxValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "15"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); + } + + @Test + @DisplayName("Should report both minValue and maxValue violations") + void shouldReportBothMinAndMaxViolations() { + // minValue > maxValue edge case — value below min triggers min violation + var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); + var definition = propertyDefinition("range", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "7"); + + // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation + assertEquals(2, violations.size()); + } + + @Test + @DisplayName("Should accept decimal number values") + void shouldAcceptDecimalNumberValues() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "99.5"); + + assertEquals(List.of(), violations); + } } - @Test - @DisplayName("Should accept valid boolean value") - void shouldAcceptBooleanValues() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { + + @Test + @DisplayName("Should accept 'true' value") + void shouldAcceptTrueValue() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "true"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept 'false' value") + void shouldAcceptFalseValue() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "false"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept case-insensitive 'TRUE'") + void shouldAcceptUppercaseTrue() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "TRUE"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept case-insensitive 'FALSE'") + void shouldAcceptUppercaseFalse() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "FALSE"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "yes"); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); + } } private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { 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 c8503d7..3433491 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 @@ -26,7 +26,9 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +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.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; @@ -145,6 +147,55 @@ void shouldHandleEntityValidationException() { assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); assertEquals(exception.getMessage(), body.getErrorDescription()); } + + /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNameAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and description + @Test + @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateNameAlreadyExistsException() { + // Given + String name = "Duplicate Name"; + EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException(name); + + // When + ResponseEntity response = exceptionHandler.handleEntityTemplateNameAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityNotFoundException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the entity-specific context message + @Test + @DisplayName("Should handle EntityNotFoundException with 404 status") + void shouldHandleEntityNotFoundException() { + // Given + EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); + + // When + ResponseEntity response = exceptionHandler.handleEntityNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } } @Nested @@ -282,9 +333,25 @@ static Stream httpMessageNotReadableExceptionTestData() { "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", "Invalid value 'INVALID_TYPE' for property 'type'" ), + Arguments.of( + "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_FORMAT' for property 'format'" + ), Arguments.of( "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", "Invalid enum value in request body" + ), + Arguments.of( + "Cannot deserialize value of type `com.example.SomeType`: some other error", + "Cannot deserialize request body property" + ), + Arguments.of( + "Something completely unexpected happened", + "Invalid request body format" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", + "Invalid value for property 'type'" ) ); } From 39580890b1154549877e53310f614f0b82528dbd Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 14:56:43 +0200 Subject: [PATCH 05/51] feat(core): fix validation type check --- .../domain/model/entity/Property.java | 11 +- .../entity/EntityValidationService.java | 2 +- .../property/PropertyValidationService.java | 41 +++++- .../api/mapper/entity/EntityDtoInMapper.java | 3 +- .../mapper/EntityPersistenceMapper.java | 2 + .../entity/EntityValidationServiceTest.java | 8 +- .../PropertyValidationServiceTest.java | 122 ++++++++++-------- 7 files changed, 126 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 4c15dcd..015d0fb 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -22,6 +22,9 @@ /// - Property values must satisfy all validation rules from [PropertyRules] /// - Required properties cannot have empty values /// - Property types must align with the template's [PropertyType] definition +/// +/// @param rawValue the original untyped value from the API input, used for type checking +/// during validation. May be null when loaded from persistence. public record Property( UUID id, @@ -29,6 +32,12 @@ public record Property( String name, @NotBlank(message = PROPERTY_VALUE_MANDATORY) - String value + String value, + + Object rawValue ) { + /// Convenience constructor for persistence and test scenarios where raw value is not needed. + public Property(UUID id, String name, String value) { + this(id, name, value, null); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index f535e7d..7b7c18c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -133,7 +133,7 @@ private void validateAgainstTemplate(EntityTemplate template, } propertyValidationService - .validatePropertyValue(definition, property.value()) + .validatePropertyValue(definition, property.value(), property.rawValue()) .forEach(violations::add); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index e769e0d..d2ba01c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -38,12 +38,20 @@ public class PropertyValidationService { /** * Validates a concrete property value against its property definition. + * Type compatibility is checked first against the original raw value + * before applying any rule-based validations. * * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value + * @param rawValue raw property value as string + * @param originalValue the original untyped value from the API input for type checking, + * may be null when loaded from persistence * @return list of violations for this value; empty when valid */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue) { + public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue, Object originalValue) { + List typeMismatch = checkOriginalValueType(propertyDefinition.name(), propertyDefinition.type(), originalValue); + if (!typeMismatch.isEmpty()) { + return typeMismatch; + } return switch (propertyDefinition.type()) { case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); @@ -51,6 +59,35 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, }; } + /// Checks that the original JSON value type is compatible with the expected [PropertyType]. + /// + /// When `originalValue` is non-null, its Java type is inspected: + /// - STRING expects a Java `String` + /// - NUMBER expects a Java `Number` + /// - BOOLEAN expects a Java `Boolean` + /// + /// If `originalValue` is null (e.g. loaded from persistence), the check is skipped + /// and type validation falls through to the string-based validators. + /// + /// @param propertyName property name for error reporting + /// @param expectedType the expected property type from the template definition + /// @param originalValue the original untyped value from the API input + /// @return a single-element list with a type mismatch message, or an empty list if compatible + private List checkOriginalValueType(String propertyName, PropertyType expectedType, Object originalValue) { + if (originalValue == null) { + return List.of(); + } + boolean compatible = switch (expectedType) { + case STRING -> originalValue instanceof String; + case NUMBER -> originalValue instanceof Number || originalValue instanceof String; + case BOOLEAN -> originalValue instanceof Boolean || originalValue instanceof String; + }; + if (!compatible) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, expectedType)); + } + return List.of(); + } + private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { if (rawValue == null) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 1f6ad3a..7bc0a59 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -50,7 +50,8 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp return new Property( null, entry.getKey(), - value + value, + entry.getValue() ); }) .toList(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 120fd65..c22ffbb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -17,6 +18,7 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); + @Mapping(target = "rawValue", ignore = true) Property toDomain(PropertyJpaEntity jpa); PropertyJpaEntity toJpa(Property domain); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 4cdd394..02c7116 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -142,7 +142,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + when(propertyValidationService.validatePropertyValue(portDefinition, "80", null)) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); @@ -157,7 +157,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); - verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + verify(propertyValidationService).validatePropertyValue(portDefinition, "80", null); } @Test @@ -189,10 +189,10 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0", null)).thenReturn(List.of()); assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0", null); } @Test diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 3f35fc0..2ffdef8 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -7,6 +7,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.constant.ValidationMessages; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; @@ -28,7 +30,7 @@ class StringValidationTests { void shouldReportTypeMismatchWhenStringValueIsNull() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null); + var violations = service.validatePropertyValue(definition, null, null); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); } @@ -38,7 +40,7 @@ void shouldReportTypeMismatchWhenStringValueIsNull() { void shouldReturnNoViolationsWhenStringHasNoRules() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello"); + var violations = service.validatePropertyValue(definition, "hello", "hello"); assertEquals(List.of(), violations); } @@ -49,7 +51,7 @@ void shouldReturnNoViolationsWhenStringPassesAllRules() { var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev"); + var violations = service.validatePropertyValue(definition, "dev", "dev"); assertEquals(List.of(), violations); } @@ -60,7 +62,7 @@ void shouldReportMinLengthViolation() { var rules = new PropertyRules(null, null, null, null, null, 5, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab"); + var violations = service.validatePropertyValue(definition, "ab", "ab"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -71,7 +73,7 @@ void shouldReportMaxLengthViolation() { var rules = new PropertyRules(null, null, null, null, 5, null, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value", "too-long-value"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -82,7 +84,7 @@ void shouldReportRegexViolation() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc"); + var violations = service.validatePropertyValue(definition, "abc", "abc"); assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); } @@ -93,7 +95,7 @@ void shouldAcceptValueMatchingRegex() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345"); + var violations = service.validatePropertyValue(definition, "12345", "12345"); assertEquals(List.of(), violations); } @@ -104,7 +106,7 @@ void shouldReportEnumViolation() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN", "UNKNOWN"); assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); } @@ -115,7 +117,7 @@ void shouldAcceptEnumValueCaseInsensitive() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active"); + var violations = service.validatePropertyValue(definition, "active", "active"); assertEquals(List.of(), violations); } @@ -126,7 +128,7 @@ void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything"); + var violations = service.validatePropertyValue(definition, "anything", "anything"); assertEquals(List.of(), violations); } @@ -137,7 +139,7 @@ void shouldReportFormatViolationForInvalidEmail() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email", "not-an-email"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); } @@ -148,7 +150,7 @@ void shouldAcceptValidEmailFormat() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com", "user@example.com"); assertEquals(List.of(), violations); } @@ -159,7 +161,7 @@ void shouldReportFormatViolationForInvalidUrl() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url", "not-a-url"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); } @@ -170,7 +172,7 @@ void shouldAcceptValidUrlFormat() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo", "https://github.com/org/repo"); assertEquals(List.of(), violations); } @@ -181,7 +183,7 @@ void shouldReportMultipleStringViolations() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA"); + var violations = service.validatePropertyValue(definition, "AA", "AA"); assertEquals(4, violations.size()); } @@ -193,12 +195,33 @@ void shouldUseCachedPatternForRepeatedRegex() { var definition = propertyDefinition("code", PropertyType.STRING, rules); // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc"); - var violations2 = service.validatePropertyValue(definition, "def"); + var violations1 = service.validatePropertyValue(definition, "abc", "abc"); + var violations2 = service.validatePropertyValue(definition, "def", "def"); assertEquals(List.of(), violations1); assertEquals(List.of(), violations2); } + + @Test + @DisplayName("Should report type mismatch when a number is sent for a STRING property") + void shouldReportTypeMismatchWhenNumberSentForString() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("label", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "12", 12); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } + + @Test + @DisplayName("Should report type mismatch when a boolean is sent for a STRING property") + void shouldReportTypeMismatchWhenBooleanSentForString() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, "true", true); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } } @Nested @@ -210,7 +233,7 @@ class NumberValidationTests { void shouldReportTypeMismatchWhenNumberValueIsInvalid() { var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number", "not-a-number"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); } @@ -220,7 +243,7 @@ void shouldReportTypeMismatchWhenNumberValueIsInvalid() { void shouldReturnNoViolationsWhenNumberHasNoRules() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42", 42); assertEquals(List.of(), violations); } @@ -231,7 +254,7 @@ void shouldReturnNoViolationsWhenNumberIsWithinBounds() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50"); + var violations = service.validatePropertyValue(definition, "50", 50); assertEquals(List.of(), violations); } @@ -242,7 +265,7 @@ void shouldReportMinValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3"); + var violations = service.validatePropertyValue(definition, "3", 3); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); } @@ -253,7 +276,7 @@ void shouldReportMaxValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15"); + var violations = service.validatePropertyValue(definition, "15", 15); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); } @@ -265,7 +288,7 @@ void shouldReportBothMinAndMaxViolations() { var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7"); + var violations = service.validatePropertyValue(definition, "7", 7); // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation assertEquals(2, violations.size()); @@ -277,62 +300,53 @@ void shouldAcceptDecimalNumberValues() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5"); + var violations = service.validatePropertyValue(definition, "99.5", 99.5); assertEquals(List.of(), violations); } - } - - @Nested - @DisplayName("BOOLEAN validation") - class BooleanValidationTests { @Test - @DisplayName("Should accept 'true' value") - void shouldAcceptTrueValue() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") + void shouldReportTypeMismatchWhenBooleanSentForNumber() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "true", true); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); } + } - @Test - @DisplayName("Should accept 'false' value") - void shouldAcceptFalseValue() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); - - var violations = service.validatePropertyValue(definition, "false"); - - assertEquals(List.of(), violations); - } + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { - @Test - @DisplayName("Should accept case-insensitive 'TRUE'") - void shouldAcceptUppercaseTrue() { + @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") + @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) + void shouldAcceptValidBooleanValues(String value) { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + Object originalValue = "true".equalsIgnoreCase(value) ? Boolean.TRUE : Boolean.FALSE; - var violations = service.validatePropertyValue(definition, "TRUE"); + var violations = service.validatePropertyValue(definition, value, originalValue); assertEquals(List.of(), violations); } @Test - @DisplayName("Should accept case-insensitive 'FALSE'") - void shouldAcceptUppercaseFalse() { + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "FALSE"); + var violations = service.validatePropertyValue(definition, "yes", "yes"); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } @Test - @DisplayName("Should report type mismatch for invalid boolean value") - void shouldReportTypeMismatchForInvalidBoolean() { + @DisplayName("Should report type mismatch when a number is sent for a BOOLEAN property") + void shouldReportTypeMismatchWhenNumberSentForBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes"); + var violations = service.validatePropertyValue(definition, "42", 42); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } From fb272c259f8a930f45b1072cda4cf5c35d035e46 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Tue, 5 May 2026 16:13:06 +0200 Subject: [PATCH 06/51] feat(core): fix review --- .../domain/constant/ValidationMessages.java | 30 ++ .../EntityAlreadyExistsException.java | 13 +- .../{ => entity}/EntityNotFoundException.java | 2 +- .../EntityValidationException.java | 15 +- .../EntityTemplateAlreadyExistsException.java | 3 +- ...ityTemplateNameAlreadyExistsException.java | 3 +- .../EntityTemplateNotFoundException.java | 2 +- ...pertyDefinitionRulesConflictException.java | 25 ++ .../idp_core/domain/model/entity/Entity.java | 3 + .../domain/model/entity/Property.java | 19 +- .../domain/service/EntityTemplateService.java | 6 +- .../domain/service/entity/EntityService.java | 36 ++- .../entity/EntityValidationService.java | 87 +---- .../EntityTemplateValidationService.java | 118 +++++++ .../PropertyDefinitionValidationService.java | 297 ++++++++++++++++++ .../property/PropertyValidationService.java | 84 ++--- .../api/controller/EntityController.java | 6 +- .../api/handler/ApiExceptionHandler.java | 12 +- .../api/mapper/entity/EntityDtoInMapper.java | 23 +- .../api/mapper/entity/EntityDtoOutMapper.java | 81 +++-- .../mapper/EntityPersistenceMapper.java | 19 +- .../service/entity/EntityServiceTest.java | 50 +-- .../entity/EntityValidationServiceTest.java | 124 +++----- .../PropertyValidationServiceTest.java | 78 ++--- .../api/handler/ApiExceptionHandlerTest.java | 12 +- 25 files changed, 773 insertions(+), 375 deletions(-) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityAlreadyExistsException.java (51%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityNotFoundException.java (95%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityValidationException.java (50%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateAlreadyExistsException.java (92%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateNameAlreadyExistsException.java (87%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateNotFoundException.java (97%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.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 a5c0d0f..9bf3e8a 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 @@ -30,6 +30,14 @@ public class ValidationMessages { public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + // 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_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"; + // 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"; @@ -45,4 +53,26 @@ public class ValidationMessages { public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + + 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 + .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/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java similarity index 51% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java index bd76169..8243748 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java @@ -1,10 +1,19 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity.Entity; -/// Domain exception for duplicate [Entity] business entities within a template scope. +/// Domain exception for duplicate [Entity] business entities within the same template context. +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an Entity that already exist within a specific template context. +/// This enforces the business invariant that entities must be unique within their template context. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require unique entities within a template context +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity public class EntityAlreadyExistsException extends RuntimeException { /// Constructs a new exception with template and entity identifiers. diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java similarity index 95% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java index cc7d4a8..42c60f6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NOT_FOUND; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java similarity index 50% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index ca9da64..42756f0 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_VALIDATION_FAILED; @@ -7,6 +7,19 @@ import lombok.Getter; /// Domain exception for entity schema validation failures +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an entity, or update an entity, with property values that +/// do not conform to the validation rules defined in the entity's template. +/// This includes violations of required properties, type mismatches, and template rules +/// This enforces the business invariant that entities must conform to the validation +/// rules defined in their template's property definitions and relation constraints. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require valid property values +/// that conform to template rules +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity @Getter public class EntityValidationException extends RuntimeException { 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 92% 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 aff0ad4..12aee0d 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,9 +1,10 @@ -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; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; /// 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/entity_template/EntityTemplateNameAlreadyExistsException.java similarity index 87% 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 885bc83..d1c7104 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,9 +1,10 @@ -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; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; /// 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/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/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java new file mode 100644 index 0000000..f68a840 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.domain.exception.property; + +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. +/// +/// **Usage patterns:** +/// - Property template creation with invalid rules +/// - Property template updates introducing rule conflicts +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 PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + + ": " + violationMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 6250a5a..2b77241 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.UUID; +import org.springframework.validation.annotation.Validated; + import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import jakarta.validation.constraints.NotBlank; @@ -21,6 +23,7 @@ /// /// Ubiquitous language: An Entity is a materialized instance of a template schema, /// containing actual values that comply with the template's structure and rules. + public record Entity( UUID id, diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 015d0fb..7850124 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -1,7 +1,6 @@ package com.decathlon.idp_core.domain.model.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; import java.util.UUID; @@ -20,24 +19,16 @@ /// **Business invariants:** /// - Property names must match a [PropertyDefinition] name in the entity's template /// - Property values must satisfy all validation rules from [PropertyRules] -/// - Required properties cannot have empty values -/// - Property types must align with the template's [PropertyType] definition -/// -/// @param rawValue the original untyped value from the API input, used for type checking -/// during validation. May be null when loaded from persistence. +/// - Required properties cannot have null/blank values +/// - Property values must be typed according to the template's [PropertyType] definition +/// (carried as [Object] so the original JSON type — String, Number, Boolean — is preserved +/// for strict type-mismatch detection at validation time). public record Property( UUID id, @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - @NotBlank(message = PROPERTY_VALUE_MANDATORY) - String value, - - Object rawValue + Object value ) { - /// Convenience constructor for persistence and test scenarios where raw value is not needed. - public Property(UUID id, String name, String value) { - this(id, name, value, null); - } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java index d9cf376..7d27fc5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java @@ -12,9 +12,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.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.model.entity_template.PropertyRules; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 3f5de08..4fa2da2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,18 +2,22 @@ import java.util.List; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -29,10 +33,13 @@ /// - Entity data integrity validation (entity, properties, relations) /// - Entity summary generation for efficient queries @Service -@AllArgsConstructor +@Validated +@RequiredArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; + private final EntityTemplateRepositoryPort entityTemplateRepository; private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; /// Retrieves entities filtered by template with existence validation. /// @@ -72,8 +79,8 @@ public List getEntitiesSummariesByIndentifiers(List ident /// @throws EntityTemplateNotFoundException when template doesn't exist /// @throws EntityNotFoundException when entity doesn't exist @Transactional - public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifier, String entityIdentifier) { - entityValidationService.checkTemplateExist(templateIdentifier); + public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { + entityTemplateValidationService.checkTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); @@ -81,8 +88,10 @@ public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifie /// Creates and persists a new entity with business validation. /// - /// **Contract:** Validates template existence, entity identifier uniqueness within - /// the template scope, and entity/property/relation data integrity before persisting. + /// **Contract:** Resolves the referenced template (single round-trip — combined + /// existence check and fetch), enforces entity identifier uniqueness within the + /// template scope, then validates entity/property data integrity against the + /// resolved template before persisting. /// /// @param entity validated entity to create and persist /// @return the persisted entity with generated identifiers @@ -91,9 +100,10 @@ public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifie /// @throws EntityValidationException when entity, property, or relation data is invalid @Transactional public Entity createEntity(@Valid Entity entity) { - entityValidationService.checkTemplateExist(entity.templateIdentifier()); - entityValidationService.checkEntityAlreadyExist(entity); - entityValidationService.validateEntity(entity); + EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); + entityValidationService.checkUniqueness(entity); + entityValidationService.validateEntity(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 7b7c18c..4902016 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -1,12 +1,6 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import java.util.List; import java.util.Map; @@ -16,16 +10,13 @@ import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; -import com.decathlon.idp_core.domain.model.entity.Relation; 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.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; /// Domain validator for [Entity] aggregates. @@ -39,34 +30,21 @@ public class EntityValidationService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; private final PropertyValidationService propertyValidationService; - /// Check entity template existence to ensure valid template reference before deeper validations. - /// @param entity the entity whose template existence is to be checked - /// @throws EntityTemplateNotFoundException if the template referenced by the entity does not exist - void checkTemplateExist(final String entity) { - if (!entityTemplateRepository.existsByIdentifier(entity)) { - throw new EntityTemplateNotFoundException("identifier", entity); - } - } - - /// Validates intrinsic entity data integrity and template-driven rules. + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// **Contract:** the caller is responsible for resolving the [EntityTemplate] + /// (typically via [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) + /// and passing it in. This avoids a redundant database round-trip and clarifies + /// the dependency graph of the validation service. /// /// @param entity the entity to validate + /// @param template the already-resolved template the entity must conform to /// @throws EntityValidationException when one or more validation rules are violated /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - /// @throws EntityTemplateNotFoundException if the referenced template does not exist - void validateEntity(Entity entity) { - checkEntityAlreadyExist(entity); - EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); - + void validateEntity(Entity entity, EntityTemplate template) { Violations violations = new Violations(); - - validateEntityHeader(entity, violations); - validatePropertiesShape(entity.properties(), violations); - validateRelationsShape(entity.relations(), violations); validateAgainstTemplate(template, entity.properties(), violations); if (!violations.isEmpty()) { @@ -74,45 +52,10 @@ void validateEntity(Entity entity) { } } - private void validateEntityHeader(Entity entity, Violations violations) { - violations.addIfBlank(entity.name(), ENTITY_NAME_MANDATORY); - violations.addIfBlank(entity.identifier(), ENTITY_IDENTIFIER_MANDATORY); - } - - private void validatePropertiesShape(List properties, Violations violations) { - if (properties == null) { - return; - } - for (int i = 0; i < properties.size(); i++) { - Property prop = properties.get(i); - if (prop.name() == null || prop.name().isBlank()) { - violations.addIndexed("Property", i, PROPERTY_NAME_MANDATORY); - } - if (prop.value() == null || prop.value().isBlank()) { - violations.addIndexed("Property", i, PROPERTY_VALUE_MANDATORY); - } - } - } - - private void validateRelationsShape(List relations, Violations violations) { - if (relations == null) { - return; - } - for (int i = 0; i < relations.size(); i++) { - Relation rel = relations.get(i); - if (rel.name() == null || rel.name().isBlank()) { - violations.addIndexed("Relation", i, RELATION_NAME_MANDATORY_SIMPLE); - } - if (rel.targetEntityIdentifiers() == null) { - violations.addIndexed("Relation", i, RELATION_TARGET_IDENTIFIERS_NOT_NULL); - } - } - } - /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation /// @param properties the list of properties from the entity to validate - /// @param violations the accumulator for validation violation messages + /// @param violations the accumulator for validation v iolation messages private void validateAgainstTemplate(EntityTemplate template, List properties, Violations violations) { @@ -123,7 +66,9 @@ private void validateAgainstTemplate(EntityTemplate template, for (PropertyDefinition definition : definitions) { Property property = propertiesByName.get(definition.name()); - boolean missing = property == null || property.value() == null || property.value().isBlank(); + boolean missing = property == null + || property.value() == null + || (property.value() instanceof String s && s.isBlank()); if (missing) { if (definition.required()) { @@ -133,7 +78,7 @@ private void validateAgainstTemplate(EntityTemplate template, } propertyValidationService - .validatePropertyValue(definition, property.value(), property.rawValue()) + .validatePropertyValue(definition, property.value()) .forEach(violations::add); } } @@ -141,7 +86,7 @@ private void validateAgainstTemplate(EntityTemplate template, /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void checkEntityAlreadyExist(final Entity entity) { + void checkUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.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..34aab2f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -0,0 +1,118 @@ +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) { + validateIdentifierUniqueness(entityTemplate.identifier()); + validateNameUniqueness(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())) { + validateIdentifierUniqueness(mergedTemplate.identifier()); + } + if (!Objects.equals(existingName, mergedTemplate.name())) { + validateNameUniqueness(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"); + } + 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); + } + } + + /// 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 + public 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 + public void validateNameUniqueness(String name) { + if (entityTemplateRepositoryPort.existsByName(name)) { + throw new EntityTemplateNameAlreadyExistsException(name); + } + } + + public void validatePropertyRules(EntityTemplate entityTemplate) { + if (entityTemplate.propertiesDefinitions() == null) { + return; + } + for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { + propertyDefinitionValidationService.validatePropertyDefinitionRules(property); + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..cebf279 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -0,0 +1,297 @@ +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_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; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.rulesAreIncompatible; + + +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.property.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:** +/// - 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. +/// +@Service +public class PropertyDefinitionValidationService { + + // 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"; + + /// 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 PropertyDefinitionRulesConflictException when rules violate business invariants + public void validatePropertyDefinitionRules(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) + /// **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 rules defined violate any of the above constraints + private void validateStringPropertyRules(String propertyName, PropertyRules rules) { + 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( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE + ); + } + // Validate max_length is not zero or negative + if (rules.maxLength() != null && rules.maxLength() <= 0) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + 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 PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + minMaxConstraintViolated(LENGTH) + ); + } + } + + /// 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 + /// **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 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 PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) + ); + } + + if (rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) + ); + } + + if (rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) + ); + } + + if (rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) + ); + } + + if (rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) + ); + } + + if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { + throw new PropertyDefinitionRulesConflictException( + 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 PropertyDefinitionRulesConflictException when any rule is set for BOOLEAN + private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null || + rules.enumValues() != null || + rules.regex() != null || + rules.maxLength() != null || + rules.minLength() != null || + rules.maxValue() != null || + rules.minValue() != null) { + + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.BOOLEAN, + PROPERTY_RULES_BOOLEAN_NOT_ALLOWED + ); + } + } + + /// 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 PropertyDefinitionRulesConflictException if the pattern is syntactically invalid + private void validateRegexPattern(String propertyName, String regexPattern) { + try { + Pattern.compile(regexPattern); + } catch (PatternSyntaxException e) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + "Invalid regex pattern: " + e.getMessage() + ); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index d2ba01c..604891c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -38,20 +38,16 @@ public class PropertyValidationService { /** * Validates a concrete property value against its property definition. - * Type compatibility is checked first against the original raw value - * before applying any rule-based validations. + * The value's runtime Java type is checked first against the expected + * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, + * BOOLEAN ⇒ {@link Boolean}). When the type matches, the value is + * normalized to a string and the type-specific rules are evaluated. * * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value as string - * @param originalValue the original untyped value from the API input for type checking, - * may be null when loaded from persistence + * @param rawValue raw property value preserving its original JSON type * @return list of violations for this value; empty when valid */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue, Object originalValue) { - List typeMismatch = checkOriginalValueType(propertyDefinition.name(), propertyDefinition.type(), originalValue); - if (!typeMismatch.isEmpty()) { - return typeMismatch; - } + public List validatePropertyValue(PropertyDefinition propertyDefinition, Object rawValue) { return switch (propertyDefinition.type()) { case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); @@ -59,37 +55,9 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, }; } - /// Checks that the original JSON value type is compatible with the expected [PropertyType]. - /// - /// When `originalValue` is non-null, its Java type is inspected: - /// - STRING expects a Java `String` - /// - NUMBER expects a Java `Number` - /// - BOOLEAN expects a Java `Boolean` - /// - /// If `originalValue` is null (e.g. loaded from persistence), the check is skipped - /// and type validation falls through to the string-based validators. - /// - /// @param propertyName property name for error reporting - /// @param expectedType the expected property type from the template definition - /// @param originalValue the original untyped value from the API input - /// @return a single-element list with a type mismatch message, or an empty list if compatible - private List checkOriginalValueType(String propertyName, PropertyType expectedType, Object originalValue) { - if (originalValue == null) { - return List.of(); - } - boolean compatible = switch (expectedType) { - case STRING -> originalValue instanceof String; - case NUMBER -> originalValue instanceof Number || originalValue instanceof String; - case BOOLEAN -> originalValue instanceof Boolean || originalValue instanceof String; - }; - if (!compatible) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, expectedType)); - } - return List.of(); - } - private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { - if (rawValue == null) { + private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + if (!(rawValue instanceof String stringValue)) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); } @@ -99,33 +67,41 @@ private List validateStringPropertyValue(String propertyName, String raw var violations = new ArrayList(); - if (rules.minLength() != null && rawValue.length() < rules.minLength()) { + if (rules.minLength() != null && stringValue.length() < rules.minLength()) { violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); } - if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { + if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); } if (rules.regex() != null - && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(rawValue).matches()) { + && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(stringValue).matches()) { violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); } if (rules.enumValues() != null && !rules.enumValues().isEmpty() - && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(rawValue))) { + && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); } - if (rules.format() != null && !matchesFormat(rules.format(), rawValue)) { + if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); } return List.copyOf(violations); } - private List validateNumberPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { final BigDecimal parsedValue; - try { - parsedValue = new BigDecimal(rawValue); - } catch (NumberFormatException _) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + switch (rawValue) { + case Number number -> parsedValue = new BigDecimal(number.toString()); + case String string -> { + try { + parsedValue = new BigDecimal(string); + } catch (NumberFormatException _) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + } + case null, default -> { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } } if (rules == null) { @@ -144,8 +120,12 @@ private List validateNumberPropertyValue(String propertyName, String raw return List.copyOf(violations); } - private List validateBooleanPropertyValue(String propertyName, String rawValue) { - if ("true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue)) { + private List validateBooleanPropertyValue(String propertyName, Object rawValue) { + if (rawValue instanceof Boolean) { + return List.of(); + } + if (rawValue instanceof String string + && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { return List.of(); } return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); 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 c221534..f9f8d90 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 @@ -31,7 +31,7 @@ import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -74,7 +74,7 @@ @RestController @RequestMapping("/api/v1/entities") @Tag(name = "Entities Management", description = "Operations related to entity management") -@AllArgsConstructor +@RequiredArgsConstructor public class EntityController { private final EntityService entityService; @@ -127,7 +127,7 @@ public Page getEntities( public EntityDtoOut getEntity( @PathVariable String templateIdentifier, @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAnIdentifier(templateIdentifier, entityIdentifier); + Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); return entityDtoOutMapper.fromEntity(entity); } 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 1cfbf69..393e68c 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 @@ -13,12 +13,12 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -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.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +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.exception.entity.EntityValidationException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 7bc0a59..45dc45a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.Map; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -28,7 +28,7 @@ /// **API contract support:** Enables clean separation between API request format /// and internal domain model structure for maintainable API evolution. @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoInMapper { /// Converts an entity creation request DTO to a domain entity. @@ -40,20 +40,11 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> { - String value; - if (entry.getValue() != null) { - value = String.valueOf(entry.getValue()); - } else { - value = null; - } - return new Property( - null, - entry.getKey(), - value, - entry.getValue() - ); - }) + .map((Map.Entry entry) -> new Property( + null, + entry.getKey(), + entry.getValue() + )) .toList(); List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() 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 0072134..1216224 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 @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; @@ -20,14 +21,12 @@ 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.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.EntityService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting domain [Entity] objects to API DTOs. /// /// **Infrastructure mapping responsibilities:** @@ -46,7 +45,7 @@ /// - Integrates with Jackson for JSON serialization patterns /// - Stateless design ensures thread safety in web containers @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoOutMapper { private final EntityTemplateService entityTemplateService; @@ -72,11 +71,11 @@ public EntityDtoOut fromEntity(Entity entity) { /// to minimize database queries. Builds summary maps for efficient relationship /// resolution across the entire page. /// - /// @param entities paginated domain entities from repository layer + /// @param entities paginated domain entities from repository layer /// @param entityTemplateIdentifier template identifier for batch template resolution /// @return paginated API DTOs with complete relationship data public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { + String entityTemplateIdentifier) { Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( @@ -94,7 +93,7 @@ public Page fromEntitiesPageToDtoPage(Page entities, /// @param entity the entity to map /// @param entityTemplate the template for property type mapping /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { Map props = mapPropertiesDto(entity, entityTemplate); List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); @@ -120,13 +119,13 @@ private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate /// /// @param entity the entity to map /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation - /// targets + /// @param relatedEntitiesSummaries map of entity summaries for relation + /// targets /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return the mapped DTO private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, - Map relatedEntitiesSummaries, - Map> relationTargetOwnershipsMap) { + Map relatedEntitiesSummaries, + Map> relationTargetOwnershipsMap) { Map props = mapPropertiesDto(entity, entityTemplate); Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaries); @@ -163,44 +162,42 @@ private Map mapPropertiesDto(Entity entity, EntityTemplate entit 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); + Object rawValue = prop.value(); + if (def == null || rawValue == null) { + return rawValue; + } + String stringValue = String.valueOf(rawValue); + PropertyType type = def.type(); + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(stringValue); + } catch (NumberFormatException _) { + return null; } - // Default to string - return value; - } else { - // Fallback if propertyDefinition is missing - return prop.value(); + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(stringValue); } + return stringValue; })); } /// Maps the relations of an entity to a map of relation names to lists of target /// entity summaries. /// - /// @param entity the entity whose relations to map + /// @param entity the entity whose relations to map /// @param relatedEntitiesSummaries map of entity summaries for relation targets /// @return a map of relation names to lists of target entity summaries private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { + Map relatedEntitiesSummaries) { return entity.relations() == null ? Collections.emptyMap() : entity.relations().stream() - .collect(Collectors.groupingBy( - Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() + .collect(Collectors.groupingBy( + Relation::name, + Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() .map(relatedEntitiesSummaries::get) .filter(Objects::nonNull), - Collectors.toList()))); + Collectors.toList()))); } /// @@ -208,11 +205,11 @@ private Map> mapRelationsDto(Entity entity, /// lists of source entity summaries. /// /// @param entity the entity whose relations-as-target to - /// map + /// map /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return a map of relation names to lists of source entity summaries private Map> mapRelationsAsTargetDto(Entity entity, - Map> relationTargetOwnershipsMap) { + Map> relationTargetOwnershipsMap) { List relationAsTargetSummaries = relationTargetOwnershipsMap .get(entity.identifier()); if (relationAsTargetSummaries == null) { @@ -271,8 +268,8 @@ private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { return entity.relations() == null ? Collections.emptyList() : new ArrayList<>(entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream()) - .collect(Collectors.toSet())); + .flatMap(rel -> rel.targetEntityIdentifiers().stream()) + .collect(Collectors.toSet())); } /// @@ -286,7 +283,7 @@ private List getUniqueTargetIdentifiersInPage(Page entities) { .flatMap(entity -> entity.relations() == null ? Stream.empty() : entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream())) + .flatMap(rel -> rel.targetEntityIdentifiers().stream())) .collect(Collectors.toSet())); } @@ -309,10 +306,10 @@ private Map buildEntitiesSummariesMap(List tar return targetIdentifiers.isEmpty() ? Collections.emptyMap() : entityService.getEntitiesSummariesByIndentifiers(targetIdentifiers) - .stream() - .collect(Collectors.toMap( - EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); + .stream() + .collect(Collectors.toMap( + EntitySummary::identifier, + es -> new EntitySummaryDto(es.identifier(), es.name()))); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index c22ffbb..9117cc4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -3,6 +3,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; +import org.mapstruct.Named; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; @@ -18,12 +19,28 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); - @Mapping(target = "rawValue", ignore = true) + @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueFromString") Property toDomain(PropertyJpaEntity jpa); + @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueToString") PropertyJpaEntity toJpa(Property domain); Relation toDomain(RelationJpaEntity jpa); RelationJpaEntity toJpa(Relation domain); + + /// Converts a domain property value (carried as [Object] to preserve the + /// original JSON type) into its canonical String representation for storage. + @Named("propertyValueToString") + default String propertyValueToString(Object value) { + return value == null ? null : String.valueOf(value); + } + + /// Promotes a persisted String value to the domain [Object] representation. + /// Persistence is the source of truth for textual storage; richer typing + /// (Number/Boolean) is reconstructed by the API output mapper using the template. + @Named("propertyValueFromString") + default Object propertyValueFromString(String value) { + return value; + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index a0a2d15..e5a14d3 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -23,12 +23,15 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +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.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @DisplayName("EntityService Tests") @@ -37,9 +40,15 @@ class EntityServiceTest { @Mock private EntityRepositoryPort entityRepository; + @Mock + private EntityTemplateRepositoryPort entityTemplateRepository; + @Mock private EntityValidationService entityValidationService; + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + @InjectMocks private EntityService entityService; @@ -87,10 +96,10 @@ void shouldReturnEntityByTemplateAndIdentifier() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - var result = entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "catalog-api"); + var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); assertSame(entity, result); - verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityTemplateValidationService).checkTemplateExists("web-service"); verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); } @@ -101,38 +110,43 @@ void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { .thenReturn(Optional.empty()); assertThrows(EntityNotFoundException.class, - () -> entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "missing-entity")); + () -> entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); } @Test @DisplayName("Should create entity when validations pass") void shouldCreateEntityWhenValidationsPass() { var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.save(entity)).thenReturn(entity); var result = entityService.createEntity(entity); assertSame(entity, result); - InOrder inOrder = inOrder(entityValidationService, entityRepository); - inOrder.verify(entityValidationService).checkTemplateExist("web-service"); - inOrder.verify(entityValidationService).checkEntityAlreadyExist(entity); - inOrder.verify(entityValidationService).validateEntity(entity); + InOrder inOrder = inOrder(entityTemplateRepository, entityValidationService, entityRepository); + inOrder.verify(entityTemplateRepository).findByIdentifier("web-service"); + inOrder.verify(entityValidationService).checkUniqueness(entity); + inOrder.verify(entityValidationService).validateEntity(entity, template); inOrder.verify(entityRepository).save(entity); + verifyNoInteractions(entityTemplateValidationService); } @Test @DisplayName("Should not save when entity already exists") void shouldNotSaveWhenEntityAlreadyExists() { var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkEntityAlreadyExist(entity); + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkUniqueness(entity); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - verify(entityValidationService).checkTemplateExist("web-service"); - verify(entityValidationService).checkEntityAlreadyExist(entity); + verify(entityTemplateRepository).findByIdentifier("web-service"); + verify(entityValidationService).checkUniqueness(entity); verifyNoMoreInteractions(entityRepository); } @@ -140,16 +154,14 @@ void shouldNotSaveWhenEntityAlreadyExists() { @DisplayName("Should stop immediately when template does not exist") void shouldStopWhenTemplateDoesNotExistOnCreate() { var entity = entity("missing-template", "catalog-api", "Catalog API"); - var templateNotFound = new EntityTemplateNotFoundException("identifier", "missing-template"); - org.mockito.Mockito.doThrow(templateNotFound) - .when(entityValidationService) - .checkTemplateExist("missing-template"); + when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - verify(entityValidationService).checkTemplateExist("missing-template"); - verifyNoInteractions(entityRepository); + verify(entityTemplateRepository).findByIdentifier("missing-template"); + verifyNoInteractions(entityValidationService); + verifyNoMoreInteractions(entityRepository); } private Entity entity(String templateIdentifier, String identifier, String name) { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 02c7116..8b719e0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -1,12 +1,6 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -26,9 +20,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; @@ -37,7 +30,6 @@ import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; @ExtendWith(MockitoExtension.class) @@ -47,8 +39,6 @@ class EntityValidationServiceTest { @Mock private EntityRepositoryPort entityRepository; - @Mock - private EntityTemplateRepositoryPort entityTemplateRepository; @Mock private PropertyValidationService propertyValidationService; @@ -56,23 +46,6 @@ class EntityValidationServiceTest { @InjectMocks private EntityValidationService entityValidationService; - @Test - @DisplayName("Should pass checkTemplateExist when template exists") - void shouldPassCheckTemplateExistWhenTemplateExists() { - when(entityTemplateRepository.existsByIdentifier("web-service")).thenReturn(true); - - assertDoesNotThrow(() -> entityValidationService.checkTemplateExist("web-service")); - } - - @Test - @DisplayName("Should throw checkTemplateExist when template does not exist") - void shouldThrowCheckTemplateExistWhenTemplateDoesNotExist() { - when(entityTemplateRepository.existsByIdentifier("missing-template")).thenReturn(false); - - assertThrows(EntityTemplateNotFoundException.class, - () -> entityValidationService.checkTemplateExist("missing-template")); - } - @Test @DisplayName("Should throw when entity with same identifier already exists") void shouldThrowWhenEntityAlreadyExists() { @@ -80,7 +53,7 @@ void shouldThrowWhenEntityAlreadyExists() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkEntityAlreadyExist(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkUniqueness(entity)); } @Test @@ -88,22 +61,14 @@ void shouldThrowWhenEntityAlreadyExists() { void shouldNotQueryRepositoryWhenIdentifierIsNull() { var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.checkEntityAlreadyExist(entity)); + assertDoesNotThrow(() -> entityValidationService.checkUniqueness(entity)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } - @Test - @DisplayName("Should throw when template is missing during validateEntity") - void shouldThrowWhenTemplateMissingDuringValidateEntity() { - var entity = entity("missing-template", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); - - assertThrows(EntityTemplateNotFoundException.class, () -> entityValidationService.validateEntity(entity)); - } @Test - @DisplayName("Should aggregate entity, property, relation, required and rule violations") + @DisplayName("Should aggregate property requirements and rule violations") void shouldAggregateAllViolationsDuringValidateEntity() { var portDefinition = new PropertyDefinition( UUID.randomUUID(), @@ -129,35 +94,24 @@ void shouldAggregateAllViolationsDuringValidateEntity() { List.of(requiredDefinition, portDefinition), List.of()); - var mockedRelation = org.mockito.Mockito.mock(Relation.class); - when(mockedRelation.name()).thenReturn(" "); - when(mockedRelation.targetEntityIdentifiers()).thenReturn(null); - var entity = entity( "web-service", - " ", - " ", + " ", // Blank identifier (handled by Jakarta, not this service) + " ", // Blank name (handled by Jakarta, not this service) List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), - List.of(mockedRelation)); + List.of()); // No relations - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(portDefinition, "80", null)) + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity, template)); - assertEquals(8, exception.getViolations().size()); - assertEquals(ENTITY_NAME_MANDATORY, exception.getViolations().get(0)); - assertEquals(ENTITY_IDENTIFIER_MANDATORY, exception.getViolations().get(1)); - assertEquals("Property[0]: " + PROPERTY_NAME_MANDATORY, exception.getViolations().get(2)); - assertEquals("Property[0]: " + PROPERTY_VALUE_MANDATORY, exception.getViolations().get(3)); - assertEquals("Relation[0]: " + RELATION_NAME_MANDATORY_SIMPLE, exception.getViolations().get(4)); - assertEquals("Relation[0]: " + RELATION_TARGET_IDENTIFIERS_NOT_NULL, exception.getViolations().get(5)); - assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); - assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); + // Expecting exactly 2 errors: the missing required property, and the invalid port value. + assertEquals(2, exception.getViolations().size()); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(0)); + assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(1)); - verify(propertyValidationService).validatePropertyValue(portDefinition, "80", null); + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); } @Test @@ -186,13 +140,11 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), null); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0", null)).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0", null); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); } @Test @@ -216,14 +168,42 @@ void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.empty()); - - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); verifyNoInteractions(propertyValidationService); } + @Test + @DisplayName("Should validate property of type STRING with a numeric string value '1234'") + void shouldValidateStringPropertyWithNumericStringValue() { + var stringDefinition = new PropertyDefinition( + UUID.randomUUID(), + "versionCode", + "Version Code as String", + PropertyType.STRING, + false, + null + ); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(stringDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), + null); + when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); + } + private Entity entity( String templateIdentifier, String identifier, diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 2ffdef8..14ed3f6 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -30,7 +30,7 @@ class StringValidationTests { void shouldReportTypeMismatchWhenStringValueIsNull() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null, null); + var violations = service.validatePropertyValue(definition, null); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); } @@ -40,7 +40,7 @@ void shouldReportTypeMismatchWhenStringValueIsNull() { void shouldReturnNoViolationsWhenStringHasNoRules() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello", "hello"); + var violations = service.validatePropertyValue(definition, "hello"); assertEquals(List.of(), violations); } @@ -51,7 +51,7 @@ void shouldReturnNoViolationsWhenStringPassesAllRules() { var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev", "dev"); + var violations = service.validatePropertyValue(definition, "dev"); assertEquals(List.of(), violations); } @@ -62,7 +62,7 @@ void shouldReportMinLengthViolation() { var rules = new PropertyRules(null, null, null, null, null, 5, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab", "ab"); + var violations = service.validatePropertyValue(definition, "ab"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -73,7 +73,7 @@ void shouldReportMaxLengthViolation() { var rules = new PropertyRules(null, null, null, null, 5, null, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value", "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -84,7 +84,7 @@ void shouldReportRegexViolation() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc", "abc"); + var violations = service.validatePropertyValue(definition, "abc"); assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); } @@ -95,7 +95,7 @@ void shouldAcceptValueMatchingRegex() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345", "12345"); + var violations = service.validatePropertyValue(definition, "12345"); assertEquals(List.of(), violations); } @@ -106,7 +106,7 @@ void shouldReportEnumViolation() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN", "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN"); assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); } @@ -117,7 +117,7 @@ void shouldAcceptEnumValueCaseInsensitive() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active", "active"); + var violations = service.validatePropertyValue(definition, "active"); assertEquals(List.of(), violations); } @@ -128,7 +128,7 @@ void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything", "anything"); + var violations = service.validatePropertyValue(definition, "anything"); assertEquals(List.of(), violations); } @@ -139,7 +139,7 @@ void shouldReportFormatViolationForInvalidEmail() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email", "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); } @@ -150,7 +150,7 @@ void shouldAcceptValidEmailFormat() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com", "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com"); assertEquals(List.of(), violations); } @@ -161,7 +161,7 @@ void shouldReportFormatViolationForInvalidUrl() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url", "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); } @@ -172,7 +172,7 @@ void shouldAcceptValidUrlFormat() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo", "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); assertEquals(List.of(), violations); } @@ -183,7 +183,7 @@ void shouldReportMultipleStringViolations() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA", "AA"); + var violations = service.validatePropertyValue(definition, "AA"); assertEquals(4, violations.size()); } @@ -195,33 +195,12 @@ void shouldUseCachedPatternForRepeatedRegex() { var definition = propertyDefinition("code", PropertyType.STRING, rules); // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc", "abc"); - var violations2 = service.validatePropertyValue(definition, "def", "def"); + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); assertEquals(List.of(), violations1); assertEquals(List.of(), violations2); } - - @Test - @DisplayName("Should report type mismatch when a number is sent for a STRING property") - void shouldReportTypeMismatchWhenNumberSentForString() { - var rules = new PropertyRules(null, null, null, null, null, 5, null, null); - var definition = propertyDefinition("label", PropertyType.STRING, rules); - - var violations = service.validatePropertyValue(definition, "12", 12); - - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } - - @Test - @DisplayName("Should report type mismatch when a boolean is sent for a STRING property") - void shouldReportTypeMismatchWhenBooleanSentForString() { - var definition = propertyDefinition("label", PropertyType.STRING, null); - - var violations = service.validatePropertyValue(definition, "true", true); - - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } } @Nested @@ -233,7 +212,7 @@ class NumberValidationTests { void shouldReportTypeMismatchWhenNumberValueIsInvalid() { var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number", "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); } @@ -243,7 +222,7 @@ void shouldReportTypeMismatchWhenNumberValueIsInvalid() { void shouldReturnNoViolationsWhenNumberHasNoRules() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42", 42); + var violations = service.validatePropertyValue(definition, "42"); assertEquals(List.of(), violations); } @@ -254,7 +233,7 @@ void shouldReturnNoViolationsWhenNumberIsWithinBounds() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50", 50); + var violations = service.validatePropertyValue(definition, "50"); assertEquals(List.of(), violations); } @@ -265,7 +244,7 @@ void shouldReportMinValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3", 3); + var violations = service.validatePropertyValue(definition, "3"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); } @@ -276,7 +255,7 @@ void shouldReportMaxValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15", 15); + var violations = service.validatePropertyValue(definition, "15"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); } @@ -288,7 +267,7 @@ void shouldReportBothMinAndMaxViolations() { var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7", 7); + var violations = service.validatePropertyValue(definition, "7"); // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation assertEquals(2, violations.size()); @@ -300,7 +279,7 @@ void shouldAcceptDecimalNumberValues() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5", 99.5); + var violations = service.validatePropertyValue(definition, "99.5"); assertEquals(List.of(), violations); } @@ -310,7 +289,7 @@ void shouldAcceptDecimalNumberValues() { void shouldReportTypeMismatchWhenBooleanSentForNumber() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true", true); + var violations = service.validatePropertyValue(definition, "true"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); } @@ -324,9 +303,8 @@ class BooleanValidationTests { @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) void shouldAcceptValidBooleanValues(String value) { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - Object originalValue = "true".equalsIgnoreCase(value) ? Boolean.TRUE : Boolean.FALSE; - var violations = service.validatePropertyValue(definition, value, originalValue); + var violations = service.validatePropertyValue(definition, value); assertEquals(List.of(), violations); } @@ -336,7 +314,7 @@ void shouldAcceptValidBooleanValues(String value) { void shouldReportTypeMismatchForInvalidBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes", "yes"); + var violations = service.validatePropertyValue(definition, "yes"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } @@ -346,7 +324,7 @@ void shouldReportTypeMismatchForInvalidBoolean() { void shouldReportTypeMismatchWhenNumberSentForBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "42", 42); + var violations = service.validatePropertyValue(definition, "42"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } 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 3433491..f7be973 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,12 +25,12 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -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.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +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.exception.entity.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; From 6f466d606d9f820abdeea80ba4fcf6c95d2b2089 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Tue, 5 May 2026 16:32:06 +0200 Subject: [PATCH 07/51] feat(core): fix end of file --- .../property/PropertyDefinitionRulesConflictException.java | 2 +- .../entity_template/EntityTemplateValidationService.java | 2 +- .../entity_template/PropertyDefinitionValidationService.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index f68a840..3ce489e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -22,4 +22,4 @@ public PropertyDefinitionRulesConflictException(String propertyName, PropertyTyp super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); } -} \ No newline at end of file +} 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 34aab2f..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 @@ -115,4 +115,4 @@ public void validatePropertyRules(EntityTemplate entityTemplate) { } } -} \ No newline at end of file +} 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 cebf279..a35f88d 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 @@ -294,4 +294,4 @@ private void validateRegexPattern(String propertyName, String regexPattern) { } } -} \ No newline at end of file +} From 6db02d48689ffb48d39ab2153a0c5aa65f9487a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 6 May 2026 11:34:39 +0200 Subject: [PATCH 08/51] feat(core): call entity template service in entity service --- .../domain/port/EntityRepositoryPort.java | 2 +- .../domain/service/entity/EntityService.java | 18 +++---- .../entity/EntityValidationService.java | 5 +- .../persistence/PostgresEntityAdapter.java | 7 +-- .../service/entity/EntityServiceTest.java | 52 +++++++++++-------- .../entity/EntityValidationServiceTest.java | 4 +- 6 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 0b2c4b8..7f84d5a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -32,7 +32,7 @@ public interface EntityRepositoryPort { Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); - Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); List findByIdentifierIn(List identifiers); diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 4fa2da2..3350bdd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,7 +2,6 @@ import java.util.List; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -10,16 +9,18 @@ import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +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.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + import jakarta.transaction.Transactional; import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. /// @@ -37,9 +38,9 @@ @RequiredArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; private final EntityValidationService entityValidationService; private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; /// Retrieves entities filtered by template with existence validation. /// @@ -52,9 +53,9 @@ public class EntityService { /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { + entityTemplateValidationService.checkTemplateExists(templateIdentifier); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable) - .orElseThrow(() -> new EntityTemplateNotFoundException(templateIdentifier)); } /// Provides lightweight entity summaries for efficient bulk operations. @@ -100,9 +101,8 @@ public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifi /// @throws EntityValidationException when entity, property, or relation data is invalid @Transactional public Entity createEntity(@Valid Entity entity) { - EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); - entityValidationService.checkUniqueness(entity); + EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); + entityValidationService.validateUniqueness(entity); entityValidationService.validateEntity(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 4902016..b926db7 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -7,7 +7,6 @@ import java.util.Optional; import java.util.stream.Collectors; -import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; @@ -19,6 +18,8 @@ import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; +import lombok.AllArgsConstructor; + /// Domain validator for [Entity] aggregates. /// /// Validation pipeline: @@ -86,7 +87,7 @@ private void validateAgainstTemplate(EntityTemplate template, /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void checkUniqueness(final Entity entity) { + void validateUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 0319c66..2325b0f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -4,7 +4,6 @@ import java.util.Optional; import java.util.UUID; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -15,6 +14,8 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { @@ -45,9 +46,9 @@ public Optional findByTemplateIdentifierAndName(String templateIdentifie } @Override - public Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return Optional.of(pageableEntity.map(mapper::toDomain)); + return pageableEntity.map(mapper::toDomain); } @Override diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index e5a14d3..65b9932 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -31,6 +32,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @@ -49,6 +51,9 @@ class EntityServiceTest { @Mock private EntityTemplateValidationService entityTemplateValidationService; + @Mock + private EntityTemplateService entityTemplateService; + @InjectMocks private EntityService entityService; @@ -59,7 +64,7 @@ void shouldReturnEntitiesByTemplateIdentifier() { var entity = entity("template-a", "entity-a", "Entity A"); var page = new PageImpl<>(List.of(entity)); - when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(Optional.of(page)); + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(page); var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); @@ -67,15 +72,17 @@ void shouldReturnEntitiesByTemplateIdentifier() { verify(entityRepository).findByTemplateIdentifier("template-a", pageable); } - @Test - @DisplayName("Should throw when template has no entities page") - void shouldThrowWhenTemplatePageNotFound() { - var pageable = Pageable.ofSize(10); - when(entityRepository.findByTemplateIdentifier("missing-template", pageable)).thenReturn(Optional.empty()); + // @Test + // @DisplayName("Should throw when template has no entities page") + // void shouldThrowWhenTemplatePageNotFound() { + // var pageable = Pageable.ofSize(10); + // when(entityRepository.findByTemplateIdentifier("missing-template", + // pageable)).thenReturn(Optional.empty()); - assertThrows(EntityTemplateNotFoundException.class, - () -> entityService.getEntitiesByTemplateIdentifier(pageable, "missing-template")); - } + // assertThrows(EntityTemplateNotFoundException.class, + // () -> entityService.getEntitiesByTemplateIdentifier(pageable, + // "missing-template")); + // } @Test @DisplayName("Should return entity summaries by identifiers") @@ -117,17 +124,18 @@ void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { @DisplayName("Should create entity when validations pass") void shouldCreateEntityWhenValidationsPass() { var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); when(entityRepository.save(entity)).thenReturn(entity); var result = entityService.createEntity(entity); assertSame(entity, result); - InOrder inOrder = inOrder(entityTemplateRepository, entityValidationService, entityRepository); - inOrder.verify(entityTemplateRepository).findByIdentifier("web-service"); - inOrder.verify(entityValidationService).checkUniqueness(entity); + InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityValidationService).validateUniqueness(entity); inOrder.verify(entityValidationService).validateEntity(entity, template); inOrder.verify(entityRepository).save(entity); verifyNoInteractions(entityTemplateValidationService); @@ -137,16 +145,17 @@ void shouldCreateEntityWhenValidationsPass() { @DisplayName("Should not save when entity already exists") void shouldNotSaveWhenEntityAlreadyExists() { var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkUniqueness(entity); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + doThrow(alreadyExists).when(entityValidationService).validateUniqueness(entity); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - verify(entityTemplateRepository).findByIdentifier("web-service"); - verify(entityValidationService).checkUniqueness(entity); + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityValidationService).validateUniqueness(entity); verifyNoMoreInteractions(entityRepository); } @@ -155,11 +164,12 @@ void shouldNotSaveWhenEntityAlreadyExists() { void shouldStopWhenTemplateDoesNotExistOnCreate() { var entity = entity("missing-template", "catalog-api", "Catalog API"); - when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); + when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) + .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - verify(entityTemplateRepository).findByIdentifier("missing-template"); + verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); verifyNoInteractions(entityValidationService); verifyNoMoreInteractions(entityRepository); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 8b719e0..6b00a96 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -53,7 +53,7 @@ void shouldThrowWhenEntityAlreadyExists() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkUniqueness(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateUniqueness(entity)); } @Test @@ -61,7 +61,7 @@ void shouldThrowWhenEntityAlreadyExists() { void shouldNotQueryRepositoryWhenIdentifierIsNull() { var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.checkUniqueness(entity)); + assertDoesNotThrow(() -> entityValidationService.validateUniqueness(entity)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } From a1212e0b2ce74b771474944ef2801a450e8a60d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Mon, 11 May 2026 16:13:36 +0200 Subject: [PATCH 09/51] feat(core): update the validate template methods calls --- .../idp_core/domain/service/entity/EntityService.java | 6 +++--- .../idp_core/domain/service/entity/EntityServiceTest.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 3350bdd..7da8ca2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -15,7 +15,7 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import jakarta.transaction.Transactional; @@ -53,7 +53,7 @@ public class EntityService { /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - entityTemplateValidationService.checkTemplateExists(templateIdentifier); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); } @@ -81,7 +81,7 @@ public List getEntitiesSummariesByIndentifiers(List ident /// @throws EntityNotFoundException when entity doesn't exist @Transactional public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { - entityTemplateValidationService.checkTemplateExists(templateIdentifier); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 65b9932..6699c56 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -32,7 +32,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @@ -106,7 +106,7 @@ void shouldReturnEntityByTemplateAndIdentifier() { var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); assertSame(entity, result); - verify(entityTemplateValidationService).checkTemplateExists("web-service"); + verify(entityTemplateValidationService).validateTemplateExists("web-service"); verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); } From 30f86d601c69a17844519c029e013c40caf747d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 13 May 2026 18:01:14 +0200 Subject: [PATCH 10/51] feat(core): update the validate template methods calls --- ...sql => V3_4__change_entity_identifier_unique_to_composite.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V3_3__change_entity_identifier_unique_to_composite.sql => V3_4__change_entity_identifier_unique_to_composite.sql} (100%) diff --git a/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql b/src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql similarity index 100% rename from src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql rename to src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql From 5f12fec7209dd3b7caa752c119e4c9816d1b5882 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 18 May 2026 11:45:49 +0200 Subject: [PATCH 11/51] feat(core): fix review --- .../domain/model/entity/Property.java | 2 +- .../domain/service/entity/EntityService.java | 3 +- .../entity/EntityValidationService.java | 22 ++++++------- .../adapters/api/dto/in/EntityDtoIn.java | 2 +- .../api/mapper/entity/EntityDtoInMapper.java | 2 +- .../mapper/EntityPersistenceMapper.java | 17 ---------- .../service/entity/EntityServiceTest.java | 22 ++----------- .../entity/EntityValidationServiceTest.java | 32 ++++++++++++++----- 8 files changed, 41 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 7850124..5e7281e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -29,6 +29,6 @@ public record Property( @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - Object value + String value ) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 7da8ca2..72e40ad 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -102,8 +102,7 @@ public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifi @Transactional public Entity createEntity(@Valid Entity entity) { EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); - entityValidationService.validateUniqueness(entity); - entityValidationService.validateEntity(entity, template); + entityValidationService.validateForCreation(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index b926db7..8143e6c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -44,22 +44,17 @@ public class EntityValidationService { /// @param template the already-resolved template the entity must conform to /// @throws EntityValidationException when one or more validation rules are violated /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - void validateEntity(Entity entity, EntityTemplate template) { - Violations violations = new Violations(); - validateAgainstTemplate(template, entity.properties(), violations); - - if (!violations.isEmpty()) { - throw new EntityValidationException(violations.asList()); - } + void validateForCreation(Entity entity, EntityTemplate template) { + validateUniqueness(entity); + validateAgainstTemplate(template, entity.properties()); } /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation /// @param properties the list of properties from the entity to validate - /// @param violations the accumulator for validation v iolation messages private void validateAgainstTemplate(EntityTemplate template, - List properties, - Violations violations) { + List properties) { + Violations violations = new Violations(); List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() .filter(p -> p.name() != null) @@ -69,7 +64,7 @@ private void validateAgainstTemplate(EntityTemplate template, Property property = propertiesByName.get(definition.name()); boolean missing = property == null || property.value() == null - || (property.value() instanceof String s && s.isBlank()); + || (property.value().isBlank()); if (missing) { if (definition.required()) { @@ -82,12 +77,15 @@ private void validateAgainstTemplate(EntityTemplate template, .validatePropertyValue(definition, property.value()) .forEach(violations::add); } + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); + } } /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void validateUniqueness(final Entity entity) { + private void validateUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 0531655..7587711 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -50,7 +50,7 @@ public class EntityDtoIn { private String identifier; @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") - private Map properties; + private Map properties; @Valid @Schema(description = FIELD_ENTITY_RELATIONS) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 45dc45a..5548ec0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -40,7 +40,7 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> new Property( + .map((Map.Entry entry) -> new Property( null, entry.getKey(), entry.getValue() diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 9117cc4..cc7edac 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -19,28 +19,11 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); - @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueFromString") Property toDomain(PropertyJpaEntity jpa); - @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueToString") PropertyJpaEntity toJpa(Property domain); Relation toDomain(RelationJpaEntity jpa); RelationJpaEntity toJpa(Relation domain); - - /// Converts a domain property value (carried as [Object] to preserve the - /// original JSON type) into its canonical String representation for storage. - @Named("propertyValueToString") - default String propertyValueToString(Object value) { - return value == null ? null : String.valueOf(value); - } - - /// Promotes a persisted String value to the domain [Object] representation. - /// Persistence is the source of truth for textual storage; richer typing - /// (Number/Boolean) is reconstructed by the API output mapper using the template. - @Named("propertyValueFromString") - default Object propertyValueFromString(String value) { - return value; - } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 6699c56..22b4cb9 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -31,7 +31,6 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -42,8 +41,6 @@ class EntityServiceTest { @Mock private EntityRepositoryPort entityRepository; - @Mock - private EntityTemplateRepositoryPort entityTemplateRepository; @Mock private EntityValidationService entityValidationService; @@ -72,18 +69,6 @@ void shouldReturnEntitiesByTemplateIdentifier() { verify(entityRepository).findByTemplateIdentifier("template-a", pageable); } - // @Test - // @DisplayName("Should throw when template has no entities page") - // void shouldThrowWhenTemplatePageNotFound() { - // var pageable = Pageable.ofSize(10); - // when(entityRepository.findByTemplateIdentifier("missing-template", - // pageable)).thenReturn(Optional.empty()); - - // assertThrows(EntityTemplateNotFoundException.class, - // () -> entityService.getEntitiesByTemplateIdentifier(pageable, - // "missing-template")); - // } - @Test @DisplayName("Should return entity summaries by identifiers") void shouldReturnEntitySummariesByIdentifiers() { @@ -135,8 +120,7 @@ void shouldCreateEntityWhenValidationsPass() { InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityValidationService).validateUniqueness(entity); - inOrder.verify(entityValidationService).validateEntity(entity, template); + inOrder.verify(entityValidationService).validateForCreation(entity, template); inOrder.verify(entityRepository).save(entity); verifyNoInteractions(entityTemplateValidationService); } @@ -150,12 +134,12 @@ void shouldNotSaveWhenEntityAlreadyExists() { var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - doThrow(alreadyExists).when(entityValidationService).validateUniqueness(entity); + doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityValidationService).validateUniqueness(entity); + verify(entityValidationService).validateForCreation(entity, template); verifyNoMoreInteractions(entityRepository); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 6b00a96..6411bd6 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -49,19 +50,34 @@ class EntityValidationServiceTest { @Test @DisplayName("Should throw when entity with same identifier already exists") void shouldThrowWhenEntityAlreadyExists() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateUniqueness(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateForCreation(entity, template)); } @Test @DisplayName("Should not query repository when identifier is null") void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.validateUniqueness(entity)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } @@ -69,7 +85,7 @@ void shouldNotQueryRepositoryWhenIdentifierIsNull() { @Test @DisplayName("Should aggregate property requirements and rule violations") - void shouldAggregateAllViolationsDuringValidateEntity() { + void shouldAggregateAllViolationsDuringValidateForCreation() { var portDefinition = new PropertyDefinition( UUID.randomUUID(), "port", @@ -104,7 +120,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { when(propertyValidationService.validatePropertyValue(portDefinition, "80")) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity, template)); + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateForCreation(entity, template)); // Expecting exactly 2 errors: the missing required property, and the invalid port value. assertEquals(2, exception.getViolations().size()); @@ -116,7 +132,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { @Test @DisplayName("Should validate entity successfully when no violations") - void shouldValidateEntitySuccessfullyWhenNoViolations() { + void shouldValidateForCreationSuccessfullyWhenNoViolations() { var versionDefinition = new PropertyDefinition( UUID.randomUUID(), "version", @@ -143,7 +159,7 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); } @@ -168,7 +184,7 @@ void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verifyNoInteractions(propertyValidationService); } @@ -200,7 +216,7 @@ void shouldValidateStringPropertyWithNumericStringValue() { null); when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); } From ecc92b2202fe6a2b6d2c0b1f4e4742b30434309e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 19 May 2026 11:41:01 +0200 Subject: [PATCH 12/51] feat(core): add a entity graph service and endpoint --- .../idp_core/domain/model/entity/Entity.java | 2 - .../model/entity/EntityCompositeKey.java | 36 +++++ .../model/entity_graph/EntityGraphNode.java | 26 ++++ .../entity_graph/EntityGraphRelation.java | 21 +++ .../port/EntityGraphRepositoryPort.java | 37 +++++ .../entity_graph/EntityGraphService.java | 144 ++++++++++++++++++ .../api/configuration/SwaggerDescription.java | 13 ++ .../api/controller/EntityGraphController.java | 78 ++++++++++ .../dto/out/entity/EntityGraphNodeDtoOut.java | 29 ++++ .../out/entity/EntityGraphRelationDtoOut.java | 26 ++++ .../entity/RelationAsTargetSummaryDtoOut.java | 13 ++ .../entity/EntityGraphDtoOutMapper.java | 50 ++++++ .../PostgresEntityGraphAdapter.java | 69 +++++++++ .../repository/JpaEntityRepository.java | 60 ++++++++ .../repository/JpaRelationRepository.java | 4 +- 15 files changed, 605 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2b77241..ab10abe 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,8 +7,6 @@ import java.util.List; import java.util.UUID; -import org.springframework.validation.annotation.Validated; - import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java new file mode 100644 index 0000000..30a0f99 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java @@ -0,0 +1,36 @@ +package com.decathlon.idp_core.domain.model.entity; + +import java.util.Objects; + +/** + * Composite key for uniquely identifying an entity across templates. + * Since the same identifier can exist in different templates, we need both fields. + */ +public record EntityCompositeKey(String templateIdentifier, String identifier) { + public static EntityCompositeKey fromString(String compositeKey) { + String[] parts = compositeKey.split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + return new EntityCompositeKey(parts[0], parts[1]); + } + + @Override + public String toString() { + return templateIdentifier + ":" + identifier; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + EntityCompositeKey that = (EntityCompositeKey) o; + return Objects.equals(templateIdentifier, that.templateIdentifier) && + Objects.equals(identifier, that.identifier); + } + + @Override + public int hashCode() { + return Objects.hash(templateIdentifier, identifier); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java new file mode 100644 index 0000000..9348a38 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +import java.util.List; + +/// A node in the entity relationship graph, containing summary information +/// and its resolved relations (recursively up to a configurable depth). +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs +/// - Understanding relationship chains between entities +/// - Providing a hierarchical view of entity connections +/// +/// @param summary the lightweight entity identification data +/// @param relations the resolved outbound relations with their target graph nodes +/// @param relationsAsTarget incoming relations where this entity is the target +public record EntityGraphNode( + String identifier, + String name, + List relations, + List relationsAsTarget +) { + public EntityGraphNode { + relations = relations != null ? List.copyOf(relations) : List.of(); + relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java new file mode 100644 index 0000000..d770639 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +import java.util.List; + +/// Represents a single named relation in the entity graph with its resolved target nodes. +/// +/// **Business purpose:** +/// - Groups related entities under their relation name +/// - Enables graph traversal by relation type +/// +/// @param name the relation name as defined in the entity template +/// @param targetTemplateIdentifier the template identifier of the target entities +/// @param targets the resolved target entity graph nodes (recursively populated up to depth) +public record EntityGraphRelation( + String name, + List targets +) { + public EntityGraphRelation { + targets = targets != null ? List.copyOf(targets) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java new file mode 100644 index 0000000..e1ae73f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -0,0 +1,37 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.Map; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; + +/// Driven port defining the contract for entity relationship graph retrieval. +/// +/// Separated from [EntityRepositoryPort] to follow the Interface Segregation Principle: +/// graph traversal is a distinct read concern backed by recursive CTE queries, +/// with no overlap with standard CRUD operations. +/// +/// **Contract expectations for implementations:** +/// - Must traverse both outbound and inbound relations up to the requested depth +/// - Must return the root entity itself as part of the result map +/// - Must return an empty map when the root entity does not exist +/// - Depth must be clamped server-side; implementations may ignore values outside a valid range +/// +/// **Transaction behavior:** Implementations should use a read-only transaction +/// as this port performs no write operations. +public interface EntityGraphRepositoryPort { + + /// Fetches all entities in the relationship graph rooted at the given composite key. + /// + /// Uses a recursive CTE to traverse both outbound and inbound relations up to the + /// specified depth, then batch-loads all entities in a minimal number of queries. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity within its template + /// @param depth the maximum traversal depth (1-10) + /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found + Map findEntityGraph( + String templateIdentifier, + String entityIdentifier, + int depth); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java new file mode 100644 index 0000000..46ca3f8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -0,0 +1,144 @@ +package com.decathlon.idp_core.domain.service.entity_graph; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; + +import lombok.RequiredArgsConstructor; + +/// Domain service for building entity relationship graphs. +/// +/// Resolves an entity's outbound and inbound relations recursively up to a configurable depth, +/// returning a tree of [EntityGraphNode] records containing summary information +/// for each connected entity. +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs in the catalog UI +/// - Understanding relationship chains (e.g., service → database → infrastructure) +/// - Providing hierarchical views for impact analysis and change propagation +/// +/// **Design decisions:** +/// - Uses depth-limited traversal to prevent unbounded recursion +/// - Optimized with recursive CTE and batch loading to minimize database queries +/// - Does not detect cycles — relies on depth limit to terminate +@Service +@RequiredArgsConstructor +public class EntityGraphService { + + private static final int MAX_DEPTH = 10; + + private final EntityRepositoryPort entityRepositoryPort; + private final EntityGraphRepositoryPort entityGraphRepositoryPort; + + /// Builds the relationship graph for an entity starting from its composite key. + /// + /// **Optimization:** Uses a recursive CTE to fetch all entities in the graph in 2 queries + /// (1 for composite key pairs, 1 for batch loading), regardless of depth. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @return the root graph node with resolved relations + /// @throws EntityNotFoundException when no entity matches the given identifiers + @Transactional(readOnly = true) + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth) { + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + + // Verify root entity exists before fetching the graph + Entity rootEntity = entityRepositoryPort + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + + // Optimized batch fetch: load all entities in the graph keyed by composite key + Map entityMap = entityGraphRepositoryPort + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth); + + EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); + + // Build the graph from pre-loaded entities (no more database calls) + return buildGraphNode(rootKey, entityMap, effectiveDepth); + } + + /// Builds a graph node from a pre-loaded entity map (no database calls). + /// Recursively resolves both outbound and inbound relations from the cached entities. + private EntityGraphNode buildGraphNode(EntityCompositeKey key, + Map entityMap, + int remainingDepth) { + Entity entity = entityMap.get(key); + if (entity == null) { + return new EntityGraphNode(key.identifier(), key.identifier(), List.of(), List.of()); + } + + if (remainingDepth <= 0) { + return new EntityGraphNode(entity.identifier(), entity.name(), List.of(), List.of()); + } + + // Resolve outbound relations from pre-loaded entities + List outboundRelations = entity.relations().stream() + .map(relation -> new EntityGraphRelation( + relation.name(), + relation.targetEntityIdentifiers().stream() + .map(targetId -> { + // Relations only store identifier; look up by identifier across all entries + EntityCompositeKey targetKey = findKeyByIdentifier(targetId, entityMap); + return buildGraphNode(targetKey, entityMap, remainingDepth - 1); + }) + .toList() + )) + .toList(); + + // Resolve inbound relations from pre-loaded entities + List inboundRelations = buildRelationsAsTargetFromMap( + entity.identifier(), entityMap, remainingDepth - 1); + + return new EntityGraphNode(entity.identifier(), entity.name(), outboundRelations, inboundRelations); + } + + /// Looks up a composite key from the map by identifier alone. + /// Falls back to a synthetic key if no match is found (entity not in graph). + private EntityCompositeKey findKeyByIdentifier(String identifier, Map entityMap) { + return entityMap.keySet().stream() + .filter(k -> k.identifier().equals(identifier)) + .findFirst() + .orElse(new EntityCompositeKey("", identifier)); + } + + /// Builds incoming relations (where this entity is the target) from the pre-loaded entity map. + /// Scans all entities to find relations pointing to this entity. + private List buildRelationsAsTargetFromMap(String targetIdentifier, + Map entityMap, + int remainingDepth) { + Map> sourcesByRelationName = new java.util.HashMap<>(); + + for (Map.Entry entry : entityMap.entrySet()) { + Entity sourceEntity = entry.getValue(); + for (Relation relation : sourceEntity.relations()) { + if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { + sourcesByRelationName + .computeIfAbsent(relation.name(), k -> new java.util.ArrayList<>()) + .add(entry.getKey()); + } + } + } + + return sourcesByRelationName.entrySet().stream() + .map(e -> new EntityGraphRelation( + e.getKey(), + e.getValue().stream() + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth)) + .toList() + )) + .toList(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index d645725..b7f85ec 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -148,4 +148,17 @@ public class SwaggerDescription { public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + + // --- Entity Graph descriptions --- + public static final String ENDPOINT_GET_ENTITY_GRAPH_SUMMARY = "Get entity relationship graph"; + public static final String ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION = "Retrieves the entity relationship graph starting from the specified entity, resolving outbound relations recursively up to the requested depth."; + public static final String RESPONSE_ENTITY_GRAPH_SUCCESS = "Entity graph successfully retrieved"; + public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; + public static final String ENTITY_GRAPH_NODE_DESCRIPTION = "A node in the entity relationship graph"; + public static final String ENTITY_GRAPH_SUMMARY_DESCRIPTION = "Summary information identifying the entity"; + public static final String ENTITY_GRAPH_RELATIONS_DESCRIPTION = "Resolved outbound relations with target entity nodes"; + public static final String ENTITY_GRAPH_RELATION_NAME_DESCRIPTION = "The relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION = "The template identifier of target entities"; + public static final String ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION = "Resolved target entity graph nodes"; + public static final String ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION = "Incoming relations where this entity is the target"; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java new file mode 100644 index 0000000..727b963 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -0,0 +1,78 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static org.springframework.http.HttpStatus.OK; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphDtoOutMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; + +/// REST controller for entity relationship graph operations. +/// +/// Provides endpoints to retrieve hierarchical relationship graphs starting from +/// a specified entity, enabling visualization of entity dependencies and connections. +@RestController +@RequestMapping("/api/v1/entities") +@RequiredArgsConstructor +@Tag(name = "Entity Graph", description = "Entity relationship graph operations") +public class EntityGraphController { + + private final EntityGraphService entityGraphService; + + /// Retrieves the entity relationship graph starting from the specified entity. + /// + /// Resolves outbound relations recursively up to the requested depth, + /// returning a tree structure with entity summary information at each node. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @return the root graph node with resolved relations + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") + @ResponseStatus(OK) + @Operation( + summary = ENDPOINT_GET_ENTITY_GRAPH_SUMMARY, + description = ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION, + responses = { + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_SUCCESS, + content = @Content(schema = @Schema(implementation = EntityGraphNodeDtoOut.class))), + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + } + ) + public EntityGraphNodeDtoOut getEntityGraph( + @PathVariable @NotBlank String templateIdentifier, + @PathVariable @NotBlank String entityIdentifier, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) + @RequestParam(defaultValue = "1") int depth) { + + EntityGraphNode graphNode = entityGraphService.getEntityGraph( + templateIdentifier, entityIdentifier, depth); + + return EntityGraphDtoOutMapper.toDto(graphNode); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java new file mode 100644 index 0000000..5d159bf --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_DESCRIPTION; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a node in the entity relationship graph. +/// +/// Contains summary information about the entity and its resolved outbound relations +/// grouped by relation name, and incoming relations where this entity is the target. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphNodeDtoOut( + + String identifier, + String name, + + @Schema(description = ENTITY_GRAPH_RELATIONS_DESCRIPTION) + Map> relations, + + @Schema(description = ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION) + Map> relationsAsTarget +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java new file mode 100644 index 0000000..55f0794 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; + +/// Output DTO representing a single named relation in the entity graph. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphRelationDtoOut( + + @Schema(description = ENTITY_GRAPH_RELATION_NAME_DESCRIPTION) + String name, + + @Schema(description = ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION) + String targetTemplateIdentifier, + + @Schema(description = ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION) + List targets +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java new file mode 100644 index 0000000..1e734bd --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Output DTO representing an incoming relationship where the entity is the target. +@JsonNaming(SnakeCaseStrategy.class) +public record RelationAsTargetSummaryDtoOut( + String targetEntityIdentifier, + String relationName, + String sourceEntityIdentifier, + String sourceEntityName +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java new file mode 100644 index 0000000..8b3e6be --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java @@ -0,0 +1,50 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; + +/// Mapper for converting domain [EntityGraphNode] to its API output DTO +/// representation. +/// +/// Uses Record Patterns for recursive tree mapping since MapStruct does not +/// handle recursive structures cleanly. +public final class EntityGraphDtoOutMapper { + + private EntityGraphDtoOutMapper() { + // Utility class + } + + /// Maps a domain graph node to its DTO representation. + /// + /// @param node the domain graph node + /// @return the output DTO + public static EntityGraphNodeDtoOut toDto(EntityGraphNode node) { + if (node == null) { + return null; + } + return new EntityGraphNodeDtoOut( + node.identifier(), node.name(), + mapRelations(node.relations()), + mapRelations(node.relationsAsTarget())); + } + + private static Map> mapRelations(List relations) { + if (relations == null || relations.isEmpty()) { + return Map.of(); + } + return relations.stream() + .collect(Collectors.toMap( + EntityGraphRelation::name, + relation -> relation.targets().stream() + .map(EntityGraphDtoOutMapper::toDto) + .toList(), + (existing, replacement) -> existing, + LinkedHashMap::new)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java new file mode 100644 index 0000000..8527027 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -0,0 +1,69 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter dedicated to entity relationship graph traversal. +/// +/// Separated from [PostgresEntityAdapter] because graph queries use a distinct +/// recursive CTE strategy that has no overlap with standard CRUD operations, +/// following the Interface Segregation Principle. +/// +/// **Query strategy:** +/// 1. One recursive CTE query to collect all (identifier, template_identifier) pairs in the graph. +/// 2. One batch query to load entities with their relations (avoids N+1). +/// 3. One batch query to load properties separately (avoids MultipleBagFetchException). +@Component +@RequiredArgsConstructor +public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { + + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; + + @Override + public Map findEntityGraph( + String templateIdentifier, + String entityIdentifier, + int depth) { + // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE + List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( + templateIdentifier, entityIdentifier, depth); + + if (graphPairs.isEmpty()) { + return Map.of(); + } + + // Step 2: extract unique identifiers for batch loading + List identifiers = graphPairs.stream() + .map(pair -> (String) pair[0]) + .distinct() + .toList(); + + // Step 3: batch-load entities with relations, then properties in separate queries + // to avoid Hibernate's MultipleBagFetchException + List jpaEntities = + jpaEntityRepository.findAllByIdentifierInWithRelations(identifiers); + jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + + // Step 4: map to domain and key by composite key for O(1) lookup + return jpaEntities.stream() + .map(mapper::toDomain) + .collect(Collectors.toMap( + e -> new EntityCompositeKey(e.templateIdentifier(), e.identifier()), + Function.identity() + )); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 97675e9..fb94167 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -27,6 +27,8 @@ public interface JpaEntityRepository extends JpaRepository findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByIdentifier(String identifier); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); @@ -56,4 +58,62 @@ WHERE r IN ( void deleteRelationsByTemplateIdentifierAndRelationName( @Param("templateIdentifier") String templateIdentifier, @Param("relationNames") Collection relationNames); + + /// Batch fetch entities by identifiers with eager loading of relations and properties. + /// Uses two separate queries to avoid Hibernate's MultipleBagFetchException. + /// First fetches entities with relations, then fetches properties separately. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithRelations(@Param("identifiers") Collection identifiers); + + /// Fetch properties for entities that were already loaded. + /// This is called after findAllByIdentifierInWithRelations to complete the entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithProperties(@Param("identifiers") Collection identifiers); + + @Query(value = """ + WITH RECURSIVE + -- Traverse outbound relations (this entity -> targets) + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + ), + -- Traverse inbound relations (sources -> this entity as target) + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiers( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, + @Param("depth") int depth); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 6c2c543..57c0c66 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -15,7 +15,9 @@ public interface JpaRelationRepository extends JpaRepository { @Query(""" - SELECT tei AS targetEntityIdentifier, r.name AS relationName, e.identifier AS sourceEntityIdentifier, e.name AS sourceEntityName + SELECT new com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary( + tei, r.name, e.identifier, e.name + ) FROM EntityJpaEntity e JOIN e.relations r JOIN r.targetEntityIdentifiers tei From 1293b541033d9c30400ff79182a3aa2004c93017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 19 May 2026 12:06:00 +0200 Subject: [PATCH 13/51] feat(core): add a entity graph service and endpoint --- .../dto/out/entity/EntityGraphNodeDtoOut.java | 8 +- .../out/entity/EntityGraphRelationDtoOut.java | 15 +- .../entity_graph/EntityGraphServiceTest.java | 284 ++++++++++++++++++ 3 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java index 5d159bf..980fb26 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java @@ -26,4 +26,10 @@ public record EntityGraphNodeDtoOut( @Schema(description = ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION) Map> relationsAsTarget -) {} +) { + /// Defensive copies prevent external mutation of the mutable Map collections + public EntityGraphNodeDtoOut { + relations = relations != null ? Map.copyOf(relations) : Map.of(); + relationsAsTarget = relationsAsTarget != null ? Map.copyOf(relationsAsTarget) : Map.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java index 55f0794..df12629 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java @@ -1,5 +1,9 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; + import java.util.List; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; @@ -7,10 +11,6 @@ import io.swagger.v3.oas.annotations.media.Schema; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; - /// Output DTO representing a single named relation in the entity graph. @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphRelationDtoOut( @@ -23,4 +23,9 @@ public record EntityGraphRelationDtoOut( @Schema(description = ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION) List targets -) {} +) { + /// Defensive copy prevents external mutation of the mutable List collection + public EntityGraphRelationDtoOut { + targets = targets != null ? List.copyOf(targets) : List.of(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java new file mode 100644 index 0000000..0b07bd0 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -0,0 +1,284 @@ +package com.decathlon.idp_core.domain.service.entity_graph; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityGraphService Tests") +class EntityGraphServiceTest { + + private static final String TEMPLATE = "web-service"; + private static final String DB_TEMPLATE = "database"; + private static final String CACHE_TEMPLATE = "cache"; + private static final String INFRA_TEMPLATE = "infrastructure"; + private static final int DEFAULT_DEPTH = 3; + + @Mock + private EntityRepositoryPort entityRepositoryPort; + + @Mock + private EntityGraphRepositoryPort entityGraphRepositoryPort; + + @InjectMocks + private EntityGraphService entityGraphService; + + // --- Fixtures --- + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); + } + + private Entity entityWithRelations(String templateIdentifier, String identifier, String name, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), relations); + } + + private Relation relation(String name, String targetTemplateIdentifier, List targetIdentifiers) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, targetIdentifiers); + } + + private EntityCompositeKey key(String templateIdentifier, String identifier) { + return new EntityCompositeKey(templateIdentifier, identifier); + } + + // --- Tests --- + + @Nested + @DisplayName("getEntityGraph — root entity not found") + class RootEntityNotFound { + + @Test + @DisplayName("Should throw EntityNotFoundException when root entity does not exist") + void shouldThrowWhenRootEntityNotFound() { + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH)) + .isInstanceOf(EntityNotFoundException.class); + + verify(entityRepositoryPort).findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing"); + verifyNoInteractions(entityGraphRepositoryPort); + } + } + + @Nested + @DisplayName("getEntityGraph — single root, no relations") + class SingleRootNoRelations { + + @Test + @DisplayName("Should return a leaf node when entity has no relations") + void shouldReturnLeafNodeWhenNoRelations() { + var root = entity(TEMPLATE, "api", "API Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(root)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.identifier()).isEqualTo("api"); + assertThat(result.name()).isEqualTo("API Service"); + assertThat(result.relations()).isEmpty(); + assertThat(result.relationsAsTarget()).isEmpty(); + } + } + + @Nested + @DisplayName("getEntityGraph — outbound relations") + class OutboundRelations { + + @Test + @DisplayName("Should resolve outbound relations to graph nodes") + void shouldResolveOutboundRelations() { + var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().getFirst().name()).isEqualTo("uses"); + assertThat(result.relations().getFirst().targets()).hasSize(1); + assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("postgres"); + } + + @Test + @DisplayName("Should create a fallback node when relation target is not in the graph map") + void shouldReturnFallbackNodeWhenTargetNotInMap() { + // Simulates a target entity outside the loaded depth — still produces a placeholder node + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("unknown-db")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.relations()).hasSize(1); + // Fallback node uses identifier as both id and name when entity is not in map + assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("unknown-db"); + } + } + + @Nested + @DisplayName("getEntityGraph — inbound relations") + class InboundRelations { + + @Test + @DisplayName("Should resolve inbound relations for entities that are targeted by others") + void shouldResolveInboundRelations() { + var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(DB_TEMPLATE, "postgres")) + .thenReturn(Optional.of(db)); + when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH); + + // postgres is targeted by api via "uses" + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().getFirst().name()).isEqualTo("uses"); + assertThat(result.relationsAsTarget().getFirst().targets().getFirst().identifier()).isEqualTo("api"); + } + } + + @Nested + @DisplayName("getEntityGraph — depth clamping") + class DepthClamping { + + @Test + @DisplayName("Should clamp depth below 1 to 1") + void shouldClampDepthBelowOne() { + var root = entity(TEMPLATE, "api", "API Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(root)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 0); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1); + } + + @Test + @DisplayName("Should clamp depth above 10 to 10") + void shouldClampDepthAboveTen() { + var root = entity(TEMPLATE, "api", "API Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(root)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10)) + .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 99); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10); + } + } + + @Nested + @DisplayName("getEntityGraph — depth limit stops recursion") + class DepthLimit { + + @Test + @DisplayName("Should return a leaf node for targets at the depth boundary") + void shouldReturnLeafNodeAtDepthBoundary() { + // api --uses--> postgres --runs-on--> server-1 + // At depth=1: postgres node is resolved but its own relations are NOT expanded + var server = entity(INFRA_TEMPLATE, "server-1", "Server 1"); + var db = entityWithRelations(DB_TEMPLATE, "postgres", "Postgres DB", + List.of(relation("runs-on", INFRA_TEMPLATE, List.of("server-1")))); + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db, + key(INFRA_TEMPLATE, "server-1"), server + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1); + + // postgres node is included but its child relations are empty (remaining depth = 0) + var dbNode = result.relations().getFirst().targets().getFirst(); + assertThat(dbNode.identifier()).isEqualTo("postgres"); + assertThat(dbNode.relations()).isEmpty(); + } + } + + @Nested + @DisplayName("getEntityGraph — multiple outbound relations") + class MultipleRelations { + + @Test + @DisplayName("Should resolve multiple named relation types correctly") + void shouldResolveMultipleNamedRelations() { + var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); + var cache = entity(CACHE_TEMPLATE, "redis", "Redis Cache"); + var api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( + relation("uses-db", DB_TEMPLATE, List.of("postgres")), + relation("uses-cache", CACHE_TEMPLATE, List.of("redis")) + )); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db, + key(CACHE_TEMPLATE, "redis"), cache + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.relations()).hasSize(2); + var relationNames = result.relations().stream().map(r -> r.name()).toList(); + assertThat(relationNames).containsExactlyInAnyOrder("uses-db", "uses-cache"); + } + } +} From 7411267d36e7e7e78fa24b95962a01eb281e30a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 19 May 2026 16:54:45 +0200 Subject: [PATCH 14/51] feat(core): add a entity graph service and endpoint --- .../model/entity_graph/EntityGraphNode.java | 2 + .../entity_graph/EntityGraphService.java | 6 +- .../api/configuration/SwaggerDescription.java | 15 +++ .../api/controller/EntityGraphController.java | 39 ++++++ .../dto/out/entity/EntityGraphEdgeDtoOut.java | 31 +++++ .../dto/out/entity/EntityGraphFlatDtoOut.java | 33 +++++ .../dto/out/entity/EntityGraphNodeDtoOut.java | 1 + .../out/entity/EntityGraphNodeFlatDtoOut.java | 31 +++++ .../entity/EntityGraphDtoOutMapper.java | 2 +- .../entity/EntityGraphFlatDtoOutMapper.java | 123 ++++++++++++++++++ .../entity_graph/EntityGraphServiceTest.java | 2 - 11 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java index 9348a38..2a795f5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -10,10 +10,12 @@ /// - Understanding relationship chains between entities /// - Providing a hierarchical view of entity connections /// +/// @param templateIdentifier the template identifier this entity belongs to /// @param summary the lightweight entity identification data /// @param relations the resolved outbound relations with their target graph nodes /// @param relationsAsTarget incoming relations where this entity is the target public record EntityGraphNode( + String templateIdentifier, String identifier, String name, List relations, diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 46ca3f8..367e154 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -77,11 +77,11 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, int remainingDepth) { Entity entity = entityMap.get(key); if (entity == null) { - return new EntityGraphNode(key.identifier(), key.identifier(), List.of(), List.of()); + return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), List.of(), List.of()); } if (remainingDepth <= 0) { - return new EntityGraphNode(entity.identifier(), entity.name(), List.of(), List.of()); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), List.of(), List.of()); } // Resolve outbound relations from pre-loaded entities @@ -102,7 +102,7 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, List inboundRelations = buildRelationsAsTargetFromMap( entity.identifier(), entityMap, remainingDepth - 1); - return new EntityGraphNode(entity.identifier(), entity.name(), outboundRelations, inboundRelations); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), outboundRelations, inboundRelations); } /// Looks up a composite key from the map by identifier alone. diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index b7f85ec..67013ba 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -161,4 +161,19 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION = "The template identifier of target entities"; public static final String ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION = "Resolved target entity graph nodes"; public static final String ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION = "Incoming relations where this entity is the target"; + + // --- Entity Graph flat (nodes & edges) descriptions --- + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; + public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; + public static final String ENTITY_GRAPH_FLAT_NODES_DESCRIPTION = "All entity nodes in the graph"; + public static final String ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION = "All directed relation edges in the graph"; + public static final String ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION = "Unique node identifier composed of templateIdentifier:identifier"; + public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; + public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; + public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; + public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; + public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 727b963..c33a558 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -1,10 +1,13 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; @@ -18,9 +21,11 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphDtoOutMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphFlatDtoOutMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -75,4 +80,38 @@ public EntityGraphNodeDtoOut getEntityGraph( return EntityGraphDtoOutMapper.toDto(graphNode); } + + /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. + /// + /// Returns all entities as nodes and all directed relations as edges, following + /// the de-facto standard for frontend visualization tools such as React Flow, + /// Vis.js, and Cytoscape. Nodes are deduplicated; edges encode directionality. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @return flat DTO containing nodes and edges arrays + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph/flat") + @ResponseStatus(OK) + @Operation( + summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, + description = ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION, + responses = { + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS, + content = @Content(schema = @Schema(implementation = EntityGraphFlatDtoOut.class))), + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + } + ) + public EntityGraphFlatDtoOut getEntityGraphFlat( + @PathVariable @NotBlank String templateIdentifier, + @PathVariable @NotBlank String entityIdentifier, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) + @RequestParam(defaultValue = "1") int depth) { + + EntityGraphNode graphNode = entityGraphService.getEntityGraph( + templateIdentifier, entityIdentifier, depth); + + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java new file mode 100644 index 0000000..c61800d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a directed relation edge in the flat entity graph. +/// +/// Encodes a single directional connection between two entity nodes, identified +/// by their composite-key-derived node IDs. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphEdgeDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION) + String id, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION) + String source, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION) + String target, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION) + String type +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java new file mode 100644 index 0000000..aa43eb8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java @@ -0,0 +1,33 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODES_DESCRIPTION; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Top-level response DTO for the flat entity graph representation. +/// +/// Separates entities from their connections into two parallel collections, +/// following the de-facto standard expected by frontend visualization libraries +/// such as React Flow, Vis.js, and Cytoscape. This format avoids nesting and +/// any risk of infinite loops caused by circular relations. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphFlatDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) + List nodes, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) + List edges +) { + /// Defensive copies prevent external mutation of the returned collections. + public EntityGraphFlatDtoOut { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); + edges = edges != null ? List.copyOf(edges) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java index 980fb26..43119c9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java @@ -18,6 +18,7 @@ @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphNodeDtoOut( + String templateIdentifier, String identifier, String name, diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java new file mode 100644 index 0000000..569941e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a single node in the flat entity graph. +/// +/// Used by frontend visualization tools (React Flow, Vis.js, Cytoscape) that expect +/// entities and their relationships as separate, non-nested collections. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphNodeFlatDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION) + String id, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION) + String label, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION) + String templateIdentifier, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) + String identifier +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java index 8b3e6be..582c9f5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java @@ -29,7 +29,7 @@ public static EntityGraphNodeDtoOut toDto(EntityGraphNode node) { return null; } return new EntityGraphNodeDtoOut( - node.identifier(), node.name(), + node.templateIdentifier(), node.identifier(), node.name(), mapRelations(node.relations()), mapRelations(node.relationsAsTarget())); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java new file mode 100644 index 0000000..d8874c2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -0,0 +1,123 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.SequencedSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphEdgeDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeFlatDtoOut; + +/// Mapper for converting a recursive [EntityGraphNode] domain tree into the flat +/// nodes-and-edges representation expected by frontend visualization libraries +/// (React Flow, Vis.js, Cytoscape). +/// +/// **Design:** +/// - Traverses both `relations` (outbound) and `relationsAsTarget` (inbound) depth-first, +/// deduplicating nodes by their composite node ID (templateIdentifier:identifier). +/// - Outbound edges are emitted as `source → target`. +/// - Inbound edges (relationsAsTarget) are emitted as `source → currentNode`, preserving +/// the original direction of the relation. This is critical when the root entity has no +/// outbound relations and is only reachable as a relation target. +/// - A `SequencedSet` of visited node IDs prevents infinite loops in cyclic graphs. +/// - A `Set` of edge signatures (`source|target|label`) deduplicates edges that would +/// otherwise be emitted twice when both sides of a relation are traversed. +public final class EntityGraphFlatDtoOutMapper { + + private EntityGraphFlatDtoOutMapper() { + // Utility class — not instantiable + } + + /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. + /// + /// @param root the root [EntityGraphNode] returned by the domain service + /// @return flat DTO with deduplicated nodes and directed edges + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { + if (root == null) { + return new EntityGraphFlatDtoOut(List.of(), List.of()); + } + + // Use a SequencedSet to deduplicate nodes while preserving insertion order + SequencedSet nodes = new LinkedHashSet<>(); + List edges = new ArrayList<>(); + // Tracks visited node IDs to prevent infinite loops in cyclic graphs + Set visitedNodeIds = new HashSet<>(); + // Tracks emitted edge signatures (source|target|label) to avoid duplicate edges + // when the same relation is encountered from both sides during traversal + Set emittedEdgeSignatures = new HashSet<>(); + var edgeCounter = new AtomicInteger(0); + + traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + + return new EntityGraphFlatDtoOut(List.copyOf(nodes), List.copyOf(edges)); + } + + private static void traverse( + EntityGraphNode node, + SequencedSet nodes, + List edges, + Set visitedNodeIds, + Set emittedEdgeSignatures, + AtomicInteger edgeCounter) { + + var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + + // Skip this node if already visited to prevent infinite loops in cyclic graphs + if (!visitedNodeIds.add(nodeId)) { + return; + } + + nodes.add(new EntityGraphNodeFlatDtoOut( + nodeId, node.name(), node.templateIdentifier(), node.identifier())); + + // Traverse outbound relations: emit edge from currentNode → target + for (EntityGraphRelation relation : node.relations()) { + for (EntityGraphNode target : relation.targets()) { + var targetId = nodeId(target.templateIdentifier(), target.identifier()); + addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); + traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + } + } + + // Traverse inbound relations: emit edge from source → currentNode. + // This is essential when the root entity has no outbound relations and is only + // reachable as a target. Without this, traversal would stop at the root with no edges. + for (EntityGraphRelation relation : node.relationsAsTarget()) { + for (EntityGraphNode source : relation.targets()) { + var sourceId = nodeId(source.templateIdentifier(), source.identifier()); + addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); + traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + } + } + } + + /// Adds a directed edge only if it has not been emitted before, preventing duplicates + /// that arise when the same relation is encountered from both the source and the target + /// during depth-first traversal. + private static void addEdge( + List edges, + Set emittedEdgeSignatures, + AtomicInteger edgeCounter, + String sourceId, + String targetId, + String label) { + + var signature = sourceId + "|" + targetId + "|" + label; + if (emittedEdgeSignatures.add(signature)) { + edges.add(new EntityGraphEdgeDtoOut( + "e" + edgeCounter.incrementAndGet(), sourceId, targetId, label)); + } + } + + /// Builds the unique node identifier from the entity's composite key. + /// Format: "templateIdentifier:identifier" — mirrors EntityCompositeKey.toString(). + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 0b07bd0..5e38977 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -65,8 +65,6 @@ private EntityCompositeKey key(String templateIdentifier, String identifier) { return new EntityCompositeKey(templateIdentifier, identifier); } - // --- Tests --- - @Nested @DisplayName("getEntityGraph — root entity not found") class RootEntityNotFound { From 35af97c23c3ec5748b423b31800eb9a25336d6d1 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Fri, 24 Apr 2026 14:05:10 +0200 Subject: [PATCH 15/51] feat(core): add post entity --- docker-compose.yml | 3 +- docs/src/concepts/entities.md | 264 +++++++++++++---- .../code/domain-infrastructure.md | 7 +- docs/src/static/swagger.yaml | 272 +++++++++++++++++- .../domain/constant/ValidationMessages.java | 19 ++ .../EntityAlreadyExistsException.java | 17 ++ .../exception/EntityNotFoundException.java | 6 +- .../exception/EntityValidationException.java | 30 ++ .../idp_core/domain/model/entity/Entity.java | 6 +- .../domain/port/EntityRepositoryPort.java | 4 +- .../service/{ => entity}/EntityService.java | 66 +++-- .../entity/EntityValidationService.java | 152 ++++++++++ .../domain/service/entity/Violations.java | 35 +++ .../property/PropertyValidationService.java | 116 ++++++++ .../configuration/SecurityConfiguration.java | 1 + .../api/configuration/SwaggerDescription.java | 16 ++ .../api/controller/EntityController.java | 47 ++- .../adapters/api/dto/in/EntityDtoIn.java | 51 +++- .../api/handler/ApiExceptionHandler.java | 50 +++- .../api/mapper/entity/EntityDtoInMapper.java | 53 ++-- .../api/mapper/entity/EntityDtoOutMapper.java | 2 +- .../persistence/PostgresEntityAdapter.java | 14 +- .../repository/JpaEntityRepository.java | 2 + .../service/entity/EntityServiceTest.java | 158 ++++++++++ .../entity/EntityValidationServiceTest.java | 235 +++++++++++++++ .../PropertyValidationServiceTest.java | 80 ++++++ .../api/controller/EntityControllerTest.java | 142 +++++++-- .../EntityTemplateControllerTest.java | 4 - .../api/handler/ApiExceptionHandlerTest.java | 90 ++++-- ...stEntityTemplate_400_properties_empty.json | 6 + ...late_400_withoutPropertiesDefinitions.json | 6 + .../json/entity/v1/postEntity_201.json | 16 +- .../entity/v1/postEntity_201_minimal.json | 4 + .../v1/postEntity_201_with_relations.json | 14 + .../v1/postEntity_400_identifier_missing.json | 7 + .../v1/postEntity_400_name_missing.json | 7 + .../postEntity_400_property_value_blank.json | 8 + .../postEntity_400_relation_name_blank.json | 11 + .../entity/v1/postEntity_409_duplicate.json | 6 + 39 files changed, 1803 insertions(+), 224 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java rename src/main/java/com/decathlon/idp_core/domain/service/{ => entity}/EntityService.java (59%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java create mode 100644 src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json create mode 100644 src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json diff --git a/docker-compose.yml b/docker-compose.yml index be4859d..139089d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,8 @@ --- -version: "3.8" services: postgres: - image: postgres:14 + image: postgres:18 environment: POSTGRES_USER: idpcore POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-idpcore_password} diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 4a78945..92598c0 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -3,17 +3,17 @@ title: Entities description: Understand Entities - instances of Entity Templates with actual data --- -Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity is the house built from that blueprint. +Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity +is the house built from that blueprint. ## Overview An Entity contains: -- **Identity** - Unique identifier and title +- **Identity** - Unique identifier and name - **Template Reference** - Which template it instantiates - **Properties** - Actual values for the template's property definitions - **Relations** - Links to other entities -- **Audit Fields** - Creation/modification timestamps and actors ```mermaid flowchart LR @@ -36,26 +36,26 @@ flowchart LR ### Complete Example -Here's an entity instantiated from the `sonar_project` template: +Here's an entity instantiated from the `web-service` template: ```json { - "identifier": "decathlon_my-backend-project", - "title": "My Backend Project", - "template": "sonar_project", + "identifier": "my-web-service", + "name": "my-web-service", + "template_identifier": "web-service", "properties": { - "project_name": "My Backend Project", - "last_analysis_date": "2025-11-28T12:20:38+0000", - "issues_number": 137, - "loc": 20000 + "port": "8080", + "environment": "dev" }, "relations": { - "github_repository": "my-backend-repo" + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + } + ] }, - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt" + "relations_as_target": {} } ``` @@ -63,17 +63,104 @@ Here's an entity instantiated from the `sonar_project` template: ## Core Fields -| Field | Type | Description | -| ------------ | -------- | -------------------------------------------- | -| `identifier` | String | Unique identifier for this entity | -| `title` | String | Human-readable name | -| `template` | String | The Entity Template this entity instantiates | -| `properties` | Object | Key-value pairs of property data | -| `relations` | Object | Links to other entities | -| `created_at` | DateTime | When the entity was created | -| `created_by` | String | Who created the entity | -| `updated_at` | DateTime | Last modification time | -| `updated_by` | String | Who last modified the entity | +| Field | Type | Description | +|-----------------------|----------|----------------------------------------------| +| `identifier` | String | Unique identifier within the template scope | +| `name` | String | Human-readable name | +| `template_identifier` | String | The Entity Template this entity instantiates | +| `properties` | Object | Key-value pairs of property data | +| `relations` | Object | Links to other entities (grouped by name) | + +--- + +## Creating an Entity + +You create an entity by sending a `POST` request to the entities endpoint, specifying the template identifier in the URL +path. + +### Endpoint + +```text +POST /api/v1/entities/{templateIdentifier} +``` + +### Request Body + +```json +{ + "name": "my-web-service", + "identifier": "my-web-service", + "properties": { + "port": "8080", + "environment": "dev" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + } + ] +} +``` + +### Validation + +IDP-Core validates entities at two levels: **syntactic validation** at the API boundary and **semantic validation** +against the template definition. + +#### Syntactic Validation (API Layer) + +The API enforces basic structural rules on the request body before any business logic runs: + +| Field | Rule | Error Message | +|-----------------------------------------|---------------------|--------------------------------------| +| `name` | Required, not blank | Entity name is mandatory | +| `identifier` | Required, not blank | Entity identifier is mandatory | +| `relations[].name` | Required, not blank | Relation name is mandatory | +| `relations[].target_entity_identifiers` | Required, not null | Relation target identifiers required | + +If any rule fails, the API returns `400 Bad Request` with a description of the violation. + +#### Semantic Validation (Domain Layer) + +After syntactic checks pass, the domain service validates the entity against its template definition: + +- **Template existence** - The template identifier must match an existing template. Returns `404 Not Found` if the + template does not exist. +- **Property value types** - Values must conform to the property definition type (STRING, NUMBER, BOOLEAN). +- **Property rules** - Values must satisfy the template's property rules (min/max length, format, regex, enum). +- **Required properties** - All properties marked as required in the template must be present. +- **Duplicate check** - An entity with the same identifier must not already exist for the template. Returns + `409 Conflict` if it does. + +### Response Codes + +| Code | Description | +|-------|----------------------------------------------------------------| +| `201` | Entity created successfully | +| `400` | Invalid request body or validation failure | +| `401` | Missing or invalid authentication token | +| `403` | Insufficient permissions | +| `404` | Template not found for the given identifier | +| `409` | An entity with this identifier already exists for the template | +| `500` | Unexpected server error | + +### Minimal Example + +You can create an entity with only the required fields: + +```json +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} +``` + +Properties and relations are optional in the request body. The domain layer validates that all *required* properties (as +defined in the template) are present. --- @@ -84,17 +171,17 @@ Properties contain the actual data values. The structure follows the template's ```json { "properties": { - "project_name": "My Backend Project", // STRING - "issues_number": 137, // NUMBER - "loc": 20000, // NUMBER - "last_analysis_date": "2025-11-28..." // STRING (date-time) + "project_name": "My Backend Project", + "issues_number": 137, + "loc": 20000, + "last_analysis_date": "2025-11-28..." } } ``` -### Validation +### Validation of properties -System validates values against the template's property rules: +The system validates values against the template's property rules: - Required properties must be present - Types must match: STRING, NUMBER, or BOOLEAN @@ -104,51 +191,119 @@ System validates values against the template's property rules: ## Relations -Relations link entities together, forming a graph. It references the entity identifiers of related entities. +Relations link entities together, forming a graph. Each relation references the entity identifiers of related entities. -### One-to-One Relations (`to_many: false`) +### Creating Relations -For consistency, even single relations are represented as arrays: +When creating an entity, you specify relations as an array of objects, each with a `name` and a list of +`target_entity_identifiers`: ```json { - "relations": { - "owned_by": ["platform-team"] - } + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + }, + { + "name": "owned-by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -### One-to-Many Relations (`to_many: true`) +### Relations in Responses -When multiple related entities are allowed, you can list several identifiers in the relation array: +In API responses, relations are grouped by name and include summary information about each target entity: ```json { "relations": { - "components": ["frontend", "backend", "database"] + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + }, + { + "identifier": "web-api-2", + "name": "Web API 2" + } + ] + }, + "relations_as_target": { + "depends-on": [ + { + "identifier": "frontend-app", + "name": "Frontend App" + } + ] } } ``` ---- +The `relations_as_target` field shows reverse relationships—other entities that reference this entity. -## Audit Fields +### One-to-One Relations (`to_many: false`) -Every entity tracks who created/modified it and when: +For consistency, even single relations are represented as arrays: ```json { - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "auth0|65c1d23377c9bea7d7adc415", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "webhook_integration_sonar" + "relations": [ + { + "name": "owned_by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -The `created_by` and `updated_by` fields contain: +### One-to-Many Relations (`to_many: true`) -- User IDs for manual operations -- Integration IDs for automated data ingestion +When multiple related entities are allowed, list several identifiers: + +```json +{ + "relations": [ + { + "name": "components", + "target_entity_identifiers": [ + "frontend", + "backend", + "database" + ] + } + ] +} +``` + +--- + +## Retrieving Entities + +### List Entities by Template + +Retrieve a paginated list of entities for a given template: + +```text +GET /api/v1/entities/{templateIdentifier}?page=0&size=20&sort=identifier,asc +``` + +### Get Entity by Identifier + +Retrieve a specific entity using its template and entity identifiers: + +```text +GET /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier} +``` --- @@ -157,7 +312,8 @@ The `created_by` and `updated_by` fields contain: Because templates are configured at runtime, the entity structure is **dynamic**: > [!WARNING] -> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure depends on the template configuration. +> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure +> depends on the template configuration. > > This means: > @@ -168,4 +324,4 @@ Because templates are configured at runtime, the entity structure is **dynamic** - **[Properties](properties.md)** - Property types and validation rules - **[Relations](relations.md)** - How entities connect -- **[Calculated Properties](calculated-properties.md)** - Automatic computations +- **[API Reference](../api/index.md)** - Interactive Swagger UI documentation diff --git a/docs/src/contributing/code/domain-infrastructure.md b/docs/src/contributing/code/domain-infrastructure.md index 8b6a25c..ad1dbf9 100644 --- a/docs/src/contributing/code/domain-infrastructure.md +++ b/docs/src/contributing/code/domain-infrastructure.md @@ -33,7 +33,12 @@ domain/ │ ├── EntityTemplateRepositoryPort │ └── RelationRepositoryPort └── service/ # Domain services logic orchestration - ├── EntityService + ├── entity/ + │ ├── EntityService # Orchestrates entity CRUD with validation + │ ├── EntityValidationService # Entity validation pipeline (template, uniqueness, structure, rules) + │ └── Violations # Mutable accumulator of validation violation messages + ├── property/ + │ └── PropertyValidationService # Validates property values against type and rules (STRING, NUMBER, BOOLEAN) ├── EntityTemplateService └── RelationService ``` diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 37c9d48..accb23c 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -9,6 +9,8 @@ security: - clientId: [] - bearer: [] tags: + - name: Entities Management + description: Operations related to entity management - name: Entities Templates Management description: Operations related to entity template management paths: @@ -160,6 +162,143 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/{templateIdentifier}: + get: + tags: + - Entities Management + summary: Get entities by template identifier + description: Retrieve a paginated list of entities with optional sorting + operationId: getEntities + parameters: + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + '*/*': + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' + content: + '*/*': + schema: + type: string + default: identifier,asc + responses: + '200': + description: Paginated entities retrieved successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityPageResponse' + '400': + description: Invalid pagination parameters + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Entities Management + summary: Create a new entity + description: Create a new entity in the system with the provided information + operationId: createEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EntityDtoIn' + required: true + responses: + '201': + description: Entity created successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '400': + description: Invalid entity data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '409': + description: Entity already exists in this template + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Template not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + get: + tags: + - Entities Management + summary: Get entity by entity template and identifier + description: Retrieve a specific entity using its string identifier and its template identifier + operationId: getEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string + responses: + '200': + description: Entity found + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' components: schemas: EntityTemplateCreateDtoIn: @@ -173,7 +312,7 @@ components: minLength: 1 name: type: string - description: Unique Entity Template name + description: Entity Template name example: Service maxLength: 255 minLength: 1 @@ -201,10 +340,10 @@ components: properties: name: type: string - description: Entity Template name + description: Unique Entity Template name example: Service maxLength: 255 - minLength: 1 + minLength: 0 pattern: "^[a-zA-Z0-9 _-]+$" description: type: string @@ -356,11 +495,6 @@ components: type: object description: Output DTO for property definition properties: - id: - type: string - format: uuid - description: Unique identifier of the property definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Property name @@ -437,11 +571,6 @@ components: type: object description: Output DTO for relation definition properties: - id: - type: string - format: uuid - description: Unique identifier of the relation definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Name of the relation @@ -535,6 +664,86 @@ components: - 511 NETWORK_AUTHENTICATION_REQUIRED errorDescription: type: string + EntityDtoIn: + type: object + description: Input DTO for creating or updating an entity + properties: + name: + type: string + description: Name of the entity + example: my-web-service + minLength: 1 + identifier: + type: string + description: Unique identifier of the entity within the template scope + example: my-web-service + minLength: 1 + properties: + type: object + additionalProperties: {} + description: Map of property name to value for this entity + example: + port: '8080' + environment: dev + relations: + type: array + description: List of relations for this entity + items: + $ref: '#/components/schemas/RelationDtoIn' + required: + - identifier + - name + RelationDtoIn: + type: object + description: Input DTO for an entity relation instance + properties: + name: + type: string + description: Name of the relation (must match a template relation definition) + example: depends-on + minLength: 1 + target_entity_identifiers: + type: array + description: List of target entity identifiers for this relation + example: + - web-api-1 + - web-api-2 + items: + type: string + required: + - name + - target_entity_identifiers + EntityDtoOut: + type: object + properties: + template_identifier: + type: string + name: + type: string + identifier: + type: string + properties: + type: object + additionalProperties: {} + relations: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + relations_as_target: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + EntitySummaryDto: + type: object + properties: + identifier: + type: string + name: + type: string PageableObject: type: object properties: @@ -572,15 +781,46 @@ components: $ref: '#/components/schemas/EntityTemplateDtoOut' pageable: $ref: '#/components/schemas/PageableObject' + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 last: type: boolean - totalPages: + size: + type: integer + format: int32 + number: type: integer format: int32 + sort: + $ref: '#/components/schemas/SortObject' + first: + type: boolean + numberOfElements: + type: integer + format: int32 + empty: + type: boolean + EntityPageResponse: + type: object + description: Paginated response containing Entity objects + properties: + content: + type: array + items: + $ref: '#/components/schemas/EntityDtoOut' + pageable: + $ref: '#/components/schemas/PageableObject' totalElements: type: integer format: int64 - first: + totalPages: + type: integer + format: int32 + last: type: boolean size: type: integer @@ -590,6 +830,8 @@ components: format: int32 sort: $ref: '#/components/schemas/SortObject' + first: + type: boolean numberOfElements: type: integer format: int32 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 c023292..e179064 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,6 +23,16 @@ public class ValidationMessages { public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; + public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; + public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; + public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; + public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; + public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; + public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; + public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; + public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; + public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; @@ -63,4 +73,13 @@ public static String minMaxConstraintViolated(String constraint) { return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED .replace("{constraint}", constraint); } + + // Entity input validation messages + public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + + // Entity creation validation messages + public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; + public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; + public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java new file mode 100644 index 0000000..bd76169 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java @@ -0,0 +1,17 @@ +package com.decathlon.idp_core.domain.exception; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_ALREADY_EXISTS; + +import com.decathlon.idp_core.domain.model.entity.Entity; + +/// Domain exception for duplicate [Entity] business entities within a template scope. +public class EntityAlreadyExistsException extends RuntimeException { + + /// Constructs a new exception with template and entity identifiers. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityName the duplicate entity name + public EntityAlreadyExistsException(String templateIdentifier, String entityName) { + super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java index 2942d91..cc7d4a8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java @@ -1,5 +1,9 @@ package com.decathlon.idp_core.domain.exception; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NOT_FOUND; + +import com.decathlon.idp_core.domain.model.entity.Entity; + /// Domain exception for missing [Entity] business entities. /// /// **Business purpose:** Represents the business rule violation when attempting @@ -20,7 +24,7 @@ public class EntityNotFoundException extends RuntimeException { /// @param templateIdentifier the identifier of the template /// @param entityIdentifier the identifier of the entity public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { - super(String.format("Entity not found with template identifier %s and entity identifier '%s'", templateIdentifier, entityIdentifier)); + super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java new file mode 100644 index 0000000..ca9da64 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java @@ -0,0 +1,30 @@ +package com.decathlon.idp_core.domain.exception; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_VALIDATION_FAILED; + +import java.util.List; + +import lombok.Getter; + +/// Domain exception for entity schema validation failures +@Getter +public class EntityValidationException extends RuntimeException { + + /** + * -- GETTER -- + * Returns the list of individual validation violation messages. + * /// + * /// + * @return immutable list of violation messages + */ + private final List violations; + + /// Constructs a new exception with a list of validation violation messages. + /// + /// @param violations the list of validation error messages + public EntityValidationException(List violations) { + super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); + this.violations = List.copyOf(violations); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2ec901e..6250a5a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.domain.model.entity; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY; import java.util.List; @@ -24,9 +26,9 @@ public record Entity( @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, - + @NotBlank(message = ENTITY_NAME_MANDATORY) String name, - + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, List properties, diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 02584b9..296a2fb 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -33,7 +33,9 @@ public interface EntityRepositoryPort { Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); + + Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable); List findByIdentifierIn(List identifiers); 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/EntityService.java similarity index 59% 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/EntityService.java index b21d871..05839ba 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -1,21 +1,21 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.entity; import java.util.List; +import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; 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; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; - import jakarta.transaction.Transactional; import jakarta.validation.Valid; -import lombok.AllArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. /// @@ -26,39 +26,37 @@ /// **Key responsibilities:** /// - Entity retrieval with template validation /// - Entity creation with business rule enforcement +/// - Entity data integrity validation (entity, properties, relations) /// - Entity summary generation for efficient queries -/// - Relationship integrity validation @Service @AllArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; + private final EntityValidationService entityValidationService; /// Retrieves entities filtered by template with existence validation. /// /// **Contract:** Returns paginated entities that conform to the specified template. /// Template existence is validated first to ensure meaningful results. /// - /// @param pageable pagination configuration for large entity sets + /// @param pageable pagination configuration for large entity sets /// @param templateIdentifier business identifier of the entity template /// @return paginated entities matching the template /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable) + .orElseThrow(() -> new EntityTemplateNotFoundException(templateIdentifier)); } - /// Provides lightweight entity summaries for efficient bulk operations. - /// - /// **Contract:** Returns summary projections without full entity data, - /// optimized for UI lists and relationship resolution scenarios. - /// - /// @param identifiers business identifiers of entities to summarize - /// @return lightweight entity summaries for the specified identifiers + /// Provides lightweight entity summaries for efficient bulk operations. + /// + /// **Contract:** Returns summary projections without full entity data, + /// optimized for UI lists and relationship resolution scenarios. + /// + /// @param identifiers business identifiers of entities to summarize + /// @return lightweight entity summaries for the specified identifiers public List getEntitiesSummariesByIndentifiers(List identifiers) { return entityRepository.findByIdentifierIn(identifiers); } @@ -69,29 +67,35 @@ public List getEntitiesSummariesByIndentifiers(List ident /// Validates template existence first, then entity existence, ensuring referential integrity. /// /// @param templateIdentifier business identifier of the entity template - /// @param entityIdentifier unique business identifier of the entity within template + /// @param entityIdentifier unique business identifier of the entity within template /// @return the entity matching both identifiers /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when entity doesn't exist + /// @throws EntityNotFoundException when entity doesn't exist @Transactional public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifier, String entityIdentifier) { - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } + entityValidationService.checkTemplateExist(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); } - /// Creates and persists a new entity with business validation. - /// - /// **Contract:** Validates entity structure against template rules and persists - /// the entity. Future enhancement will include comprehensive business rule validation. - /// - /// @param entity validated entity to create and persist - /// @return the persisted entity with generated identifiers + /// Creates and persists a new entity with business validation. + /// + /// **Contract:** Validates template existence, entity identifier uniqueness within + /// the template scope, and entity/property/relation data integrity before persisting. + /// + /// @param entity validated entity to create and persist + /// @return the persisted entity with generated identifiers + /// @throws EntityTemplateNotFoundException when the referenced template doesn't exist + /// @throws EntityAlreadyExistsException when an entity with the same identifier already exists for this template + /// @throws EntityValidationException when entity, property, or relation data is invalid + @Transactional public Entity createEntity(@Valid Entity entity) { - // Add validations + entityValidationService.checkTemplateExist(entity.templateIdentifier()); + entityValidationService.checkEntityAlreadyExist(entity); + entityValidationService.validateEntity(entity); return entityRepository.save(entity); } + + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java new file mode 100644 index 0000000..f535e7d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -0,0 +1,152 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +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.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +/// Domain validator for [Entity] aggregates. +/// +/// Validation pipeline: +/// 1. Existence checks (template found, entity not duplicated). +/// 2. Syntactic checks on the entity itself (name/identifier, nested properties, relations). +/// 3. Template-driven semantic checks (required, type, rules). +@Service +@AllArgsConstructor +public class EntityValidationService { + + private final EntityRepositoryPort entityRepository; + private final EntityTemplateRepositoryPort entityTemplateRepository; + private final PropertyValidationService propertyValidationService; + + /// Check entity template existence to ensure valid template reference before deeper validations. + /// @param entity the entity whose template existence is to be checked + /// @throws EntityTemplateNotFoundException if the template referenced by the entity does not exist + void checkTemplateExist(final String entity) { + if (!entityTemplateRepository.existsByIdentifier(entity)) { + throw new EntityTemplateNotFoundException("identifier", entity); + } + } + + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// @param entity the entity to validate + /// @throws EntityValidationException when one or more validation rules are violated + /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template + /// @throws EntityTemplateNotFoundException if the referenced template does not exist + void validateEntity(Entity entity) { + checkEntityAlreadyExist(entity); + EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); + + Violations violations = new Violations(); + + validateEntityHeader(entity, violations); + validatePropertiesShape(entity.properties(), violations); + validateRelationsShape(entity.relations(), violations); + validateAgainstTemplate(template, entity.properties(), violations); + + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); + } + } + + private void validateEntityHeader(Entity entity, Violations violations) { + violations.addIfBlank(entity.name(), ENTITY_NAME_MANDATORY); + violations.addIfBlank(entity.identifier(), ENTITY_IDENTIFIER_MANDATORY); + } + + private void validatePropertiesShape(List properties, Violations violations) { + if (properties == null) { + return; + } + for (int i = 0; i < properties.size(); i++) { + Property prop = properties.get(i); + if (prop.name() == null || prop.name().isBlank()) { + violations.addIndexed("Property", i, PROPERTY_NAME_MANDATORY); + } + if (prop.value() == null || prop.value().isBlank()) { + violations.addIndexed("Property", i, PROPERTY_VALUE_MANDATORY); + } + } + } + + private void validateRelationsShape(List relations, Violations violations) { + if (relations == null) { + return; + } + for (int i = 0; i < relations.size(); i++) { + Relation rel = relations.get(i); + if (rel.name() == null || rel.name().isBlank()) { + violations.addIndexed("Relation", i, RELATION_NAME_MANDATORY_SIMPLE); + } + if (rel.targetEntityIdentifiers() == null) { + violations.addIndexed("Relation", i, RELATION_TARGET_IDENTIFIERS_NOT_NULL); + } + } + } + + /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. + /// @param template the entity template whose property definitions are used for validation + /// @param properties the list of properties from the entity to validate + /// @param violations the accumulator for validation violation messages + private void validateAgainstTemplate(EntityTemplate template, + List properties, + Violations violations) { + List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); + Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() + .filter(p -> p.name() != null) + .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); + + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null || property.value() == null || property.value().isBlank(); + + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); + } + continue; + } + + propertyValidationService + .validatePropertyValue(definition, property.value()) + .forEach(violations::add); + } + } + + /// Checks for existing entity with same template and identifier to prevent duplicates. + /// @param entity the entity to check for existence + /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists + void checkEntityAlreadyExist(final Entity entity) { + if (entity.identifier() != null + && entityRepository + .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) + .isPresent()) { + throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java new file mode 100644 index 0000000..92a3dd6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -0,0 +1,35 @@ +package com.decathlon.idp_core.domain.service.entity; +import java.util.ArrayList; +import java.util.List; + +/// Mutable accumulator of validation violation messages. +/// +/// Centralises message formatting and indexed-prefix handling so domain +/// validators stay focused on the rule they enforce rather than on string +/// concatenation. Not thread-safe; intended for short-lived per-request use. +final class Violations { + private final List messages = new ArrayList<>(); + void add(String message) { + messages.add(message); + } + void add(String template, Object... args) { + messages.add(template.formatted(args)); + } + void addIfBlank(String value, String message) { + if (value == null || value.isBlank()) { + messages.add(message); + } + } + + /// Adds a violation prefixed with the indexed collection name, e.g. + /// `Property[2]: Property name is mandatory`. + void addIndexed(String collection, int index, String message) { + messages.add("%s[%d]: %s".formatted(collection, index, message)); + } + boolean isEmpty() { + return messages.isEmpty(); + } + List asList() { + return List.copyOf(messages); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java new file mode 100644 index 0000000..983e1e3 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -0,0 +1,116 @@ +package com.decathlon.idp_core.domain.service.property; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_ENUM_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_FORMAT_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REGEX_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_MISMATCH; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; + +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; + +/** + * Domain service validating entity property values against template definitions. + */ +@Service +public class PropertyValidationService { + + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + + /** + * Validates a concrete property value against its property definition. + * + * @param propertyDefinition property definition with expected type and optional rules + * @param rawValue raw property value + * @return list of violations for this value; empty when valid + */ + public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue) { + return switch (propertyDefinition.type()) { + case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); + }; + } + + private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + if (rawValue == null) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minLength() != null && rawValue.length() < rules.minLength()) { + violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); + } + if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { + violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); + } + if (rules.regex() != null && !Pattern.matches(rules.regex(), rawValue)) { + violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); + } + if (rules.enumValues() != null && !rules.enumValues().isEmpty() + && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(rawValue))) { + violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); + } + if (rules.format() != null && !matchesFormat(rules.format(), rawValue)) { + violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); + } + + return List.copyOf(violations); + } + + private List validateNumberPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + final BigDecimal parsedValue; + try { + parsedValue = new BigDecimal(rawValue); + } catch (RuntimeException exception) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.minValue())) < 0) { + violations.add(PROPERTY_MIN_VALUE_VIOLATION.formatted(propertyName, rules.minValue())); + } + if (rules.maxValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.maxValue())) > 0) { + violations.add(PROPERTY_MAX_VALUE_VIOLATION.formatted(propertyName, rules.maxValue())); + } + + return List.copyOf(violations); + } + + private List validateBooleanPropertyValue(String propertyName, String rawValue) { + if ("true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue)) { + return List.of(); + } + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + } + + private boolean matchesFormat(PropertyFormat format, String value) { + return switch (format) { + case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); + case URL -> URL_PATTERN.matcher(value).matches(); + }; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index 9242ef9..cf6a342 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 53aacc8..d645725 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -26,9 +26,12 @@ public class SwaggerDescription { public static final String NO_CONTENT_CODE = "204"; public static final String PARTIAL_CONTENT_CODE = "206"; public static final String BAD_REQUEST_CODE = "400"; + public static final String UNAUTHORIZED_CODE = "401"; + public static final String FORBIDDEN_CODE = "403"; public static final String NOT_FOUND_CODE = "404"; public static final String CONFLICT_CODE = "409"; public static final String SERVICE_UNAVAILABLE_CODE = "503"; + public static final String INTERNAL_SERVER_ERROR_CODE = "500"; /// Entity Template API endpoint constants public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; @@ -78,11 +81,15 @@ public class SwaggerDescription { public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; + public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; public static final String RESPONSE_ENTITY_FOUND = "Entity found"; public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; + public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; + public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; // --- Schema (class) descriptions --- @@ -95,6 +102,8 @@ public class SwaggerDescription { public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; + public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; + public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; // --- Field descriptions (shared) --- public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; @@ -104,6 +113,13 @@ public class SwaggerDescription { public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; + public static final String FIELD_ENTITY_NAME = "Name of the entity"; + public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; + public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; + public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; + public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; + public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; + public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; public static final String FIELD_PROPERTY_NAME = "Property name"; public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; 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..ad37b94 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 @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.BAD_REQUEST_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CONFLICT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CREATED_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_SUMMARY; @@ -8,20 +9,29 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PAGE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SIZE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SORT_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITIES_PAGINATED_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CONFLICT; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CREATED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_FOUND; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INSUFFICIENT_RIGHTS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_ENTITY_DATA; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_PAGINATION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNAUTHORIZED; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; +import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -35,7 +45,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.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; @@ -43,7 +53,6 @@ import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoInMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -51,7 +60,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.AllArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; /// REST API adapter providing entity management endpoints. /// @@ -77,14 +88,14 @@ public class EntityController { /// Supports standard REST pagination parameters and returns appropriate HTTP status codes. /// Template validation is handled by the domain service layer. /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control /// @param templateIdentifier template filter for entity scope limitation /// @return paginated entity DTOs optimized for API consumers @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @@ -105,13 +116,13 @@ public Page getEntities( /// Returns HTTP 404 if either template or entity doesn't exist, maintaining REST semantics. /// /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context + /// @param entityIdentifier unique business identifier within template context /// @return entity DTO with full property and relationship data @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut getEntity( @@ -128,16 +139,22 @@ public EntityDtoOut getEntity( /// and returns HTTP 201 on success, HTTP 400 for validation errors. /// /// @param templateIdentifier target template identifier for entity creation context - /// @param entityDtoIn entity creation payload with properties and relationships + /// @param entityDtoIn entity creation payload with properties and relationships /// @return created entity DTO with server-generated identifiers @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) @PostMapping("/{templateIdentifier}") @ResponseStatus(CREATED) - public EntityDtoOut createEntity(@PathVariable String templateIdentifier, @RequestBody EntityDtoIn entityDtoIn) { + public EntityDtoOut createEntity( + @NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityDtoIn entityDtoIn) { + Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); Entity savedEntity = entityService.createEntity(entity); return entityDtoOutMapper.fromEntity(savedEntity); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 33bd356..0531655 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -1,34 +1,79 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_PROPERTIES; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATIONS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_TARGETS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_IN; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_RELATION_IN; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; + import java.util.List; import java.util.Map; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +/// Input DTO for creating a new entity within a template scope. +/// +/// **Infrastructure validation:** Performs syntactic validation at the API boundary +/// using Jakarta Validation annotations. Semantic validation (schema conformance +/// against template definitions) is handled by the domain service layer. @Data +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = SCHEMA_ENTITY_IN) public class EntityDtoIn { + + @NotBlank(message = ENTITY_NAME_MANDATORY) + @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") private String name; + + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") private String identifier; + + @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") private Map properties; + + @Valid + @Schema(description = FIELD_ENTITY_RELATIONS) private List relations; + /// Input DTO for an entity relation instance. + /// + /// **Infrastructure validation:** Validates relation name presence and target + /// identifiers at the API boundary before domain-level schema checks. @Data + @Builder @NoArgsConstructor @AllArgsConstructor - - @Builder @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = SCHEMA_ENTITY_RELATION_IN) public static class RelationDtoIn { + + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) + @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") private String name; + + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) + @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") private List targetEntityIdentifiers; } } 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 70ca673..5c4aed8 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,15 +15,18 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; 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; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -31,7 +34,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.servlet.NoHandlerFoundException; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -181,6 +183,40 @@ public ResponseEntity handleRelationCannotTargetItselfException( return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } + /// Handles validation exceptions from Spring MVC handler method parameters. + /// + /// **Error aggregation:** Combines multiple validation error messages into a single + /// user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + log.warn("Handler method validation error: {}", ex.getMessage()); + String errorMessage = ex.getAllErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities already exist. + /// + /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 + /// status indicating business rule conflict for duplicate entities. + @ExceptionHandler(EntityAlreadyExistsException.class) + public ResponseEntity handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { + log.warn("Entity already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity validation fails. + /// + /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status with aggregated + /// validation error messages for client correction. + @ExceptionHandler(EntityValidationException.class) + public ResponseEntity handleEntityValidationException(EntityValidationException ex) { + log.warn("Entity validation failed: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles Bean Validation constraint violations from domain model validation. /// /// **Error aggregation:** Combines multiple constraint violation messages into @@ -232,12 +268,6 @@ public ResponseEntity handleEntityNotFoundException(EntityNotFoun ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); return ResponseEntity.status(NOT_FOUND).body(errorResponse); } - - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity handleNotFound(NoHandlerFoundException e) { - return createErrorResponse(NOT_FOUND, "Resource not found: " + e.getRequestURL()); - } - private String parseHttpMessageNotReadableError(String originalMessage) { if (originalMessage == null) { return "Invalid request body format"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index a5e6b8f..1f6ad3a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; +import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -11,8 +12,6 @@ import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting API request DTOs to domain [Entity] objects. /// /// **Infrastructure mapping responsibilities:** @@ -28,38 +27,43 @@ /// /// **API contract support:** Enables clean separation between API request format /// and internal domain model structure for maintainable API evolution. - @Component @AllArgsConstructor public class EntityDtoInMapper { + + /// Converts an entity creation request DTO to a domain entity. + /// + /// @param entityDtoIn the entity creation request payload + /// @param entityTemplateIdentifier the target template identifier + /// @return the mapped domain entity with audit fields populated public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemplateIdentifier) { List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> { - String value; - if (entry.getValue() != null) { - value = String.valueOf(entry.getValue()); - } else { - value = null; - } - return new Property( - null, - entry.getKey(), - value - ); - }) - .toList(); + .map((Map.Entry entry) -> { + String value; + if (entry.getValue() != null) { + value = String.valueOf(entry.getValue()); + } else { + value = null; + } + return new Property( + null, + entry.getKey(), + value + ); + }) + .toList(); List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() : entityDtoIn.getRelations().stream() - .map(relDto -> new Relation( - null, - relDto.getName(), - null, // targetTemplateIdentifier not available in DTO - relDto.getTargetEntityIdentifiers() - )) - .toList(); + .map(relDto -> new Relation( + null, + relDto.getName(), + null, + relDto.getTargetEntityIdentifiers() + )) + .toList(); return new Entity( null, @@ -70,5 +74,4 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp relations ); } - } 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 2bd2b04..2600f54 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,8 +20,8 @@ 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.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity.EntityService; 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/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 680c482..6977e85 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -5,6 +5,7 @@ import java.util.Optional; import java.util.UUID; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -15,8 +16,6 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; -import lombok.RequiredArgsConstructor; - @Component @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { @@ -41,8 +40,15 @@ public Optional findByTemplateIdentifierAndIdentifier(String templateIde } @Override - public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { - return jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable).map(mapper::toDomain); + public Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName) { + return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) + .map(mapper::toDomain); + } + + @Override + public Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return Optional.of(pageableEntity.map(mapper::toDomain)); } @Override diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 44e8ac9..97675e9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -27,6 +27,8 @@ public interface JpaEntityRepository extends JpaRepository findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); @Modifying(clearAutomatically = true, flushAutomatically = true) diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java new file mode 100644 index 0000000..a0a2d15 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -0,0 +1,158 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityService Tests") +class EntityServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityValidationService entityValidationService; + + @InjectMocks + private EntityService entityService; + + @Test + @DisplayName("Should return entities page by template identifier") + void shouldReturnEntitiesByTemplateIdentifier() { + var pageable = Pageable.ofSize(10); + var entity = entity("template-a", "entity-a", "Entity A"); + var page = new PageImpl<>(List.of(entity)); + + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(Optional.of(page)); + + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); + + assertSame(page, result); + verify(entityRepository).findByTemplateIdentifier("template-a", pageable); + } + + @Test + @DisplayName("Should throw when template has no entities page") + void shouldThrowWhenTemplatePageNotFound() { + var pageable = Pageable.ofSize(10); + when(entityRepository.findByTemplateIdentifier("missing-template", pageable)).thenReturn(Optional.empty()); + + assertThrows(EntityTemplateNotFoundException.class, + () -> entityService.getEntitiesByTemplateIdentifier(pageable, "missing-template")); + } + + @Test + @DisplayName("Should return entity summaries by identifiers") + void shouldReturnEntitySummariesByIdentifiers() { + var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); + when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); + + var result = entityService.getEntitiesSummariesByIndentifiers(List.of("service-a")); + + assertEquals(summaries, result); + verify(entityRepository).findByIdentifierIn(List.of("service-a")); + } + + @Test + @DisplayName("Should return entity by template and identifier") + void shouldReturnEntityByTemplateAndIdentifier() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + var result = entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "catalog-api"); + + assertSame(entity, result); + verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + } + + @Test + @DisplayName("Should throw when entity is not found for template") + void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "missing-entity")); + } + + @Test + @DisplayName("Should create entity when validations pass") + void shouldCreateEntityWhenValidationsPass() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.save(entity)).thenReturn(entity); + + var result = entityService.createEntity(entity); + + assertSame(entity, result); + + InOrder inOrder = inOrder(entityValidationService, entityRepository); + inOrder.verify(entityValidationService).checkTemplateExist("web-service"); + inOrder.verify(entityValidationService).checkEntityAlreadyExist(entity); + inOrder.verify(entityValidationService).validateEntity(entity); + inOrder.verify(entityRepository).save(entity); + } + + @Test + @DisplayName("Should not save when entity already exists") + void shouldNotSaveWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); + + org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkEntityAlreadyExist(entity); + + assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); + + verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityValidationService).checkEntityAlreadyExist(entity); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should stop immediately when template does not exist") + void shouldStopWhenTemplateDoesNotExistOnCreate() { + var entity = entity("missing-template", "catalog-api", "Catalog API"); + var templateNotFound = new EntityTemplateNotFoundException("identifier", "missing-template"); + + org.mockito.Mockito.doThrow(templateNotFound) + .when(entityValidationService) + .checkTemplateExist("missing-template"); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); + + verify(entityValidationService).checkTemplateExist("missing-template"); + verifyNoInteractions(entityRepository); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java new file mode 100644 index 0000000..4cdd394 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -0,0 +1,235 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +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; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityValidationService Tests") +class EntityValidationServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityTemplateRepositoryPort entityTemplateRepository; + + @Mock + private PropertyValidationService propertyValidationService; + + @InjectMocks + private EntityValidationService entityValidationService; + + @Test + @DisplayName("Should pass checkTemplateExist when template exists") + void shouldPassCheckTemplateExistWhenTemplateExists() { + when(entityTemplateRepository.existsByIdentifier("web-service")).thenReturn(true); + + assertDoesNotThrow(() -> entityValidationService.checkTemplateExist("web-service")); + } + + @Test + @DisplayName("Should throw checkTemplateExist when template does not exist") + void shouldThrowCheckTemplateExistWhenTemplateDoesNotExist() { + when(entityTemplateRepository.existsByIdentifier("missing-template")).thenReturn(false); + + assertThrows(EntityTemplateNotFoundException.class, + () -> entityValidationService.checkTemplateExist("missing-template")); + } + + @Test + @DisplayName("Should throw when entity with same identifier already exists") + void shouldThrowWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkEntityAlreadyExist(entity)); + } + + @Test + @DisplayName("Should not query repository when identifier is null") + void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.checkEntityAlreadyExist(entity)); + + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("Should throw when template is missing during validateEntity") + void shouldThrowWhenTemplateMissingDuringValidateEntity() { + var entity = entity("missing-template", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityValidationService.validateEntity(entity)); + } + + @Test + @DisplayName("Should aggregate entity, property, relation, required and rule violations") + void shouldAggregateAllViolationsDuringValidateEntity() { + var portDefinition = new PropertyDefinition( + UUID.randomUUID(), + "port", + "Port", + PropertyType.NUMBER, + true, + new PropertyRules(null, null, null, null, null, null, 65535, 1024)); + + var requiredDefinition = new PropertyDefinition( + UUID.randomUUID(), + "ownerEmail", + "Owner email", + PropertyType.STRING, + true, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(requiredDefinition, portDefinition), + List.of()); + + var mockedRelation = org.mockito.Mockito.mock(Relation.class); + when(mockedRelation.name()).thenReturn(" "); + when(mockedRelation.targetEntityIdentifiers()).thenReturn(null); + + var entity = entity( + "web-service", + " ", + " ", + List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), + List.of(mockedRelation)); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); + + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); + + assertEquals(8, exception.getViolations().size()); + assertEquals(ENTITY_NAME_MANDATORY, exception.getViolations().get(0)); + assertEquals(ENTITY_IDENTIFIER_MANDATORY, exception.getViolations().get(1)); + assertEquals("Property[0]: " + PROPERTY_NAME_MANDATORY, exception.getViolations().get(2)); + assertEquals("Property[0]: " + PROPERTY_VALUE_MANDATORY, exception.getViolations().get(3)); + assertEquals("Relation[0]: " + RELATION_NAME_MANDATORY_SIMPLE, exception.getViolations().get(4)); + assertEquals("Relation[0]: " + RELATION_TARGET_IDENTIFIERS_NOT_NULL, exception.getViolations().get(5)); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); + assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); + + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + } + + @Test + @DisplayName("Should validate entity successfully when no violations") + void shouldValidateEntitySuccessfullyWhenNoViolations() { + var versionDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(versionDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), + null); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.empty()); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + } + + @Test + @DisplayName("Should skip property rule validation for missing optional property") + void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { + var optionalDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(optionalDefinition), + List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + verifyNoInteractions(propertyValidationService); + } + + private Entity entity( + String templateIdentifier, + String identifier, + String name, + List properties, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, relations); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java new file mode 100644 index 0000000..f8416e6 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -0,0 +1,80 @@ +package com.decathlon.idp_core.domain.service.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.constant.ValidationMessages; +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("PropertyValidationService Tests") +class PropertyValidationServiceTest { + + private final PropertyValidationService service = new PropertyValidationService(); + + @Test + @DisplayName("Should report type mismatch for non numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "not-a-number"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + + @Test + @DisplayName("Should report string constraint violations") + void shouldReportStringRuleViolations() { + var definition = propertyDefinition("name", PropertyType.STRING, new PropertyRules( + null, + PropertyFormat.EMAIL, + List.of("prod", "dev"), + "^[a-z]+$", + 5, + 3, + null, + null)); + + var violations = service.validatePropertyValue(definition, "AA"); + + assertEquals(4, violations.size()); + } + + @Test + @DisplayName("Should report number bound violations") + void shouldReportNumberBoundViolations() { + var definition = propertyDefinition("size", PropertyType.NUMBER, new PropertyRules( + null, + null, + null, + null, + null, + null, + 10, + 5)); + + var violations = service.validatePropertyValue(definition, "3"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + } + + @Test + @DisplayName("Should accept valid boolean value") + void shouldAcceptBooleanValues() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "true"); + + assertEquals(List.of(), violations); + } + + private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { + return new PropertyDefinition(null, name, "description", type, true, rules); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 9675a33..ccee22d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -17,21 +17,19 @@ import com.decathlon.idp_core.AbstractIntegrationTest; - /// Integration tests for the EntityController REST API endpoints. - /// These tests verify the behavior of entity retrieval endpoints, including - /// pagination, authentication, and lookup by template identifier and entity - /// identifier. +/// Integration tests for the EntityController REST API endpoints. +/// These tests verify the behavior of entity retrieval endpoints, including +/// pagination, authentication, and lookup by template identifier and entity +/// identifier. public class EntityControllerTest extends AbstractIntegrationTest { - @Autowired - private MockMvc mockMvc; - private static final String TEMPLATE_IDENTIFIER = "web-service"; private static final String ENTITY_IDENTIFIER = "web-api-2"; private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; - + @Autowired + private MockMvc mockMvc; /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated /// retrieval). @@ -44,9 +42,9 @@ class GetEntitiesByTemplateIdentifierTests { @WithMockUser void getEntities_paginated_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("page", "0") - .param("size", "15") - .accept(APPLICATION_JSON)) + .param("page", "0") + .param("size", "15") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -63,7 +61,7 @@ void getEntities_paginated_200() throws Exception { @WithMockUser void getEntities_paginated_404_when_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -71,7 +69,7 @@ void getEntities_paginated_404_when_non_existent_template() throws Exception { @DisplayName("Should return 401 without authentication") void getTemplates_paginated_401_without_user_token() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isUnauthorized()); } @@ -81,10 +79,10 @@ void getTemplates_paginated_401_without_user_token() throws Exception { void getEntities_paginated_200_custom() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) + .param("page", "1") + .param("size", "5") + .param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content.length()").value(1)) @@ -100,7 +98,7 @@ void getEntities_paginated_200_custom() throws Exception { @WithMockUser void getEntities_invalid_pagination_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -124,7 +122,7 @@ class GetEntitiesByTemplateAndEntityIdentifierTests { @WithMockUser void getEntityByTemplateAndIdentifier_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) @@ -136,7 +134,7 @@ void getEntityByTemplateAndIdentifier_200() throws Exception { @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -145,7 +143,7 @@ void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } } @@ -159,14 +157,108 @@ class PostEntitiesTests { @DisplayName("Should create entity and return 201") void postEntity_201() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) .andExpect(status().isCreated()) .andReturn(); } + @Test + @WithMockUser() + @DisplayName("Should return 400 when required template properties are missing") + void postEntity_400_when_required_properties_missing() throws Exception { + var payload = """ + { + "name": "web-service-missing-required", + "identifier": "web-service-missing-required", + "properties": { + "port": "8080" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property type does not match template") + void postEntity_400_when_property_type_mismatch() throws Exception { + var payload = """ + { + "name": "web-service-invalid-type", + "identifier": "web-service-invalid-type", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "not-a-number", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property rules are not respected") + void postEntity_400_when_property_rules_not_respected() throws Exception { + var payload = """ + { + "name": "web-service-invalid-rules", + "identifier": "web-service-invalid-rules", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "invalid-email", + "port": "80", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "invalid-url", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match required format EMAIL"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match required format URL"), + org.hamcrest.Matchers.containsString("Property 'port' value must be greater than or equal to 1024") + ))); + } + } } 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 f8a5a02..c6b2a23 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 @@ -311,10 +311,6 @@ void postTemplate_400_name_invalid_pattern() throws Exception { /// This test verifies that: /// - Validation error message indicates property definitions are /// @throws Exception if the MockMvc request fails - /// Tests the POST /api/v1/entity-templates endpoint when property name field is - /// missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @DisplayName("Returns 400 when property name is missing") 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 880ade3..985735e 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,10 +25,11 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; 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; import jakarta.validation.ConstraintViolationException; @@ -105,6 +106,45 @@ void shouldHandleEntityTemplateAlreadyExistsException() { assertEquals(HttpStatus.CONFLICT.name(), body.getError()); assertEquals(expectedMessage, body.getErrorDescription()); } + + /// Tests the handling of [EntityAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the original domain exception message + @Test + @DisplayName("Should handle EntityAlreadyExistsException with 409 status") + void shouldHandleEntityAlreadyExistsException() { + // Given + EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", "api-gateway"); + + // When + ResponseEntity response = exceptionHandler.handleEntityAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle EntityValidationException with 400 status") + void shouldHandleEntityValidationException() { + EntityValidationException exception = new EntityValidationException(java.util.List.of("Invalid property")); + + ResponseEntity response = exceptionHandler.handleEntityValidationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } } @Nested @@ -226,6 +266,29 @@ private ConstraintViolation createMockConstraintViolation(String message @DisplayName("HTTP Message Exception Handling") class HttpMessageExceptionTests { + /// Provides test data for [HttpMessageNotReadableException] scenarios. + /// Each argument contains: input message and expected error description. + static Stream httpMessageNotReadableExceptionTestData() { + return Stream.of( + Arguments.of( + "Required request body is missing: public ResponseEntity", + "Request body is required" + ), + Arguments.of( + "JSON parse error: Unexpected character", + "Invalid JSON format in request body" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_TYPE' for property 'type'" + ), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body" + ) + ); + } + /// Tests the handling of [HttpMessageNotReadableException] when exception message is null. /// /// **This test verifies that:** @@ -252,29 +315,6 @@ void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { assertEquals("Invalid request body format", body.getErrorDescription()); } - /// Provides test data for [HttpMessageNotReadableException] scenarios. - /// Each argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of( - "Required request body is missing: public ResponseEntity", - "Request body is required" - ), - Arguments.of( - "JSON parse error: Unexpected character", - "Invalid JSON format in request body" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'" - ), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body" - ) - ); - } - /// Parameterized test for handling [HttpMessageNotReadableException] with various error scenarios. /// /// **This test verifies that different types of HttpMessageNotReadableException are properly @@ -290,7 +330,7 @@ static Stream httpMessageNotReadableExceptionTestData() { /// - User-friendly error description is provided /// - Error response structure is consistent /// - /// @param originalMessage the original exception message to be processed + /// @param originalMessage the original exception message to be processed /// @param expectedErrorDescription the expected user-friendly error description @ParameterizedTest @MethodSource("httpMessageNotReadableExceptionTestData") diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json new file mode 100644 index 0000000..4c00a50 --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json @@ -0,0 +1,6 @@ +{ + "identifier": "temp-test-0", + "description": "This is a test template", + "properties_definitions": [], + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json new file mode 100644 index 0000000..996e560 --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json @@ -0,0 +1,6 @@ +{ + "identifier": "web-service", + "name": "web-service", + "description": "This is a test template", + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json index 82367a2..3593858 100644 --- a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json @@ -1,9 +1,15 @@ { - "name": "microservice-2", - "identifier": "microservice-2", + "name": "web-service-valid-1", + "identifier": "web-service-valid-1", "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", "port": "8080", - "environment": "dev" - }, - "relations": [] + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } } diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json new file mode 100644 index 0000000..678a6bf --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json @@ -0,0 +1,4 @@ +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json new file mode 100644 index 0000000..8643862 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json @@ -0,0 +1,14 @@ +{ + "name": "microservice-with-relations", + "identifier": "microservice-with-relations", + "properties": { + "port": "9090", + "environment": "staging" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": ["web-api-1"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json new file mode 100644 index 0000000..20e6fe2 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json @@ -0,0 +1,7 @@ +{ + "name": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json new file mode 100644 index 0000000..7c0b057 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json @@ -0,0 +1,7 @@ +{ + "identifier": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json new file mode 100644 index 0000000..570184e --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json @@ -0,0 +1,8 @@ +{ + "name": "entity-prop-no-value", + "identifier": "entity-prop-no-value", + "properties": { + "applicationName": "" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json new file mode 100644 index 0000000..9bb5cbc --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json @@ -0,0 +1,11 @@ +{ + "name": "entity-rel-no-name", + "identifier": "entity-rel-no-name", + "properties": {}, + "relations": [ + { + "name": "", + "target_entity_identifiers": ["some-target"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json new file mode 100644 index 0000000..e850f2b --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json @@ -0,0 +1,6 @@ +{ + "name": "Web API 1 duplicate", + "identifier": "web-api-1", + "properties": {}, + "relations": [] +} From 54972b1a2a4a6e9f4c4f2baba925e981a1f683ee Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 09:23:01 +0200 Subject: [PATCH 16/51] feat(core): fix sonar issue and copilot review --- .../service/property/PropertyValidationService.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index 983e1e3..e769e0d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -12,6 +12,8 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import org.springframework.stereotype.Service; @@ -30,6 +32,10 @@ public class PropertyValidationService { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + /// Cache of compiled regex patterns keyed by their source string. + /// Avoids recompiling the same pattern on every property validation call. + private final Map patternCache = new ConcurrentHashMap<>(); + /** * Validates a concrete property value against its property definition. * @@ -62,7 +68,8 @@ private List validateStringPropertyValue(String propertyName, String raw if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); } - if (rules.regex() != null && !Pattern.matches(rules.regex(), rawValue)) { + if (rules.regex() != null + && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(rawValue).matches()) { violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); } if (rules.enumValues() != null && !rules.enumValues().isEmpty() @@ -80,7 +87,7 @@ private List validateNumberPropertyValue(String propertyName, String raw final BigDecimal parsedValue; try { parsedValue = new BigDecimal(rawValue); - } catch (RuntimeException exception) { + } catch (NumberFormatException _) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); } From ad2fbd103de4b0b6c09d2dde9f8083fcbc67cbc6 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 09:47:05 +0200 Subject: [PATCH 17/51] feat(core): fix sonar review --- .../adapters/api/configuration/SecurityConfiguration.java | 1 - .../infrastructure/adapters/api/controller/EntityController.java | 1 - 2 files changed, 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index cf6a342..9242ef9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -5,7 +5,6 @@ import java.util.List; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; 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 ad37b94..c221534 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 @@ -62,7 +62,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; /// REST API adapter providing entity management endpoints. /// From 63b1970416cee1942e98e889d1e7312ffbcd36a6 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 11:44:56 +0200 Subject: [PATCH 18/51] feat(core): fix sonar qube and test --- .../model/entity/EntityJpaEntity.java | 4 +- ..._entity_identifier_unique_to_composite.sql | 9 + .../PropertyValidationServiceTest.java | 354 +++++++++++++++--- .../api/handler/ApiExceptionHandlerTest.java | 67 ++++ 4 files changed, 388 insertions(+), 46 deletions(-) create mode 100644 src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index cd3f143..848693d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -21,7 +21,9 @@ @jakarta.persistence.Entity @Data -@Table(name = "entity") +@Table(name = "entity", uniqueConstraints = { + @UniqueConstraint(columnNames = {"identifier", "template_identifier"}) +}) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql b/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql new file mode 100644 index 0000000..11255aa --- /dev/null +++ b/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql @@ -0,0 +1,9 @@ +-- Change unique constraint on entity table: +-- Drop the unique constraint on identifier alone +-- Add a composite unique constraint on (identifier, template_identifier) +-- This allows the same identifier to exist across different templates + +ALTER TABLE entity DROP CONSTRAINT entity_identifier_key; + +ALTER TABLE entity ADD CONSTRAINT entity_identifier_template_identifier_key + UNIQUE (identifier, template_identifier); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index f8416e6..3f35fc0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -5,6 +5,7 @@ import java.util.List; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import com.decathlon.idp_core.domain.constant.ValidationMessages; @@ -18,60 +19,323 @@ class PropertyValidationServiceTest { private final PropertyValidationService service = new PropertyValidationService(); - @Test - @DisplayName("Should report type mismatch for non numeric NUMBER value") - void shouldReportTypeMismatchWhenNumberValueIsInvalid() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); + @Nested + @DisplayName("STRING validation") + class StringValidationTests { - var violations = service.validatePropertyValue(definition, "not-a-number"); + @Test + @DisplayName("Should report type mismatch when STRING value is null") + void shouldReportTypeMismatchWhenStringValueIsNull() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + var violations = service.validatePropertyValue(definition, null); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } + + @Test + @DisplayName("Should return no violations when STRING has no rules") + void shouldReturnNoViolationsWhenStringHasNoRules() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, "hello"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when STRING value satisfies all rules") + void shouldReturnNoViolationsWhenStringPassesAllRules() { + var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); + var definition = propertyDefinition("env", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "dev"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minLength violation") + void shouldReportMinLengthViolation() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "ab"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report maxLength violation") + void shouldReportMaxLengthViolation() { + var rules = new PropertyRules(null, null, null, null, 5, null, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "too-long-value"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report regex violation") + void shouldReportRegexViolation() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "abc"); + + assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); + } + + @Test + @DisplayName("Should accept value matching regex") + void shouldAcceptValueMatchingRegex() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "12345"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report enum violation when value not in allowed list") + void shouldReportEnumViolation() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "UNKNOWN"); + + assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); + } + + @Test + @DisplayName("Should accept enum value with case-insensitive match") + void shouldAcceptEnumValueCaseInsensitive() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "active"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should skip enum check when enumValues is empty") + void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { + var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "anything"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid EMAIL") + void shouldReportFormatViolationForInvalidEmail() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-an-email"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); + } + + @Test + @DisplayName("Should accept valid EMAIL format") + void shouldAcceptValidEmailFormat() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "user@example.com"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid URL") + void shouldReportFormatViolationForInvalidUrl() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-a-url"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); + } + + @Test + @DisplayName("Should accept valid URL format") + void shouldAcceptValidUrlFormat() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); - @Test - @DisplayName("Should report string constraint violations") - void shouldReportStringRuleViolations() { - var definition = propertyDefinition("name", PropertyType.STRING, new PropertyRules( - null, - PropertyFormat.EMAIL, - List.of("prod", "dev"), - "^[a-z]+$", - 5, - 3, - null, - null)); - - var violations = service.validatePropertyValue(definition, "AA"); - - assertEquals(4, violations.size()); + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report multiple violations at once") + void shouldReportMultipleStringViolations() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "AA"); + + assertEquals(4, violations.size()); + } + + @Test + @DisplayName("Should use cached pattern for repeated regex validations") + void shouldUseCachedPatternForRepeatedRegex() { + var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + // Validate twice with the same regex to exercise the cache + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); + + assertEquals(List.of(), violations1); + assertEquals(List.of(), violations2); + } } - @Test - @DisplayName("Should report number bound violations") - void shouldReportNumberBoundViolations() { - var definition = propertyDefinition("size", PropertyType.NUMBER, new PropertyRules( - null, - null, - null, - null, - null, - null, - 10, - 5)); - - var violations = service.validatePropertyValue(definition, "3"); - - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + @Nested + @DisplayName("NUMBER validation") + class NumberValidationTests { + + @Test + @DisplayName("Should report type mismatch for non-numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "not-a-number"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER has no rules") + void shouldReturnNoViolationsWhenNumberHasNoRules() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "42"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER is within bounds") + void shouldReturnNoViolationsWhenNumberIsWithinBounds() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("score", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "50"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minValue violation") + void shouldReportMinValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "3"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + } + + @Test + @DisplayName("Should report maxValue violation") + void shouldReportMaxValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "15"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); + } + + @Test + @DisplayName("Should report both minValue and maxValue violations") + void shouldReportBothMinAndMaxViolations() { + // minValue > maxValue edge case — value below min triggers min violation + var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); + var definition = propertyDefinition("range", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "7"); + + // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation + assertEquals(2, violations.size()); + } + + @Test + @DisplayName("Should accept decimal number values") + void shouldAcceptDecimalNumberValues() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "99.5"); + + assertEquals(List.of(), violations); + } } - @Test - @DisplayName("Should accept valid boolean value") - void shouldAcceptBooleanValues() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { + + @Test + @DisplayName("Should accept 'true' value") + void shouldAcceptTrueValue() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "true"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept 'false' value") + void shouldAcceptFalseValue() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "false"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept case-insensitive 'TRUE'") + void shouldAcceptUppercaseTrue() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "TRUE"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept case-insensitive 'FALSE'") + void shouldAcceptUppercaseFalse() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "FALSE"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "yes"); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); + } } private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { 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 985735e..10e9cce 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 @@ -26,8 +26,10 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.EntityValidationException; 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.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import jakarta.validation.ConstraintViolation; @@ -145,6 +147,55 @@ void shouldHandleEntityValidationException() { assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); assertEquals(exception.getMessage(), body.getErrorDescription()); } + + /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNameAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and description + @Test + @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateNameAlreadyExistsException() { + // Given + String name = "Duplicate Name"; + EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException(name); + + // When + ResponseEntity response = exceptionHandler.handleEntityTemplateNameAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityNotFoundException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the entity-specific context message + @Test + @DisplayName("Should handle EntityNotFoundException with 404 status") + void shouldHandleEntityNotFoundException() { + // Given + EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); + + // When + ResponseEntity response = exceptionHandler.handleEntityNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } } @Nested @@ -282,9 +333,25 @@ static Stream httpMessageNotReadableExceptionTestData() { "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", "Invalid value 'INVALID_TYPE' for property 'type'" ), + Arguments.of( + "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_FORMAT' for property 'format'" + ), Arguments.of( "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", "Invalid enum value in request body" + ), + Arguments.of( + "Cannot deserialize value of type `com.example.SomeType`: some other error", + "Cannot deserialize request body property" + ), + Arguments.of( + "Something completely unexpected happened", + "Invalid request body format" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", + "Invalid value for property 'type'" ) ); } From 13f71f3187624ff6d9798de0117e4b4a45c8a2dc Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 14:56:43 +0200 Subject: [PATCH 19/51] feat(core): fix validation type check --- .../domain/model/entity/Property.java | 11 +- .../entity/EntityValidationService.java | 2 +- .../property/PropertyValidationService.java | 41 +++++- .../api/mapper/entity/EntityDtoInMapper.java | 3 +- .../mapper/EntityPersistenceMapper.java | 2 + .../entity/EntityValidationServiceTest.java | 8 +- .../PropertyValidationServiceTest.java | 122 ++++++++++-------- 7 files changed, 126 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 4c15dcd..015d0fb 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -22,6 +22,9 @@ /// - Property values must satisfy all validation rules from [PropertyRules] /// - Required properties cannot have empty values /// - Property types must align with the template's [PropertyType] definition +/// +/// @param rawValue the original untyped value from the API input, used for type checking +/// during validation. May be null when loaded from persistence. public record Property( UUID id, @@ -29,6 +32,12 @@ public record Property( String name, @NotBlank(message = PROPERTY_VALUE_MANDATORY) - String value + String value, + + Object rawValue ) { + /// Convenience constructor for persistence and test scenarios where raw value is not needed. + public Property(UUID id, String name, String value) { + this(id, name, value, null); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index f535e7d..7b7c18c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -133,7 +133,7 @@ private void validateAgainstTemplate(EntityTemplate template, } propertyValidationService - .validatePropertyValue(definition, property.value()) + .validatePropertyValue(definition, property.value(), property.rawValue()) .forEach(violations::add); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index e769e0d..d2ba01c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -38,12 +38,20 @@ public class PropertyValidationService { /** * Validates a concrete property value against its property definition. + * Type compatibility is checked first against the original raw value + * before applying any rule-based validations. * * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value + * @param rawValue raw property value as string + * @param originalValue the original untyped value from the API input for type checking, + * may be null when loaded from persistence * @return list of violations for this value; empty when valid */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue) { + public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue, Object originalValue) { + List typeMismatch = checkOriginalValueType(propertyDefinition.name(), propertyDefinition.type(), originalValue); + if (!typeMismatch.isEmpty()) { + return typeMismatch; + } return switch (propertyDefinition.type()) { case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); @@ -51,6 +59,35 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, }; } + /// Checks that the original JSON value type is compatible with the expected [PropertyType]. + /// + /// When `originalValue` is non-null, its Java type is inspected: + /// - STRING expects a Java `String` + /// - NUMBER expects a Java `Number` + /// - BOOLEAN expects a Java `Boolean` + /// + /// If `originalValue` is null (e.g. loaded from persistence), the check is skipped + /// and type validation falls through to the string-based validators. + /// + /// @param propertyName property name for error reporting + /// @param expectedType the expected property type from the template definition + /// @param originalValue the original untyped value from the API input + /// @return a single-element list with a type mismatch message, or an empty list if compatible + private List checkOriginalValueType(String propertyName, PropertyType expectedType, Object originalValue) { + if (originalValue == null) { + return List.of(); + } + boolean compatible = switch (expectedType) { + case STRING -> originalValue instanceof String; + case NUMBER -> originalValue instanceof Number || originalValue instanceof String; + case BOOLEAN -> originalValue instanceof Boolean || originalValue instanceof String; + }; + if (!compatible) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, expectedType)); + } + return List.of(); + } + private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { if (rawValue == null) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 1f6ad3a..7bc0a59 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -50,7 +50,8 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp return new Property( null, entry.getKey(), - value + value, + entry.getValue() ); }) .toList(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 120fd65..c22ffbb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -17,6 +18,7 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); + @Mapping(target = "rawValue", ignore = true) Property toDomain(PropertyJpaEntity jpa); PropertyJpaEntity toJpa(Property domain); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 4cdd394..02c7116 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -142,7 +142,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + when(propertyValidationService.validatePropertyValue(portDefinition, "80", null)) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); @@ -157,7 +157,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); - verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + verify(propertyValidationService).validatePropertyValue(portDefinition, "80", null); } @Test @@ -189,10 +189,10 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0", null)).thenReturn(List.of()); assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0", null); } @Test diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 3f35fc0..2ffdef8 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -7,6 +7,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.constant.ValidationMessages; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; @@ -28,7 +30,7 @@ class StringValidationTests { void shouldReportTypeMismatchWhenStringValueIsNull() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null); + var violations = service.validatePropertyValue(definition, null, null); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); } @@ -38,7 +40,7 @@ void shouldReportTypeMismatchWhenStringValueIsNull() { void shouldReturnNoViolationsWhenStringHasNoRules() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello"); + var violations = service.validatePropertyValue(definition, "hello", "hello"); assertEquals(List.of(), violations); } @@ -49,7 +51,7 @@ void shouldReturnNoViolationsWhenStringPassesAllRules() { var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev"); + var violations = service.validatePropertyValue(definition, "dev", "dev"); assertEquals(List.of(), violations); } @@ -60,7 +62,7 @@ void shouldReportMinLengthViolation() { var rules = new PropertyRules(null, null, null, null, null, 5, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab"); + var violations = service.validatePropertyValue(definition, "ab", "ab"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -71,7 +73,7 @@ void shouldReportMaxLengthViolation() { var rules = new PropertyRules(null, null, null, null, 5, null, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value", "too-long-value"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -82,7 +84,7 @@ void shouldReportRegexViolation() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc"); + var violations = service.validatePropertyValue(definition, "abc", "abc"); assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); } @@ -93,7 +95,7 @@ void shouldAcceptValueMatchingRegex() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345"); + var violations = service.validatePropertyValue(definition, "12345", "12345"); assertEquals(List.of(), violations); } @@ -104,7 +106,7 @@ void shouldReportEnumViolation() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN", "UNKNOWN"); assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); } @@ -115,7 +117,7 @@ void shouldAcceptEnumValueCaseInsensitive() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active"); + var violations = service.validatePropertyValue(definition, "active", "active"); assertEquals(List.of(), violations); } @@ -126,7 +128,7 @@ void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything"); + var violations = service.validatePropertyValue(definition, "anything", "anything"); assertEquals(List.of(), violations); } @@ -137,7 +139,7 @@ void shouldReportFormatViolationForInvalidEmail() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email", "not-an-email"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); } @@ -148,7 +150,7 @@ void shouldAcceptValidEmailFormat() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com", "user@example.com"); assertEquals(List.of(), violations); } @@ -159,7 +161,7 @@ void shouldReportFormatViolationForInvalidUrl() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url", "not-a-url"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); } @@ -170,7 +172,7 @@ void shouldAcceptValidUrlFormat() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo", "https://github.com/org/repo"); assertEquals(List.of(), violations); } @@ -181,7 +183,7 @@ void shouldReportMultipleStringViolations() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA"); + var violations = service.validatePropertyValue(definition, "AA", "AA"); assertEquals(4, violations.size()); } @@ -193,12 +195,33 @@ void shouldUseCachedPatternForRepeatedRegex() { var definition = propertyDefinition("code", PropertyType.STRING, rules); // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc"); - var violations2 = service.validatePropertyValue(definition, "def"); + var violations1 = service.validatePropertyValue(definition, "abc", "abc"); + var violations2 = service.validatePropertyValue(definition, "def", "def"); assertEquals(List.of(), violations1); assertEquals(List.of(), violations2); } + + @Test + @DisplayName("Should report type mismatch when a number is sent for a STRING property") + void shouldReportTypeMismatchWhenNumberSentForString() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("label", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "12", 12); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } + + @Test + @DisplayName("Should report type mismatch when a boolean is sent for a STRING property") + void shouldReportTypeMismatchWhenBooleanSentForString() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, "true", true); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } } @Nested @@ -210,7 +233,7 @@ class NumberValidationTests { void shouldReportTypeMismatchWhenNumberValueIsInvalid() { var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number", "not-a-number"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); } @@ -220,7 +243,7 @@ void shouldReportTypeMismatchWhenNumberValueIsInvalid() { void shouldReturnNoViolationsWhenNumberHasNoRules() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42", 42); assertEquals(List.of(), violations); } @@ -231,7 +254,7 @@ void shouldReturnNoViolationsWhenNumberIsWithinBounds() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50"); + var violations = service.validatePropertyValue(definition, "50", 50); assertEquals(List.of(), violations); } @@ -242,7 +265,7 @@ void shouldReportMinValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3"); + var violations = service.validatePropertyValue(definition, "3", 3); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); } @@ -253,7 +276,7 @@ void shouldReportMaxValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15"); + var violations = service.validatePropertyValue(definition, "15", 15); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); } @@ -265,7 +288,7 @@ void shouldReportBothMinAndMaxViolations() { var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7"); + var violations = service.validatePropertyValue(definition, "7", 7); // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation assertEquals(2, violations.size()); @@ -277,62 +300,53 @@ void shouldAcceptDecimalNumberValues() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5"); + var violations = service.validatePropertyValue(definition, "99.5", 99.5); assertEquals(List.of(), violations); } - } - - @Nested - @DisplayName("BOOLEAN validation") - class BooleanValidationTests { @Test - @DisplayName("Should accept 'true' value") - void shouldAcceptTrueValue() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") + void shouldReportTypeMismatchWhenBooleanSentForNumber() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "true", true); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); } + } - @Test - @DisplayName("Should accept 'false' value") - void shouldAcceptFalseValue() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); - - var violations = service.validatePropertyValue(definition, "false"); - - assertEquals(List.of(), violations); - } + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { - @Test - @DisplayName("Should accept case-insensitive 'TRUE'") - void shouldAcceptUppercaseTrue() { + @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") + @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) + void shouldAcceptValidBooleanValues(String value) { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + Object originalValue = "true".equalsIgnoreCase(value) ? Boolean.TRUE : Boolean.FALSE; - var violations = service.validatePropertyValue(definition, "TRUE"); + var violations = service.validatePropertyValue(definition, value, originalValue); assertEquals(List.of(), violations); } @Test - @DisplayName("Should accept case-insensitive 'FALSE'") - void shouldAcceptUppercaseFalse() { + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "FALSE"); + var violations = service.validatePropertyValue(definition, "yes", "yes"); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } @Test - @DisplayName("Should report type mismatch for invalid boolean value") - void shouldReportTypeMismatchForInvalidBoolean() { + @DisplayName("Should report type mismatch when a number is sent for a BOOLEAN property") + void shouldReportTypeMismatchWhenNumberSentForBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes"); + var violations = service.validatePropertyValue(definition, "42", 42); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } From 2cb2a9e2cad86f7c731522977570f9c3a8f11028 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Tue, 5 May 2026 16:13:06 +0200 Subject: [PATCH 20/51] feat(core): fix review --- .../domain/constant/ValidationMessages.java | 30 +++++ .../EntityAlreadyExistsException.java | 13 +- .../{ => entity}/EntityNotFoundException.java | 2 +- .../EntityValidationException.java | 15 ++- ...pertyDefinitionRulesConflictException.java | 25 ++++ .../idp_core/domain/model/entity/Entity.java | 3 + .../domain/model/entity/Property.java | 19 +-- .../domain/service/entity/EntityService.java | 34 +++-- .../entity/EntityValidationService.java | 87 +++--------- .../EntityTemplateService.java | 2 +- .../PropertyDefinitionValidationService.java | 1 - .../property/PropertyValidationService.java | 84 +++++------- .../api/controller/EntityController.java | 6 +- .../api/handler/ApiExceptionHandler.java | 6 +- .../api/mapper/entity/EntityDtoInMapper.java | 23 +--- .../api/mapper/entity/EntityDtoOutMapper.java | 51 ++++--- .../mapper/EntityPersistenceMapper.java | 19 ++- .../service/entity/EntityServiceTest.java | 50 ++++--- .../entity/EntityValidationServiceTest.java | 124 ++++++++---------- .../PropertyValidationServiceTest.java | 78 ++++------- .../api/handler/ApiExceptionHandlerTest.java | 6 +- 21 files changed, 330 insertions(+), 348 deletions(-) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityAlreadyExistsException.java (51%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityNotFoundException.java (95%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityValidationException.java (50%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.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 e179064..6bb038f 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 @@ -34,6 +34,14 @@ public class ValidationMessages { public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; + // 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_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"; + // 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 template identifier is mandatory and cannot be blank"; @@ -82,4 +90,26 @@ public static String minMaxConstraintViolated(String constraint) { public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + + 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 + .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/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java similarity index 51% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java index bd76169..8243748 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java @@ -1,10 +1,19 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity.Entity; -/// Domain exception for duplicate [Entity] business entities within a template scope. +/// Domain exception for duplicate [Entity] business entities within the same template context. +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an Entity that already exist within a specific template context. +/// This enforces the business invariant that entities must be unique within their template context. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require unique entities within a template context +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity public class EntityAlreadyExistsException extends RuntimeException { /// Constructs a new exception with template and entity identifiers. diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java similarity index 95% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java index cc7d4a8..42c60f6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NOT_FOUND; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java similarity index 50% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index ca9da64..42756f0 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_VALIDATION_FAILED; @@ -7,6 +7,19 @@ import lombok.Getter; /// Domain exception for entity schema validation failures +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an entity, or update an entity, with property values that +/// do not conform to the validation rules defined in the entity's template. +/// This includes violations of required properties, type mismatches, and template rules +/// This enforces the business invariant that entities must conform to the validation +/// rules defined in their template's property definitions and relation constraints. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require valid property values +/// that conform to template rules +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity @Getter public class EntityValidationException extends RuntimeException { diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java new file mode 100644 index 0000000..f68a840 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.domain.exception.property; + +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. +/// +/// **Usage patterns:** +/// - Property template creation with invalid rules +/// - Property template updates introducing rule conflicts +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 PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + + ": " + violationMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 6250a5a..2b77241 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.UUID; +import org.springframework.validation.annotation.Validated; + import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import jakarta.validation.constraints.NotBlank; @@ -21,6 +23,7 @@ /// /// Ubiquitous language: An Entity is a materialized instance of a template schema, /// containing actual values that comply with the template's structure and rules. + public record Entity( UUID id, diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 015d0fb..7850124 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -1,7 +1,6 @@ package com.decathlon.idp_core.domain.model.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; import java.util.UUID; @@ -20,24 +19,16 @@ /// **Business invariants:** /// - Property names must match a [PropertyDefinition] name in the entity's template /// - Property values must satisfy all validation rules from [PropertyRules] -/// - Required properties cannot have empty values -/// - Property types must align with the template's [PropertyType] definition -/// -/// @param rawValue the original untyped value from the API input, used for type checking -/// during validation. May be null when loaded from persistence. +/// - Required properties cannot have null/blank values +/// - Property values must be typed according to the template's [PropertyType] definition +/// (carried as [Object] so the original JSON type — String, Number, Boolean — is preserved +/// for strict type-mismatch detection at validation time). public record Property( UUID id, @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - @NotBlank(message = PROPERTY_VALUE_MANDATORY) - String value, - - Object rawValue + Object value ) { - /// Convenience constructor for persistence and test scenarios where raw value is not needed. - public Property(UUID id, String name, String value) { - this(id, name, value, null); - } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 05839ba..d90231e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,18 +2,22 @@ import java.util.List; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -29,10 +33,13 @@ /// - Entity data integrity validation (entity, properties, relations) /// - Entity summary generation for efficient queries @Service -@AllArgsConstructor +@Validated +@RequiredArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; + private final EntityTemplateRepositoryPort entityTemplateRepository; private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; /// Retrieves entities filtered by template with existence validation. /// @@ -72,8 +79,8 @@ public List getEntitiesSummariesByIndentifiers(List ident /// @throws EntityTemplateNotFoundException when template doesn't exist /// @throws EntityNotFoundException when entity doesn't exist @Transactional - public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifier, String entityIdentifier) { - entityValidationService.checkTemplateExist(templateIdentifier); + public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); @@ -81,8 +88,10 @@ public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifie /// Creates and persists a new entity with business validation. /// - /// **Contract:** Validates template existence, entity identifier uniqueness within - /// the template scope, and entity/property/relation data integrity before persisting. + /// **Contract:** Resolves the referenced template (single round-trip — combined + /// existence check and fetch), enforces entity identifier uniqueness within the + /// template scope, then validates entity/property data integrity against the + /// resolved template before persisting. /// /// @param entity validated entity to create and persist /// @return the persisted entity with generated identifiers @@ -91,9 +100,10 @@ public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifie /// @throws EntityValidationException when entity, property, or relation data is invalid @Transactional public Entity createEntity(@Valid Entity entity) { - entityValidationService.checkTemplateExist(entity.templateIdentifier()); - entityValidationService.checkEntityAlreadyExist(entity); - entityValidationService.validateEntity(entity); + EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); + entityValidationService.checkUniqueness(entity); + entityValidationService.validateEntity(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 7b7c18c..4902016 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -1,12 +1,6 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import java.util.List; import java.util.Map; @@ -16,16 +10,13 @@ import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; -import com.decathlon.idp_core.domain.model.entity.Relation; 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.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; /// Domain validator for [Entity] aggregates. @@ -39,34 +30,21 @@ public class EntityValidationService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; private final PropertyValidationService propertyValidationService; - /// Check entity template existence to ensure valid template reference before deeper validations. - /// @param entity the entity whose template existence is to be checked - /// @throws EntityTemplateNotFoundException if the template referenced by the entity does not exist - void checkTemplateExist(final String entity) { - if (!entityTemplateRepository.existsByIdentifier(entity)) { - throw new EntityTemplateNotFoundException("identifier", entity); - } - } - - /// Validates intrinsic entity data integrity and template-driven rules. + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// **Contract:** the caller is responsible for resolving the [EntityTemplate] + /// (typically via [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) + /// and passing it in. This avoids a redundant database round-trip and clarifies + /// the dependency graph of the validation service. /// /// @param entity the entity to validate + /// @param template the already-resolved template the entity must conform to /// @throws EntityValidationException when one or more validation rules are violated /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - /// @throws EntityTemplateNotFoundException if the referenced template does not exist - void validateEntity(Entity entity) { - checkEntityAlreadyExist(entity); - EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); - + void validateEntity(Entity entity, EntityTemplate template) { Violations violations = new Violations(); - - validateEntityHeader(entity, violations); - validatePropertiesShape(entity.properties(), violations); - validateRelationsShape(entity.relations(), violations); validateAgainstTemplate(template, entity.properties(), violations); if (!violations.isEmpty()) { @@ -74,45 +52,10 @@ void validateEntity(Entity entity) { } } - private void validateEntityHeader(Entity entity, Violations violations) { - violations.addIfBlank(entity.name(), ENTITY_NAME_MANDATORY); - violations.addIfBlank(entity.identifier(), ENTITY_IDENTIFIER_MANDATORY); - } - - private void validatePropertiesShape(List properties, Violations violations) { - if (properties == null) { - return; - } - for (int i = 0; i < properties.size(); i++) { - Property prop = properties.get(i); - if (prop.name() == null || prop.name().isBlank()) { - violations.addIndexed("Property", i, PROPERTY_NAME_MANDATORY); - } - if (prop.value() == null || prop.value().isBlank()) { - violations.addIndexed("Property", i, PROPERTY_VALUE_MANDATORY); - } - } - } - - private void validateRelationsShape(List relations, Violations violations) { - if (relations == null) { - return; - } - for (int i = 0; i < relations.size(); i++) { - Relation rel = relations.get(i); - if (rel.name() == null || rel.name().isBlank()) { - violations.addIndexed("Relation", i, RELATION_NAME_MANDATORY_SIMPLE); - } - if (rel.targetEntityIdentifiers() == null) { - violations.addIndexed("Relation", i, RELATION_TARGET_IDENTIFIERS_NOT_NULL); - } - } - } - /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation /// @param properties the list of properties from the entity to validate - /// @param violations the accumulator for validation violation messages + /// @param violations the accumulator for validation v iolation messages private void validateAgainstTemplate(EntityTemplate template, List properties, Violations violations) { @@ -123,7 +66,9 @@ private void validateAgainstTemplate(EntityTemplate template, for (PropertyDefinition definition : definitions) { Property property = propertiesByName.get(definition.name()); - boolean missing = property == null || property.value() == null || property.value().isBlank(); + boolean missing = property == null + || property.value() == null + || (property.value() instanceof String s && s.isBlank()); if (missing) { if (definition.required()) { @@ -133,7 +78,7 @@ private void validateAgainstTemplate(EntityTemplate template, } propertyValidationService - .validatePropertyValue(definition, property.value(), property.rawValue()) + .validatePropertyValue(definition, property.value()) .forEach(violations::add); } } @@ -141,7 +86,7 @@ private void validateAgainstTemplate(EntityTemplate template, /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void checkEntityAlreadyExist(final Entity entity) { + void checkUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.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 8b2b677..3528e14 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 @@ -13,9 +13,9 @@ import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -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.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.model.entity_template.PropertyRules; 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 cd1d6c7..79647cc 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 @@ -15,7 +15,6 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; - 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; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index d2ba01c..604891c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -38,20 +38,16 @@ public class PropertyValidationService { /** * Validates a concrete property value against its property definition. - * Type compatibility is checked first against the original raw value - * before applying any rule-based validations. + * The value's runtime Java type is checked first against the expected + * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, + * BOOLEAN ⇒ {@link Boolean}). When the type matches, the value is + * normalized to a string and the type-specific rules are evaluated. * * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value as string - * @param originalValue the original untyped value from the API input for type checking, - * may be null when loaded from persistence + * @param rawValue raw property value preserving its original JSON type * @return list of violations for this value; empty when valid */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue, Object originalValue) { - List typeMismatch = checkOriginalValueType(propertyDefinition.name(), propertyDefinition.type(), originalValue); - if (!typeMismatch.isEmpty()) { - return typeMismatch; - } + public List validatePropertyValue(PropertyDefinition propertyDefinition, Object rawValue) { return switch (propertyDefinition.type()) { case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); @@ -59,37 +55,9 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, }; } - /// Checks that the original JSON value type is compatible with the expected [PropertyType]. - /// - /// When `originalValue` is non-null, its Java type is inspected: - /// - STRING expects a Java `String` - /// - NUMBER expects a Java `Number` - /// - BOOLEAN expects a Java `Boolean` - /// - /// If `originalValue` is null (e.g. loaded from persistence), the check is skipped - /// and type validation falls through to the string-based validators. - /// - /// @param propertyName property name for error reporting - /// @param expectedType the expected property type from the template definition - /// @param originalValue the original untyped value from the API input - /// @return a single-element list with a type mismatch message, or an empty list if compatible - private List checkOriginalValueType(String propertyName, PropertyType expectedType, Object originalValue) { - if (originalValue == null) { - return List.of(); - } - boolean compatible = switch (expectedType) { - case STRING -> originalValue instanceof String; - case NUMBER -> originalValue instanceof Number || originalValue instanceof String; - case BOOLEAN -> originalValue instanceof Boolean || originalValue instanceof String; - }; - if (!compatible) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, expectedType)); - } - return List.of(); - } - private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { - if (rawValue == null) { + private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + if (!(rawValue instanceof String stringValue)) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); } @@ -99,33 +67,41 @@ private List validateStringPropertyValue(String propertyName, String raw var violations = new ArrayList(); - if (rules.minLength() != null && rawValue.length() < rules.minLength()) { + if (rules.minLength() != null && stringValue.length() < rules.minLength()) { violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); } - if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { + if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); } if (rules.regex() != null - && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(rawValue).matches()) { + && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(stringValue).matches()) { violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); } if (rules.enumValues() != null && !rules.enumValues().isEmpty() - && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(rawValue))) { + && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); } - if (rules.format() != null && !matchesFormat(rules.format(), rawValue)) { + if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); } return List.copyOf(violations); } - private List validateNumberPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { final BigDecimal parsedValue; - try { - parsedValue = new BigDecimal(rawValue); - } catch (NumberFormatException _) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + switch (rawValue) { + case Number number -> parsedValue = new BigDecimal(number.toString()); + case String string -> { + try { + parsedValue = new BigDecimal(string); + } catch (NumberFormatException _) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + } + case null, default -> { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } } if (rules == null) { @@ -144,8 +120,12 @@ private List validateNumberPropertyValue(String propertyName, String raw return List.copyOf(violations); } - private List validateBooleanPropertyValue(String propertyName, String rawValue) { - if ("true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue)) { + private List validateBooleanPropertyValue(String propertyName, Object rawValue) { + if (rawValue instanceof Boolean) { + return List.of(); + } + if (rawValue instanceof String string + && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { return List.of(); } return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); 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 c221534..f9f8d90 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 @@ -31,7 +31,7 @@ import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -74,7 +74,7 @@ @RestController @RequestMapping("/api/v1/entities") @Tag(name = "Entities Management", description = "Operations related to entity management") -@AllArgsConstructor +@RequiredArgsConstructor public class EntityController { private final EntityService entityService; @@ -127,7 +127,7 @@ public Page getEntities( public EntityDtoOut getEntity( @PathVariable String templateIdentifier, @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAnIdentifier(templateIdentifier, entityIdentifier); + Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); return entityDtoOutMapper.fromEntity(entity); } 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 5c4aed8..f373c15 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 @@ -17,9 +17,9 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; 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; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 7bc0a59..45dc45a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.Map; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -28,7 +28,7 @@ /// **API contract support:** Enables clean separation between API request format /// and internal domain model structure for maintainable API evolution. @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoInMapper { /// Converts an entity creation request DTO to a domain entity. @@ -40,20 +40,11 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> { - String value; - if (entry.getValue() != null) { - value = String.valueOf(entry.getValue()); - } else { - value = null; - } - return new Property( - null, - entry.getKey(), - value, - entry.getValue() - ); - }) + .map((Map.Entry entry) -> new Property( + null, + entry.getKey(), + entry.getValue() + )) .toList(); List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() 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 2600f54..be15c3c 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 @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; @@ -21,13 +22,11 @@ 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.EntityTemplateService; -import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.domain.service.RelationService; +import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting domain [Entity] objects to API DTOs. /// /// **Infrastructure mapping responsibilities:** @@ -46,7 +45,7 @@ /// - Integrates with Jackson for JSON serialization patterns /// - Stateless design ensures thread safety in web containers @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoOutMapper { private final EntityTemplateService entityTemplateService; @@ -72,11 +71,11 @@ public EntityDtoOut fromEntity(Entity entity) { /// to minimize database queries. Builds summary maps for efficient relationship /// resolution across the entire page. /// - /// @param entities paginated domain entities from repository layer + /// @param entities paginated domain entities from repository layer /// @param entityTemplateIdentifier template identifier for batch template resolution /// @return paginated API DTOs with complete relationship data public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { + String entityTemplateIdentifier) { Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( @@ -94,7 +93,7 @@ public Page fromEntitiesPageToDtoPage(Page entities, /// @param entity the entity to map /// @param entityTemplate the template for property type mapping /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { Map props = mapPropertiesDto(entity, entityTemplate); List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); @@ -120,13 +119,13 @@ private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate /// /// @param entity the entity to map /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation - /// targets + /// @param relatedEntitiesSummaries map of entity summaries for relation + /// targets /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return the mapped DTO private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, - Map relatedEntitiesSummaries, - Map> relationTargetOwnershipsMap) { + Map relatedEntitiesSummaries, + Map> relationTargetOwnershipsMap) { Map props = mapPropertiesDto(entity, entityTemplate); Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaries); @@ -191,20 +190,20 @@ private Object convertPropertyValue(Property property, PropertyDefinition defini /// Maps the relations of an entity to a map of relation names to lists of target /// entity summaries. /// - /// @param entity the entity whose relations to map + /// @param entity the entity whose relations to map /// @param relatedEntitiesSummaries map of entity summaries for relation targets /// @return a map of relation names to lists of target entity summaries private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { + Map relatedEntitiesSummaries) { return entity.relations() == null ? Collections.emptyMap() : entity.relations().stream() - .collect(Collectors.groupingBy( - Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() + .collect(Collectors.groupingBy( + Relation::name, + Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() .map(relatedEntitiesSummaries::get) .filter(Objects::nonNull), - Collectors.toList()))); + Collectors.toList()))); } /// @@ -212,11 +211,11 @@ private Map> mapRelationsDto(Entity entity, /// lists of source entity summaries. /// /// @param entity the entity whose relations-as-target to - /// map + /// map /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return a map of relation names to lists of source entity summaries private Map> mapRelationsAsTargetDto(Entity entity, - Map> relationTargetOwnershipsMap) { + Map> relationTargetOwnershipsMap) { List relationAsTargetSummaries = relationTargetOwnershipsMap .get(entity.identifier()); if (relationAsTargetSummaries == null) { @@ -275,8 +274,8 @@ private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { return entity.relations() == null ? Collections.emptyList() : new ArrayList<>(entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream()) - .collect(Collectors.toSet())); + .flatMap(rel -> rel.targetEntityIdentifiers().stream()) + .collect(Collectors.toSet())); } /// @@ -290,7 +289,7 @@ private List getUniqueTargetIdentifiersInPage(Page entities) { .flatMap(entity -> entity.relations() == null ? Stream.empty() : entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream())) + .flatMap(rel -> rel.targetEntityIdentifiers().stream())) .collect(Collectors.toSet())); } @@ -313,10 +312,10 @@ private Map buildEntitiesSummariesMap(List tar return targetIdentifiers.isEmpty() ? Collections.emptyMap() : entityService.getEntitiesSummariesByIndentifiers(targetIdentifiers) - .stream() - .collect(Collectors.toMap( - EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); + .stream() + .collect(Collectors.toMap( + EntitySummary::identifier, + es -> new EntitySummaryDto(es.identifier(), es.name()))); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index c22ffbb..9117cc4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -3,6 +3,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; +import org.mapstruct.Named; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; @@ -18,12 +19,28 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); - @Mapping(target = "rawValue", ignore = true) + @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueFromString") Property toDomain(PropertyJpaEntity jpa); + @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueToString") PropertyJpaEntity toJpa(Property domain); Relation toDomain(RelationJpaEntity jpa); RelationJpaEntity toJpa(Relation domain); + + /// Converts a domain property value (carried as [Object] to preserve the + /// original JSON type) into its canonical String representation for storage. + @Named("propertyValueToString") + default String propertyValueToString(Object value) { + return value == null ? null : String.valueOf(value); + } + + /// Promotes a persisted String value to the domain [Object] representation. + /// Persistence is the source of truth for textual storage; richer typing + /// (Number/Boolean) is reconstructed by the API output mapper using the template. + @Named("propertyValueFromString") + default Object propertyValueFromString(String value) { + return value; + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index a0a2d15..e5a14d3 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -23,12 +23,15 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +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.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @DisplayName("EntityService Tests") @@ -37,9 +40,15 @@ class EntityServiceTest { @Mock private EntityRepositoryPort entityRepository; + @Mock + private EntityTemplateRepositoryPort entityTemplateRepository; + @Mock private EntityValidationService entityValidationService; + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + @InjectMocks private EntityService entityService; @@ -87,10 +96,10 @@ void shouldReturnEntityByTemplateAndIdentifier() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - var result = entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "catalog-api"); + var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); assertSame(entity, result); - verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityTemplateValidationService).checkTemplateExists("web-service"); verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); } @@ -101,38 +110,43 @@ void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { .thenReturn(Optional.empty()); assertThrows(EntityNotFoundException.class, - () -> entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "missing-entity")); + () -> entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); } @Test @DisplayName("Should create entity when validations pass") void shouldCreateEntityWhenValidationsPass() { var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.save(entity)).thenReturn(entity); var result = entityService.createEntity(entity); assertSame(entity, result); - InOrder inOrder = inOrder(entityValidationService, entityRepository); - inOrder.verify(entityValidationService).checkTemplateExist("web-service"); - inOrder.verify(entityValidationService).checkEntityAlreadyExist(entity); - inOrder.verify(entityValidationService).validateEntity(entity); + InOrder inOrder = inOrder(entityTemplateRepository, entityValidationService, entityRepository); + inOrder.verify(entityTemplateRepository).findByIdentifier("web-service"); + inOrder.verify(entityValidationService).checkUniqueness(entity); + inOrder.verify(entityValidationService).validateEntity(entity, template); inOrder.verify(entityRepository).save(entity); + verifyNoInteractions(entityTemplateValidationService); } @Test @DisplayName("Should not save when entity already exists") void shouldNotSaveWhenEntityAlreadyExists() { var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkEntityAlreadyExist(entity); + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkUniqueness(entity); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - verify(entityValidationService).checkTemplateExist("web-service"); - verify(entityValidationService).checkEntityAlreadyExist(entity); + verify(entityTemplateRepository).findByIdentifier("web-service"); + verify(entityValidationService).checkUniqueness(entity); verifyNoMoreInteractions(entityRepository); } @@ -140,16 +154,14 @@ void shouldNotSaveWhenEntityAlreadyExists() { @DisplayName("Should stop immediately when template does not exist") void shouldStopWhenTemplateDoesNotExistOnCreate() { var entity = entity("missing-template", "catalog-api", "Catalog API"); - var templateNotFound = new EntityTemplateNotFoundException("identifier", "missing-template"); - org.mockito.Mockito.doThrow(templateNotFound) - .when(entityValidationService) - .checkTemplateExist("missing-template"); + when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - verify(entityValidationService).checkTemplateExist("missing-template"); - verifyNoInteractions(entityRepository); + verify(entityTemplateRepository).findByIdentifier("missing-template"); + verifyNoInteractions(entityValidationService); + verifyNoMoreInteractions(entityRepository); } private Entity entity(String templateIdentifier, String identifier, String name) { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 02c7116..8b719e0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -1,12 +1,6 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -26,9 +20,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; @@ -37,7 +30,6 @@ import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; @ExtendWith(MockitoExtension.class) @@ -47,8 +39,6 @@ class EntityValidationServiceTest { @Mock private EntityRepositoryPort entityRepository; - @Mock - private EntityTemplateRepositoryPort entityTemplateRepository; @Mock private PropertyValidationService propertyValidationService; @@ -56,23 +46,6 @@ class EntityValidationServiceTest { @InjectMocks private EntityValidationService entityValidationService; - @Test - @DisplayName("Should pass checkTemplateExist when template exists") - void shouldPassCheckTemplateExistWhenTemplateExists() { - when(entityTemplateRepository.existsByIdentifier("web-service")).thenReturn(true); - - assertDoesNotThrow(() -> entityValidationService.checkTemplateExist("web-service")); - } - - @Test - @DisplayName("Should throw checkTemplateExist when template does not exist") - void shouldThrowCheckTemplateExistWhenTemplateDoesNotExist() { - when(entityTemplateRepository.existsByIdentifier("missing-template")).thenReturn(false); - - assertThrows(EntityTemplateNotFoundException.class, - () -> entityValidationService.checkTemplateExist("missing-template")); - } - @Test @DisplayName("Should throw when entity with same identifier already exists") void shouldThrowWhenEntityAlreadyExists() { @@ -80,7 +53,7 @@ void shouldThrowWhenEntityAlreadyExists() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkEntityAlreadyExist(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkUniqueness(entity)); } @Test @@ -88,22 +61,14 @@ void shouldThrowWhenEntityAlreadyExists() { void shouldNotQueryRepositoryWhenIdentifierIsNull() { var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.checkEntityAlreadyExist(entity)); + assertDoesNotThrow(() -> entityValidationService.checkUniqueness(entity)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } - @Test - @DisplayName("Should throw when template is missing during validateEntity") - void shouldThrowWhenTemplateMissingDuringValidateEntity() { - var entity = entity("missing-template", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); - - assertThrows(EntityTemplateNotFoundException.class, () -> entityValidationService.validateEntity(entity)); - } @Test - @DisplayName("Should aggregate entity, property, relation, required and rule violations") + @DisplayName("Should aggregate property requirements and rule violations") void shouldAggregateAllViolationsDuringValidateEntity() { var portDefinition = new PropertyDefinition( UUID.randomUUID(), @@ -129,35 +94,24 @@ void shouldAggregateAllViolationsDuringValidateEntity() { List.of(requiredDefinition, portDefinition), List.of()); - var mockedRelation = org.mockito.Mockito.mock(Relation.class); - when(mockedRelation.name()).thenReturn(" "); - when(mockedRelation.targetEntityIdentifiers()).thenReturn(null); - var entity = entity( "web-service", - " ", - " ", + " ", // Blank identifier (handled by Jakarta, not this service) + " ", // Blank name (handled by Jakarta, not this service) List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), - List.of(mockedRelation)); + List.of()); // No relations - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(portDefinition, "80", null)) + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity, template)); - assertEquals(8, exception.getViolations().size()); - assertEquals(ENTITY_NAME_MANDATORY, exception.getViolations().get(0)); - assertEquals(ENTITY_IDENTIFIER_MANDATORY, exception.getViolations().get(1)); - assertEquals("Property[0]: " + PROPERTY_NAME_MANDATORY, exception.getViolations().get(2)); - assertEquals("Property[0]: " + PROPERTY_VALUE_MANDATORY, exception.getViolations().get(3)); - assertEquals("Relation[0]: " + RELATION_NAME_MANDATORY_SIMPLE, exception.getViolations().get(4)); - assertEquals("Relation[0]: " + RELATION_TARGET_IDENTIFIERS_NOT_NULL, exception.getViolations().get(5)); - assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); - assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); + // Expecting exactly 2 errors: the missing required property, and the invalid port value. + assertEquals(2, exception.getViolations().size()); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(0)); + assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(1)); - verify(propertyValidationService).validatePropertyValue(portDefinition, "80", null); + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); } @Test @@ -186,13 +140,11 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), null); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0", null)).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0", null); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); } @Test @@ -216,14 +168,42 @@ void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.empty()); - - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); verifyNoInteractions(propertyValidationService); } + @Test + @DisplayName("Should validate property of type STRING with a numeric string value '1234'") + void shouldValidateStringPropertyWithNumericStringValue() { + var stringDefinition = new PropertyDefinition( + UUID.randomUUID(), + "versionCode", + "Version Code as String", + PropertyType.STRING, + false, + null + ); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(stringDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), + null); + when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); + } + private Entity entity( String templateIdentifier, String identifier, diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 2ffdef8..14ed3f6 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -30,7 +30,7 @@ class StringValidationTests { void shouldReportTypeMismatchWhenStringValueIsNull() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null, null); + var violations = service.validatePropertyValue(definition, null); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); } @@ -40,7 +40,7 @@ void shouldReportTypeMismatchWhenStringValueIsNull() { void shouldReturnNoViolationsWhenStringHasNoRules() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello", "hello"); + var violations = service.validatePropertyValue(definition, "hello"); assertEquals(List.of(), violations); } @@ -51,7 +51,7 @@ void shouldReturnNoViolationsWhenStringPassesAllRules() { var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev", "dev"); + var violations = service.validatePropertyValue(definition, "dev"); assertEquals(List.of(), violations); } @@ -62,7 +62,7 @@ void shouldReportMinLengthViolation() { var rules = new PropertyRules(null, null, null, null, null, 5, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab", "ab"); + var violations = service.validatePropertyValue(definition, "ab"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -73,7 +73,7 @@ void shouldReportMaxLengthViolation() { var rules = new PropertyRules(null, null, null, null, 5, null, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value", "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -84,7 +84,7 @@ void shouldReportRegexViolation() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc", "abc"); + var violations = service.validatePropertyValue(definition, "abc"); assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); } @@ -95,7 +95,7 @@ void shouldAcceptValueMatchingRegex() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345", "12345"); + var violations = service.validatePropertyValue(definition, "12345"); assertEquals(List.of(), violations); } @@ -106,7 +106,7 @@ void shouldReportEnumViolation() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN", "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN"); assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); } @@ -117,7 +117,7 @@ void shouldAcceptEnumValueCaseInsensitive() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active", "active"); + var violations = service.validatePropertyValue(definition, "active"); assertEquals(List.of(), violations); } @@ -128,7 +128,7 @@ void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything", "anything"); + var violations = service.validatePropertyValue(definition, "anything"); assertEquals(List.of(), violations); } @@ -139,7 +139,7 @@ void shouldReportFormatViolationForInvalidEmail() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email", "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); } @@ -150,7 +150,7 @@ void shouldAcceptValidEmailFormat() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com", "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com"); assertEquals(List.of(), violations); } @@ -161,7 +161,7 @@ void shouldReportFormatViolationForInvalidUrl() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url", "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); } @@ -172,7 +172,7 @@ void shouldAcceptValidUrlFormat() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo", "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); assertEquals(List.of(), violations); } @@ -183,7 +183,7 @@ void shouldReportMultipleStringViolations() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA", "AA"); + var violations = service.validatePropertyValue(definition, "AA"); assertEquals(4, violations.size()); } @@ -195,33 +195,12 @@ void shouldUseCachedPatternForRepeatedRegex() { var definition = propertyDefinition("code", PropertyType.STRING, rules); // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc", "abc"); - var violations2 = service.validatePropertyValue(definition, "def", "def"); + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); assertEquals(List.of(), violations1); assertEquals(List.of(), violations2); } - - @Test - @DisplayName("Should report type mismatch when a number is sent for a STRING property") - void shouldReportTypeMismatchWhenNumberSentForString() { - var rules = new PropertyRules(null, null, null, null, null, 5, null, null); - var definition = propertyDefinition("label", PropertyType.STRING, rules); - - var violations = service.validatePropertyValue(definition, "12", 12); - - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } - - @Test - @DisplayName("Should report type mismatch when a boolean is sent for a STRING property") - void shouldReportTypeMismatchWhenBooleanSentForString() { - var definition = propertyDefinition("label", PropertyType.STRING, null); - - var violations = service.validatePropertyValue(definition, "true", true); - - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } } @Nested @@ -233,7 +212,7 @@ class NumberValidationTests { void shouldReportTypeMismatchWhenNumberValueIsInvalid() { var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number", "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); } @@ -243,7 +222,7 @@ void shouldReportTypeMismatchWhenNumberValueIsInvalid() { void shouldReturnNoViolationsWhenNumberHasNoRules() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42", 42); + var violations = service.validatePropertyValue(definition, "42"); assertEquals(List.of(), violations); } @@ -254,7 +233,7 @@ void shouldReturnNoViolationsWhenNumberIsWithinBounds() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50", 50); + var violations = service.validatePropertyValue(definition, "50"); assertEquals(List.of(), violations); } @@ -265,7 +244,7 @@ void shouldReportMinValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3", 3); + var violations = service.validatePropertyValue(definition, "3"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); } @@ -276,7 +255,7 @@ void shouldReportMaxValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15", 15); + var violations = service.validatePropertyValue(definition, "15"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); } @@ -288,7 +267,7 @@ void shouldReportBothMinAndMaxViolations() { var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7", 7); + var violations = service.validatePropertyValue(definition, "7"); // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation assertEquals(2, violations.size()); @@ -300,7 +279,7 @@ void shouldAcceptDecimalNumberValues() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5", 99.5); + var violations = service.validatePropertyValue(definition, "99.5"); assertEquals(List.of(), violations); } @@ -310,7 +289,7 @@ void shouldAcceptDecimalNumberValues() { void shouldReportTypeMismatchWhenBooleanSentForNumber() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true", true); + var violations = service.validatePropertyValue(definition, "true"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); } @@ -324,9 +303,8 @@ class BooleanValidationTests { @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) void shouldAcceptValidBooleanValues(String value) { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - Object originalValue = "true".equalsIgnoreCase(value) ? Boolean.TRUE : Boolean.FALSE; - var violations = service.validatePropertyValue(definition, value, originalValue); + var violations = service.validatePropertyValue(definition, value); assertEquals(List.of(), violations); } @@ -336,7 +314,7 @@ void shouldAcceptValidBooleanValues(String value) { void shouldReportTypeMismatchForInvalidBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes", "yes"); + var violations = service.validatePropertyValue(definition, "yes"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } @@ -346,7 +324,7 @@ void shouldReportTypeMismatchForInvalidBoolean() { void shouldReportTypeMismatchWhenNumberSentForBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "42", 42); + var violations = service.validatePropertyValue(definition, "42"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } 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 10e9cce..f7be973 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,12 +25,12 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; 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.exception.entity.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; From 6df88346b6599ff50bc734b5daf295f2027556ab Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Tue, 5 May 2026 16:32:06 +0200 Subject: [PATCH 21/51] feat(core): fix end of file --- .../property/PropertyDefinitionRulesConflictException.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index f68a840..3ce489e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -22,4 +22,4 @@ public PropertyDefinitionRulesConflictException(String propertyName, PropertyTyp super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); } -} \ No newline at end of file +} From e035d4eb6bf2a47a5925437fb4a12f111391b48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 6 May 2026 11:34:39 +0200 Subject: [PATCH 22/51] feat(core): call entity template service in entity service --- .../domain/port/EntityRepositoryPort.java | 2 +- .../domain/service/entity/EntityService.java | 18 +++---- .../entity/EntityValidationService.java | 5 +- .../persistence/PostgresEntityAdapter.java | 7 +-- .../service/entity/EntityServiceTest.java | 52 +++++++++++-------- .../entity/EntityValidationServiceTest.java | 4 +- 6 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 296a2fb..7ba98f5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -35,7 +35,7 @@ public interface EntityRepositoryPort { Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); - Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); List findByIdentifierIn(List identifiers); diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index d90231e..e9d5109 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,7 +2,6 @@ import java.util.List; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -10,16 +9,18 @@ import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +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.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + import jakarta.transaction.Transactional; import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. /// @@ -37,9 +38,9 @@ @RequiredArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; private final EntityValidationService entityValidationService; private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; /// Retrieves entities filtered by template with existence validation. /// @@ -52,9 +53,9 @@ public class EntityService { /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { + entityTemplateValidationService.checkTemplateExists(templateIdentifier); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable) - .orElseThrow(() -> new EntityTemplateNotFoundException(templateIdentifier)); } /// Provides lightweight entity summaries for efficient bulk operations. @@ -100,9 +101,8 @@ public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifi /// @throws EntityValidationException when entity, property, or relation data is invalid @Transactional public Entity createEntity(@Valid Entity entity) { - EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); - entityValidationService.checkUniqueness(entity); + EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); + entityValidationService.validateUniqueness(entity); entityValidationService.validateEntity(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 4902016..b926db7 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -7,7 +7,6 @@ import java.util.Optional; import java.util.stream.Collectors; -import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; @@ -19,6 +18,8 @@ import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; +import lombok.AllArgsConstructor; + /// Domain validator for [Entity] aggregates. /// /// Validation pipeline: @@ -86,7 +87,7 @@ private void validateAgainstTemplate(EntityTemplate template, /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void checkUniqueness(final Entity entity) { + void validateUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 6977e85..2a877ee 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -5,7 +5,6 @@ import java.util.Optional; import java.util.UUID; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -16,6 +15,8 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { @@ -46,9 +47,9 @@ public Optional findByTemplateIdentifierAndName(String templateIdentifie } @Override - public Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return Optional.of(pageableEntity.map(mapper::toDomain)); + return pageableEntity.map(mapper::toDomain); } @Override diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index e5a14d3..65b9932 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -31,6 +32,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @@ -49,6 +51,9 @@ class EntityServiceTest { @Mock private EntityTemplateValidationService entityTemplateValidationService; + @Mock + private EntityTemplateService entityTemplateService; + @InjectMocks private EntityService entityService; @@ -59,7 +64,7 @@ void shouldReturnEntitiesByTemplateIdentifier() { var entity = entity("template-a", "entity-a", "Entity A"); var page = new PageImpl<>(List.of(entity)); - when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(Optional.of(page)); + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(page); var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); @@ -67,15 +72,17 @@ void shouldReturnEntitiesByTemplateIdentifier() { verify(entityRepository).findByTemplateIdentifier("template-a", pageable); } - @Test - @DisplayName("Should throw when template has no entities page") - void shouldThrowWhenTemplatePageNotFound() { - var pageable = Pageable.ofSize(10); - when(entityRepository.findByTemplateIdentifier("missing-template", pageable)).thenReturn(Optional.empty()); + // @Test + // @DisplayName("Should throw when template has no entities page") + // void shouldThrowWhenTemplatePageNotFound() { + // var pageable = Pageable.ofSize(10); + // when(entityRepository.findByTemplateIdentifier("missing-template", + // pageable)).thenReturn(Optional.empty()); - assertThrows(EntityTemplateNotFoundException.class, - () -> entityService.getEntitiesByTemplateIdentifier(pageable, "missing-template")); - } + // assertThrows(EntityTemplateNotFoundException.class, + // () -> entityService.getEntitiesByTemplateIdentifier(pageable, + // "missing-template")); + // } @Test @DisplayName("Should return entity summaries by identifiers") @@ -117,17 +124,18 @@ void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { @DisplayName("Should create entity when validations pass") void shouldCreateEntityWhenValidationsPass() { var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); when(entityRepository.save(entity)).thenReturn(entity); var result = entityService.createEntity(entity); assertSame(entity, result); - InOrder inOrder = inOrder(entityTemplateRepository, entityValidationService, entityRepository); - inOrder.verify(entityTemplateRepository).findByIdentifier("web-service"); - inOrder.verify(entityValidationService).checkUniqueness(entity); + InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityValidationService).validateUniqueness(entity); inOrder.verify(entityValidationService).validateEntity(entity, template); inOrder.verify(entityRepository).save(entity); verifyNoInteractions(entityTemplateValidationService); @@ -137,16 +145,17 @@ void shouldCreateEntityWhenValidationsPass() { @DisplayName("Should not save when entity already exists") void shouldNotSaveWhenEntityAlreadyExists() { var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkUniqueness(entity); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + doThrow(alreadyExists).when(entityValidationService).validateUniqueness(entity); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - verify(entityTemplateRepository).findByIdentifier("web-service"); - verify(entityValidationService).checkUniqueness(entity); + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityValidationService).validateUniqueness(entity); verifyNoMoreInteractions(entityRepository); } @@ -155,11 +164,12 @@ void shouldNotSaveWhenEntityAlreadyExists() { void shouldStopWhenTemplateDoesNotExistOnCreate() { var entity = entity("missing-template", "catalog-api", "Catalog API"); - when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); + when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) + .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - verify(entityTemplateRepository).findByIdentifier("missing-template"); + verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); verifyNoInteractions(entityValidationService); verifyNoMoreInteractions(entityRepository); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 8b719e0..6b00a96 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -53,7 +53,7 @@ void shouldThrowWhenEntityAlreadyExists() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkUniqueness(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateUniqueness(entity)); } @Test @@ -61,7 +61,7 @@ void shouldThrowWhenEntityAlreadyExists() { void shouldNotQueryRepositoryWhenIdentifierIsNull() { var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.checkUniqueness(entity)); + assertDoesNotThrow(() -> entityValidationService.validateUniqueness(entity)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } From 3f68ff66297d790991009877ff8abdda301963eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Mon, 11 May 2026 16:13:36 +0200 Subject: [PATCH 23/51] feat(core): update the validate template methods calls --- .../idp_core/domain/service/entity/EntityService.java | 4 ++-- .../idp_core/domain/service/entity/EntityServiceTest.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index e9d5109..7da8ca2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -15,7 +15,7 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import jakarta.transaction.Transactional; @@ -53,7 +53,7 @@ public class EntityService { /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - entityTemplateValidationService.checkTemplateExists(templateIdentifier); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 65b9932..6699c56 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -32,7 +32,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @@ -106,7 +106,7 @@ void shouldReturnEntityByTemplateAndIdentifier() { var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); assertSame(entity, result); - verify(entityTemplateValidationService).checkTemplateExists("web-service"); + verify(entityTemplateValidationService).validateTemplateExists("web-service"); verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); } From 92192df21c67de24f638a5dedab4a1db8ce0765d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 13 May 2026 18:01:14 +0200 Subject: [PATCH 24/51] feat(core): update the validate template methods calls --- ...sql => V3_4__change_entity_identifier_unique_to_composite.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V3_3__change_entity_identifier_unique_to_composite.sql => V3_4__change_entity_identifier_unique_to_composite.sql} (100%) diff --git a/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql b/src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql similarity index 100% rename from src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql rename to src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql From fb7c2df2f02c098d96d4f7b579fea8f02556389d Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 18 May 2026 11:45:49 +0200 Subject: [PATCH 25/51] feat(core): fix review --- .../domain/model/entity/Property.java | 2 +- .../domain/service/entity/EntityService.java | 3 +- .../entity/EntityValidationService.java | 22 ++++++------- .../adapters/api/dto/in/EntityDtoIn.java | 2 +- .../api/mapper/entity/EntityDtoInMapper.java | 2 +- .../mapper/EntityPersistenceMapper.java | 17 ---------- .../service/entity/EntityServiceTest.java | 22 ++----------- .../entity/EntityValidationServiceTest.java | 32 ++++++++++++++----- 8 files changed, 41 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 7850124..5e7281e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -29,6 +29,6 @@ public record Property( @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - Object value + String value ) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 7da8ca2..72e40ad 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -102,8 +102,7 @@ public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifi @Transactional public Entity createEntity(@Valid Entity entity) { EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); - entityValidationService.validateUniqueness(entity); - entityValidationService.validateEntity(entity, template); + entityValidationService.validateForCreation(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index b926db7..8143e6c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -44,22 +44,17 @@ public class EntityValidationService { /// @param template the already-resolved template the entity must conform to /// @throws EntityValidationException when one or more validation rules are violated /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - void validateEntity(Entity entity, EntityTemplate template) { - Violations violations = new Violations(); - validateAgainstTemplate(template, entity.properties(), violations); - - if (!violations.isEmpty()) { - throw new EntityValidationException(violations.asList()); - } + void validateForCreation(Entity entity, EntityTemplate template) { + validateUniqueness(entity); + validateAgainstTemplate(template, entity.properties()); } /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation /// @param properties the list of properties from the entity to validate - /// @param violations the accumulator for validation v iolation messages private void validateAgainstTemplate(EntityTemplate template, - List properties, - Violations violations) { + List properties) { + Violations violations = new Violations(); List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() .filter(p -> p.name() != null) @@ -69,7 +64,7 @@ private void validateAgainstTemplate(EntityTemplate template, Property property = propertiesByName.get(definition.name()); boolean missing = property == null || property.value() == null - || (property.value() instanceof String s && s.isBlank()); + || (property.value().isBlank()); if (missing) { if (definition.required()) { @@ -82,12 +77,15 @@ private void validateAgainstTemplate(EntityTemplate template, .validatePropertyValue(definition, property.value()) .forEach(violations::add); } + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); + } } /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void validateUniqueness(final Entity entity) { + private void validateUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 0531655..7587711 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -50,7 +50,7 @@ public class EntityDtoIn { private String identifier; @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") - private Map properties; + private Map properties; @Valid @Schema(description = FIELD_ENTITY_RELATIONS) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 45dc45a..5548ec0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -40,7 +40,7 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> new Property( + .map((Map.Entry entry) -> new Property( null, entry.getKey(), entry.getValue() diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 9117cc4..cc7edac 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -19,28 +19,11 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); - @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueFromString") Property toDomain(PropertyJpaEntity jpa); - @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueToString") PropertyJpaEntity toJpa(Property domain); Relation toDomain(RelationJpaEntity jpa); RelationJpaEntity toJpa(Relation domain); - - /// Converts a domain property value (carried as [Object] to preserve the - /// original JSON type) into its canonical String representation for storage. - @Named("propertyValueToString") - default String propertyValueToString(Object value) { - return value == null ? null : String.valueOf(value); - } - - /// Promotes a persisted String value to the domain [Object] representation. - /// Persistence is the source of truth for textual storage; richer typing - /// (Number/Boolean) is reconstructed by the API output mapper using the template. - @Named("propertyValueFromString") - default Object propertyValueFromString(String value) { - return value; - } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 6699c56..22b4cb9 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -31,7 +31,6 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -42,8 +41,6 @@ class EntityServiceTest { @Mock private EntityRepositoryPort entityRepository; - @Mock - private EntityTemplateRepositoryPort entityTemplateRepository; @Mock private EntityValidationService entityValidationService; @@ -72,18 +69,6 @@ void shouldReturnEntitiesByTemplateIdentifier() { verify(entityRepository).findByTemplateIdentifier("template-a", pageable); } - // @Test - // @DisplayName("Should throw when template has no entities page") - // void shouldThrowWhenTemplatePageNotFound() { - // var pageable = Pageable.ofSize(10); - // when(entityRepository.findByTemplateIdentifier("missing-template", - // pageable)).thenReturn(Optional.empty()); - - // assertThrows(EntityTemplateNotFoundException.class, - // () -> entityService.getEntitiesByTemplateIdentifier(pageable, - // "missing-template")); - // } - @Test @DisplayName("Should return entity summaries by identifiers") void shouldReturnEntitySummariesByIdentifiers() { @@ -135,8 +120,7 @@ void shouldCreateEntityWhenValidationsPass() { InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityValidationService).validateUniqueness(entity); - inOrder.verify(entityValidationService).validateEntity(entity, template); + inOrder.verify(entityValidationService).validateForCreation(entity, template); inOrder.verify(entityRepository).save(entity); verifyNoInteractions(entityTemplateValidationService); } @@ -150,12 +134,12 @@ void shouldNotSaveWhenEntityAlreadyExists() { var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - doThrow(alreadyExists).when(entityValidationService).validateUniqueness(entity); + doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityValidationService).validateUniqueness(entity); + verify(entityValidationService).validateForCreation(entity, template); verifyNoMoreInteractions(entityRepository); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 6b00a96..6411bd6 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -49,19 +50,34 @@ class EntityValidationServiceTest { @Test @DisplayName("Should throw when entity with same identifier already exists") void shouldThrowWhenEntityAlreadyExists() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateUniqueness(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateForCreation(entity, template)); } @Test @DisplayName("Should not query repository when identifier is null") void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.validateUniqueness(entity)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } @@ -69,7 +85,7 @@ void shouldNotQueryRepositoryWhenIdentifierIsNull() { @Test @DisplayName("Should aggregate property requirements and rule violations") - void shouldAggregateAllViolationsDuringValidateEntity() { + void shouldAggregateAllViolationsDuringValidateForCreation() { var portDefinition = new PropertyDefinition( UUID.randomUUID(), "port", @@ -104,7 +120,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { when(propertyValidationService.validatePropertyValue(portDefinition, "80")) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity, template)); + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateForCreation(entity, template)); // Expecting exactly 2 errors: the missing required property, and the invalid port value. assertEquals(2, exception.getViolations().size()); @@ -116,7 +132,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { @Test @DisplayName("Should validate entity successfully when no violations") - void shouldValidateEntitySuccessfullyWhenNoViolations() { + void shouldValidateForCreationSuccessfullyWhenNoViolations() { var versionDefinition = new PropertyDefinition( UUID.randomUUID(), "version", @@ -143,7 +159,7 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); } @@ -168,7 +184,7 @@ void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verifyNoInteractions(propertyValidationService); } @@ -200,7 +216,7 @@ void shouldValidateStringPropertyWithNumericStringValue() { null); when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); } From c501d32d6a3970b7e20ebf26623aebb8df83b5c7 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Wed, 20 May 2026 10:44:35 +0200 Subject: [PATCH 26/51] feat(core): rebase on main --- .../domain/constant/ValidationMessages.java | 35 ++----------------- .../api/handler/ApiExceptionHandlerTest.java | 2 +- 2 files changed, 3 insertions(+), 34 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 6bb038f..0d011a5 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 @@ -32,7 +32,6 @@ public class ValidationMessages { public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; - public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // 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"; @@ -41,6 +40,8 @@ public class ValidationMessages { 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"; + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; @@ -52,36 +53,6 @@ public class ValidationMessages { public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; - // 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_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"; - - 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 - .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); - } - // Entity input validation messages public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; @@ -91,8 +62,6 @@ public static String minMaxConstraintViolated(String constraint) { public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; - 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 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 f7be973..dd840bc 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 @@ -343,7 +343,7 @@ static Stream httpMessageNotReadableExceptionTestData() { ), Arguments.of( "Cannot deserialize value of type `com.example.SomeType`: some other error", - "Cannot deserialize request body property" + "Invalid type: expected SomeType" ), Arguments.of( "Something completely unexpected happened", From 7e9c55625c13320b87afa0d6a8b514138138a443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 10:51:31 +0200 Subject: [PATCH 27/51] feat(core): add a entity graph service and endpoint --- .../api/configuration/SwaggerDescription.java | 14 +---- .../api/controller/EntityGraphController.java | 53 +++---------------- .../dto/out/entity/EntityGraphNodeDtoOut.java | 36 ------------- .../out/entity/EntityGraphRelationDtoOut.java | 31 ----------- .../entity/EntityGraphDtoOutMapper.java | 50 ----------------- 5 files changed, 9 insertions(+), 175 deletions(-) delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 67013ba..c3b229e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -149,20 +149,8 @@ public class SwaggerDescription { public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; - // --- Entity Graph descriptions --- - public static final String ENDPOINT_GET_ENTITY_GRAPH_SUMMARY = "Get entity relationship graph"; - public static final String ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION = "Retrieves the entity relationship graph starting from the specified entity, resolving outbound relations recursively up to the requested depth."; - public static final String RESPONSE_ENTITY_GRAPH_SUCCESS = "Entity graph successfully retrieved"; + // --- Entity Graph (flat nodes & edges) descriptions --- public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; - public static final String ENTITY_GRAPH_NODE_DESCRIPTION = "A node in the entity relationship graph"; - public static final String ENTITY_GRAPH_SUMMARY_DESCRIPTION = "Summary information identifying the entity"; - public static final String ENTITY_GRAPH_RELATIONS_DESCRIPTION = "Resolved outbound relations with target entity nodes"; - public static final String ENTITY_GRAPH_RELATION_NAME_DESCRIPTION = "The relation name as defined in the entity template"; - public static final String ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION = "The template identifier of target entities"; - public static final String ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION = "Resolved target entity graph nodes"; - public static final String ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION = "Incoming relations where this entity is the target"; - - // --- Entity Graph flat (nodes & edges) descriptions --- public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index c33a558..872b846 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -1,14 +1,11 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; @@ -22,9 +19,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; -import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphDtoOutMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphFlatDtoOutMapper; import io.swagger.v3.oas.annotations.Operation; @@ -38,8 +33,9 @@ /// REST controller for entity relationship graph operations. /// -/// Provides endpoints to retrieve hierarchical relationship graphs starting from -/// a specified entity, enabling visualization of entity dependencies and connections. +/// Provides endpoints to retrieve flat (nodes and edges) relationship graphs +/// starting from a specified entity, suitable for frontend visualization tools +/// such as React Flow, Vis.js, and Cytoscape. @RestController @RequestMapping("/api/v1/entities") @RequiredArgsConstructor @@ -48,50 +44,17 @@ public class EntityGraphController { private final EntityGraphService entityGraphService; - /// Retrieves the entity relationship graph starting from the specified entity. - /// - /// Resolves outbound relations recursively up to the requested depth, - /// returning a tree structure with entity summary information at each node. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) - /// @return the root graph node with resolved relations - @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") - @ResponseStatus(OK) - @Operation( - summary = ENDPOINT_GET_ENTITY_GRAPH_SUMMARY, - description = ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION, - responses = { - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_SUCCESS, - content = @Content(schema = @Schema(implementation = EntityGraphNodeDtoOut.class))), - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, - content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - } - ) - public EntityGraphNodeDtoOut getEntityGraph( - @PathVariable @NotBlank String templateIdentifier, - @PathVariable @NotBlank String entityIdentifier, - @Parameter(description = PARAM_DEPTH_DESCRIPTION) - @RequestParam(defaultValue = "1") int depth) { - - EntityGraphNode graphNode = entityGraphService.getEntityGraph( - templateIdentifier, entityIdentifier, depth); - - return EntityGraphDtoOutMapper.toDto(graphNode); - } - /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. /// - /// Returns all entities as nodes and all directed relations as edges, following - /// the de-facto standard for frontend visualization tools such as React Flow, - /// Vis.js, and Cytoscape. Nodes are deduplicated; edges encode directionality. + /// Returns all entities as nodes and all directed relations as edges. Nodes are + /// deduplicated; edges encode directionality. Suitable for React Flow, Vis.js, + /// Cytoscape, and similar frontend graph visualization libraries. /// /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) /// @return flat DTO containing nodes and edges arrays - @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph/flat") + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @Operation( summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, @@ -103,7 +66,7 @@ public EntityGraphNodeDtoOut getEntityGraph( content = @Content(schema = @Schema(implementation = ErrorResponse.class))) } ) - public EntityGraphFlatDtoOut getEntityGraphFlat( + public EntityGraphFlatDtoOut getEntityGraph( @PathVariable @NotBlank String templateIdentifier, @PathVariable @NotBlank String entityIdentifier, @Parameter(description = PARAM_DEPTH_DESCRIPTION) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java deleted file mode 100644 index 43119c9..0000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; - -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_DESCRIPTION; - -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import io.swagger.v3.oas.annotations.media.Schema; - -/// Output DTO representing a node in the entity relationship graph. -/// -/// Contains summary information about the entity and its resolved outbound relations -/// grouped by relation name, and incoming relations where this entity is the target. -@JsonNaming(SnakeCaseStrategy.class) -public record EntityGraphNodeDtoOut( - - String templateIdentifier, - String identifier, - String name, - - @Schema(description = ENTITY_GRAPH_RELATIONS_DESCRIPTION) - Map> relations, - - @Schema(description = ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION) - Map> relationsAsTarget -) { - /// Defensive copies prevent external mutation of the mutable Map collections - public EntityGraphNodeDtoOut { - relations = relations != null ? Map.copyOf(relations) : Map.of(); - relationsAsTarget = relationsAsTarget != null ? Map.copyOf(relationsAsTarget) : Map.of(); - } -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java deleted file mode 100644 index df12629..0000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; - -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; - -import java.util.List; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import io.swagger.v3.oas.annotations.media.Schema; - -/// Output DTO representing a single named relation in the entity graph. -@JsonNaming(SnakeCaseStrategy.class) -public record EntityGraphRelationDtoOut( - - @Schema(description = ENTITY_GRAPH_RELATION_NAME_DESCRIPTION) - String name, - - @Schema(description = ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION) - String targetTemplateIdentifier, - - @Schema(description = ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION) - List targets -) { - /// Defensive copy prevents external mutation of the mutable List collection - public EntityGraphRelationDtoOut { - targets = targets != null ? List.copyOf(targets) : List.of(); - } -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java deleted file mode 100644 index 582c9f5..0000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; -import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; - -/// Mapper for converting domain [EntityGraphNode] to its API output DTO -/// representation. -/// -/// Uses Record Patterns for recursive tree mapping since MapStruct does not -/// handle recursive structures cleanly. -public final class EntityGraphDtoOutMapper { - - private EntityGraphDtoOutMapper() { - // Utility class - } - - /// Maps a domain graph node to its DTO representation. - /// - /// @param node the domain graph node - /// @return the output DTO - public static EntityGraphNodeDtoOut toDto(EntityGraphNode node) { - if (node == null) { - return null; - } - return new EntityGraphNodeDtoOut( - node.templateIdentifier(), node.identifier(), node.name(), - mapRelations(node.relations()), - mapRelations(node.relationsAsTarget())); - } - - private static Map> mapRelations(List relations) { - if (relations == null || relations.isEmpty()) { - return Map.of(); - } - return relations.stream() - .collect(Collectors.toMap( - EntityGraphRelation::name, - relation -> relation.targets().stream() - .map(EntityGraphDtoOutMapper::toDto) - .toList(), - (existing, replacement) -> existing, - LinkedHashMap::new)); - } -} From 48ed5f8724edd3ca9f0ede6934434ae91f0b6d30 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Wed, 20 May 2026 10:59:03 +0200 Subject: [PATCH 28/51] feat(core): fix review after rebase --- docs/src/static/swagger.yaml | 186 +++--------------- .../idp_core/domain/model/entity/Entity.java | 5 + .../model/entity_template/EntityTemplate.java | 5 + .../api/configuration/CorsProperties.java | 8 +- 4 files changed, 44 insertions(+), 160 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index accb23c..8f0b4db 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -53,11 +53,11 @@ paths: schema: type: string requestBody: + required: true content: application/json: schema: $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' - required: true responses: '200': description: Template update successfully @@ -118,7 +118,7 @@ paths: default: '20' - name: sort in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' + description: Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc. content: '*/*': schema: @@ -144,11 +144,11 @@ paths: description: Create a new template in the system with the provided information operationId: createTemplate requestBody: + required: true content: application/json: schema: $ref: '#/components/schemas/EntityTemplateCreateDtoIn' - required: true responses: '201': description: Template created successfully @@ -172,8 +172,8 @@ paths: parameters: - name: page in: query - description: Page number for pagination. Defaults to 0. required: false + description: Page number for pagination. Defaults to 0. content: '*/*': schema: @@ -181,8 +181,8 @@ paths: default: '0' - name: size in: query - description: Number of items per page. Defaults to 20. required: false + description: Number of items per page. Defaults to 20. content: '*/*': schema: @@ -195,7 +195,7 @@ paths: type: string - name: sort in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' + description: Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc. content: '*/*': schema: @@ -226,13 +226,12 @@ paths: required: true schema: type: string - minLength: 1 requestBody: + required: true content: application/json: schema: $ref: '#/components/schemas/EntityDtoIn' - required: true responses: '201': description: Entity created successfully @@ -246,28 +245,6 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' - '401': - description: Unauthorized - Missing or invalid token - '403': - description: Insufficient rights - '409': - description: Entity already exists in this template - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '404': - description: Template not found with the provided identifier - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '500': - description: Unexpected server-side failure - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: get: tags: @@ -312,11 +289,11 @@ components: minLength: 1 name: type: string - description: Entity Template name + description: Unique Entity Template name example: Service maxLength: 255 minLength: 1 - pattern: "^[a-zA-Z0-9 _-]+$" + pattern: ^[a-zA-Z0-9 _-]+$ description: type: string description: Entity Template description @@ -343,8 +320,8 @@ components: description: Unique Entity Template name example: Service maxLength: 255 - minLength: 0 - pattern: "^[a-zA-Z0-9 _-]+$" + minLength: 1 + pattern: ^[a-zA-Z0-9 _-]+$ description: type: string description: Entity Template description @@ -385,11 +362,12 @@ components: example: STRING required: type: boolean - default: false description: Whether this property is required example: true + default: false rules: - $ref: '#/components/schemas/PropertyRulesDtoIn' + allOf: + - $ref: '#/components/schemas/PropertyRulesDtoIn' description: Property validation rules required: - description @@ -454,14 +432,14 @@ components: minLength: 1 required: type: boolean - default: false description: Whether this relation is required example: false + default: false to_many: type: boolean - default: false description: Whether this relation can have multiple targets example: true + default: false required: - name - target_template_identifier @@ -516,18 +494,14 @@ components: description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoOut' + allOf: + - $ref: '#/components/schemas/PropertyRulesDtoOut' description: Property validation rules example: Property validation rules PropertyRulesDtoOut: type: object description: Output DTO for property validation rules properties: - id: - type: string - format: uuid - description: Unique identifier of the property rules - example: 123e4567-e89b-12d3-a456-426614174000 format: type: string description: Format of the property @@ -587,132 +561,29 @@ components: type: boolean description: Whether this relation can have multiple targets example: true - ErrorResponse: - type: object - properties: - error: - type: string - enum: - - 100 CONTINUE - - 101 SWITCHING_PROTOCOLS - - 102 PROCESSING - - 103 EARLY_HINTS - - 103 CHECKPOINT - - 200 OK - - 201 CREATED - - 202 ACCEPTED - - 203 NON_AUTHORITATIVE_INFORMATION - - 204 NO_CONTENT - - 205 RESET_CONTENT - - 206 PARTIAL_CONTENT - - 207 MULTI_STATUS - - 208 ALREADY_REPORTED - - 226 IM_USED - - 300 MULTIPLE_CHOICES - - 301 MOVED_PERMANENTLY - - 302 FOUND - - 302 MOVED_TEMPORARILY - - 303 SEE_OTHER - - 304 NOT_MODIFIED - - 305 USE_PROXY - - 307 TEMPORARY_REDIRECT - - 308 PERMANENT_REDIRECT - - 400 BAD_REQUEST - - 401 UNAUTHORIZED - - 402 PAYMENT_REQUIRED - - 403 FORBIDDEN - - 404 NOT_FOUND - - 405 METHOD_NOT_ALLOWED - - 406 NOT_ACCEPTABLE - - 407 PROXY_AUTHENTICATION_REQUIRED - - 408 REQUEST_TIMEOUT - - 409 CONFLICT - - 410 GONE - - 411 LENGTH_REQUIRED - - 412 PRECONDITION_FAILED - - 413 PAYLOAD_TOO_LARGE - - 413 REQUEST_ENTITY_TOO_LARGE - - 414 URI_TOO_LONG - - 414 REQUEST_URI_TOO_LONG - - 415 UNSUPPORTED_MEDIA_TYPE - - 416 REQUESTED_RANGE_NOT_SATISFIABLE - - 417 EXPECTATION_FAILED - - 418 I_AM_A_TEAPOT - - 419 INSUFFICIENT_SPACE_ON_RESOURCE - - 420 METHOD_FAILURE - - 421 DESTINATION_LOCKED - - 422 UNPROCESSABLE_ENTITY - - 423 LOCKED - - 424 FAILED_DEPENDENCY - - 425 TOO_EARLY - - 426 UPGRADE_REQUIRED - - 428 PRECONDITION_REQUIRED - - 429 TOO_MANY_REQUESTS - - 431 REQUEST_HEADER_FIELDS_TOO_LARGE - - 451 UNAVAILABLE_FOR_LEGAL_REASONS - - 500 INTERNAL_SERVER_ERROR - - 501 NOT_IMPLEMENTED - - 502 BAD_GATEWAY - - 503 SERVICE_UNAVAILABLE - - 504 GATEWAY_TIMEOUT - - 505 HTTP_VERSION_NOT_SUPPORTED - - 506 VARIANT_ALSO_NEGOTIATES - - 507 INSUFFICIENT_STORAGE - - 508 LOOP_DETECTED - - 509 BANDWIDTH_LIMIT_EXCEEDED - - 510 NOT_EXTENDED - - 511 NETWORK_AUTHENTICATION_REQUIRED - errorDescription: - type: string EntityDtoIn: type: object - description: Input DTO for creating or updating an entity properties: name: type: string - description: Name of the entity - example: my-web-service - minLength: 1 identifier: type: string - description: Unique identifier of the entity within the template scope - example: my-web-service - minLength: 1 properties: type: object - additionalProperties: {} - description: Map of property name to value for this entity - example: - port: '8080' - environment: dev + additionalProperties: true relations: type: array - description: List of relations for this entity items: $ref: '#/components/schemas/RelationDtoIn' - required: - - identifier - - name RelationDtoIn: type: object - description: Input DTO for an entity relation instance properties: name: type: string - description: Name of the relation (must match a template relation definition) - example: depends-on - minLength: 1 target_entity_identifiers: type: array - description: List of target entity identifiers for this relation - example: - - web-api-1 - - web-api-2 items: type: string - required: - - name - - target_entity_identifiers EntityDtoOut: type: object properties: @@ -724,7 +595,7 @@ components: type: string properties: type: object - additionalProperties: {} + additionalProperties: true relations: type: object additionalProperties: @@ -744,16 +615,19 @@ components: type: string name: type: string + ErrorResponse: + type: object + properties: + error: + type: string + errorDescription: + type: string PageableObject: type: object properties: offset: type: integer format: int64 - sort: - $ref: '#/components/schemas/SortObject' - unpaged: - type: boolean paged: type: boolean pageNumber: @@ -762,6 +636,10 @@ components: pageSize: type: integer format: int32 + sort: + $ref: '#/components/schemas/SortObject' + unpaged: + type: boolean SortObject: type: object properties: @@ -844,7 +722,7 @@ components: name: clientId flows: clientCredentials: - tokenUrl: https://my-oauth-server.com/as/token.oauth2 + tokenUrl: http://localhost:8080/auth/token bearer: type: http description: bearer authentication diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2b77241..8fc58b4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -38,4 +38,9 @@ public record Entity( List relations ) { + /// Compact constructor defensively copies mutable collections to keep the record immutable. + public Entity { + properties = properties != null ? List.copyOf(properties) : List.of(); + relations = relations != null ? List.copyOf(relations) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 9a0fb0b..04967db 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java @@ -43,4 +43,9 @@ public record EntityTemplate( List relationsDefinitions ) { + /// Compact constructor defensively copies mutable collections to preserve immutability. + public EntityTemplate { + propertiesDefinitions = propertiesDefinitions != null ? List.copyOf(propertiesDefinitions) : List.of(); + relationsDefinitions = relationsDefinitions != null ? List.copyOf(relationsDefinitions) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index e81360a..6109722 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -11,11 +11,7 @@ public record CorsProperties( List allowedOriginPatterns ) { public CorsProperties { - if (allowedOriginPatterns == null) { - allowedOriginPatterns = List.of(); - } - if (allowedOrigins == null) { - allowedOrigins = List.of(); - } + allowedOrigins = allowedOrigins != null ? List.copyOf(allowedOrigins) : List.of(); + allowedOriginPatterns = allowedOriginPatterns != null ? List.copyOf(allowedOriginPatterns) : List.of(); } } From b68ba4d1a2709f7eeb73a260fc7c61890de6e75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 11:19:15 +0200 Subject: [PATCH 29/51] feat(entity-graph): add a entity graph service and endpoint add include data parameter for showing properties in node --- .../model/entity_graph/EntityGraphNode.java | 8 ++++- .../entity_graph/EntityGraphService.java | 32 +++++++++++++------ .../api/configuration/SwaggerDescription.java | 2 ++ .../api/controller/EntityGraphController.java | 8 +++-- .../out/entity/EntityGraphNodeFlatDtoOut.java | 16 ++++++++-- .../entity/EntityGraphFlatDtoOutMapper.java | 16 ++++++++-- .../entity_graph/EntityGraphServiceTest.java | 18 +++++------ 7 files changed, 73 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java index 2a795f5..fff3564 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -2,6 +2,8 @@ import java.util.List; +import com.decathlon.idp_core.domain.model.entity.Property; + /// A node in the entity relationship graph, containing summary information /// and its resolved relations (recursively up to a configurable depth). /// @@ -11,17 +13,21 @@ /// - Providing a hierarchical view of entity connections /// /// @param templateIdentifier the template identifier this entity belongs to -/// @param summary the lightweight entity identification data +/// @param identifier the business identifier of the entity +/// @param name the human-readable name of the entity +/// @param properties the entity's property instances; empty when not requested /// @param relations the resolved outbound relations with their target graph nodes /// @param relationsAsTarget incoming relations where this entity is the target public record EntityGraphNode( String templateIdentifier, String identifier, String name, + List properties, List relations, List relationsAsTarget ) { public EntityGraphNode { + properties = properties != null ? List.copyOf(properties) : List.of(); relations = relations != null ? List.copyOf(relations) : List.of(); relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 367e154..e32f1ae 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -9,6 +9,7 @@ import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; @@ -49,10 +50,13 @@ public class EntityGraphService { /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @param includeProperties when true, each graph node carries the entity's full property list; + /// when false, properties are omitted to reduce response size /// @return the root graph node with resolved relations /// @throws EntityNotFoundException when no entity matches the given identifiers @Transactional(readOnly = true) - public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth) { + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, + boolean includeProperties) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); // Verify root entity exists before fetching the graph @@ -67,21 +71,24 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); // Build the graph from pre-loaded entities (no more database calls) - return buildGraphNode(rootKey, entityMap, effectiveDepth); + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties); } /// Builds a graph node from a pre-loaded entity map (no database calls). /// Recursively resolves both outbound and inbound relations from the cached entities. private EntityGraphNode buildGraphNode(EntityCompositeKey key, Map entityMap, - int remainingDepth) { + int remainingDepth, + boolean includeProperties) { Entity entity = entityMap.get(key); if (entity == null) { - return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), List.of(), List.of()); + return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), + List.of(), List.of(), List.of()); } if (remainingDepth <= 0) { - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), List.of(), List.of()); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + List.of(), List.of(), List.of()); } // Resolve outbound relations from pre-loaded entities @@ -92,7 +99,7 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, .map(targetId -> { // Relations only store identifier; look up by identifier across all entries EntityCompositeKey targetKey = findKeyByIdentifier(targetId, entityMap); - return buildGraphNode(targetKey, entityMap, remainingDepth - 1); + return buildGraphNode(targetKey, entityMap, remainingDepth - 1, includeProperties); }) .toList() )) @@ -100,9 +107,13 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Resolve inbound relations from pre-loaded entities List inboundRelations = buildRelationsAsTargetFromMap( - entity.identifier(), entityMap, remainingDepth - 1); + entity.identifier(), entityMap, remainingDepth - 1, includeProperties); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), outboundRelations, inboundRelations); + // Include properties only when explicitly requested to keep responses lean + List properties = includeProperties ? entity.properties() : List.of(); + + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + properties, outboundRelations, inboundRelations); } /// Looks up a composite key from the map by identifier alone. @@ -118,7 +129,8 @@ private EntityCompositeKey findKeyByIdentifier(String identifier, Map buildRelationsAsTargetFromMap(String targetIdentifier, Map entityMap, - int remainingDepth) { + int remainingDepth, + boolean includeProperties) { Map> sourcesByRelationName = new java.util.HashMap<>(); for (Map.Entry entry : entityMap.entrySet()) { @@ -136,7 +148,7 @@ private List buildRelationsAsTargetFromMap(String targetIde .map(e -> new EntityGraphRelation( e.getKey(), e.getValue().stream() - .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth)) + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, includeProperties)) .toList() )) .toList(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index c3b229e..23e72bd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -164,4 +164,6 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; + public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 872b846..ee8f58a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -5,6 +5,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; @@ -53,6 +54,7 @@ public class EntityGraphController { /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @param includeData when true, each node includes a data object with entity property values /// @return flat DTO containing nodes and edges arrays @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @@ -70,10 +72,12 @@ public EntityGraphFlatDtoOut getEntityGraph( @PathVariable @NotBlank String templateIdentifier, @PathVariable @NotBlank String entityIdentifier, @Parameter(description = PARAM_DEPTH_DESCRIPTION) - @RequestParam(defaultValue = "1") int depth) { + @RequestParam(defaultValue = "1") int depth, + @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) + @RequestParam(defaultValue = "false") boolean includeData) { EntityGraphNode graphNode = entityGraphService.getEntityGraph( - templateIdentifier, entityIdentifier, depth); + templateIdentifier, entityIdentifier, depth, includeData); return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java index 569941e..0ae498b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -1,10 +1,15 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -14,6 +19,9 @@ /// /// Used by frontend visualization tools (React Flow, Vis.js, Cytoscape) that expect /// entities and their relationships as separate, non-nested collections. +/// +/// The optional `data` field is populated only when `include_data=true` is requested, +/// containing property name-to-value pairs for the entity. @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphNodeFlatDtoOut( @@ -27,5 +35,9 @@ public record EntityGraphNodeFlatDtoOut( String templateIdentifier, @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) - String identifier + String identifier, + + @JsonInclude(Include.NON_EMPTY) + @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) + Map data ) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index d8874c2..c7ca7a4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -4,9 +4,11 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.SequencedSet; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; @@ -38,8 +40,7 @@ private EntityGraphFlatDtoOutMapper() { /// /// @param root the root [EntityGraphNode] returned by the domain service /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { - if (root == null) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } @@ -74,7 +75,8 @@ private static void traverse( } nodes.add(new EntityGraphNodeFlatDtoOut( - nodeId, node.name(), node.templateIdentifier(), node.identifier())); + nodeId, node.name(), node.templateIdentifier(), node.identifier(), + toDataMap(node))); // Traverse outbound relations: emit edge from currentNode → target for (EntityGraphRelation relation : node.relations()) { @@ -120,4 +122,12 @@ private static void addEdge( private static String nodeId(String templateIdentifier, String identifier) { return templateIdentifier + ":" + identifier; } + + /// Converts a node's property list to a name→value map for the `data` field. + /// Returns an empty map when there are no properties; the DTO's @JsonInclude(NON_EMPTY) + /// annotation ensures an empty map is omitted from the JSON output. + private static Map toDataMap(EntityGraphNode node) { + return node.properties().stream() + .collect(Collectors.toMap(p -> p.name(), p -> p.value())); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 5e38977..0d0efbf 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -75,7 +75,7 @@ void shouldThrowWhenRootEntityNotFound() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH)) + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH, false)) .isInstanceOf(EntityNotFoundException.class); verify(entityRepositoryPort).findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing"); @@ -97,7 +97,7 @@ void shouldReturnLeafNodeWhenNoRelations() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.identifier()).isEqualTo("api"); assertThat(result.name()).isEqualTo("API Service"); @@ -125,7 +125,7 @@ void shouldResolveOutboundRelations() { key(DB_TEMPLATE, "postgres"), db )); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.relations()).hasSize(1); assertThat(result.relations().getFirst().name()).isEqualTo("uses"); @@ -145,7 +145,7 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) .thenReturn(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.relations()).hasSize(1); // Fallback node uses identifier as both id and name when entity is not in map @@ -172,7 +172,7 @@ void shouldResolveInboundRelations() { key(DB_TEMPLATE, "postgres"), db )); - EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false); // postgres is targeted by api via "uses" assertThat(result.relationsAsTarget()).hasSize(1); @@ -195,7 +195,7 @@ void shouldClampDepthBelowOne() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 0); + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1); } @@ -210,7 +210,7 @@ void shouldClampDepthAboveTen() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 99); + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10); } @@ -240,7 +240,7 @@ void shouldReturnLeafNodeAtDepthBoundary() { key(INFRA_TEMPLATE, "server-1"), server )); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); // postgres node is included but its child relations are empty (remaining depth = 0) var dbNode = result.relations().getFirst().targets().getFirst(); @@ -272,7 +272,7 @@ void shouldResolveMultipleNamedRelations() { key(CACHE_TEMPLATE, "redis"), cache )); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.relations()).hasSize(2); var relationNames = result.relations().stream().map(r -> r.name()).toList(); From fb1cc279fae273b2c9184c293bc29c86d79d3d6c Mon Sep 17 00:00:00 2001 From: evebrnd Date: Wed, 20 May 2026 11:25:51 +0200 Subject: [PATCH 30/51] feat(core): implement entities search system --- .../domain/constant/ValidationMessages.java | 19 + .../exception/InvalidQueryException.java | 12 + .../domain/model/entity/SearchFilterNode.java | 48 ++ .../domain/model/enums/LogicalConnector.java | 11 + .../domain/model/enums/SearchOperator.java | 27 + .../domain/port/EntityRepositoryPort.java | 4 + .../domain/service/EntitySearchService.java | 94 +++ .../domain/service/EntityService.java | 19 + .../api/configuration/SwaggerDescription.java | 9 + .../api/controller/EntityController.java | 71 +- .../api/dto/in/EntitySearchRequestDtoIn.java | 67 ++ .../adapters/api/dto/in/FilterNodeDtoIn.java | 33 + .../api/handler/ApiExceptionHandler.java | 11 + .../api/mapper/entity/EntityDtoOutMapper.java | 86 ++- .../entity/EntitySearchDomainMapper.java | 179 +++++ .../persistence/PostgresEntityAdapter.java | 13 + .../repository/JpaEntityRepository.java | 3 +- .../EntitySearchSpecification.java | 315 +++++++++ .../service/EntitySearchServiceTest.java | 203 ++++++ .../api/controller/EntityControllerTest.java | 668 ++++++++++++++++++ .../mapper/EntitySearchDomainMapperTest.java | 378 ++++++++++ .../EntitySearchSpecificationTest.java | 297 ++++++++ .../test/R__2_Insert_entities_test_data.sql | 63 ++ .../search/search_request_in_templates.json | 11 + .../entity/v1/search/search_request_neq.json | 12 + .../search/search_request_or_templates.json | 11 + ...search_request_relation_name_contains.json | 9 + .../search_request_relation_name_eq.json | 9 + .../search_request_relations_as_target.json | 11 + .../v1/search/search_request_starts_with.json | 11 + .../search_request_template_and_property.json | 11 + ...earchEntities_200_byRelationsAsTarget.json | 25 + ...rchEntities_200_byTemplateAndProperty.json | 20 + 33 files changed, 2733 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/EntitySearchService.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntitySearchDomainMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/EntitySearchServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntitySearchDomainMapperTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_in_templates.json create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_neq.json create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_or_templates.json create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_relation_name_contains.json create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_relation_name_eq.json create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target.json create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_starts_with.json create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_template_and_property.json create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json 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 c023292..c50372b 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 @@ -63,4 +63,23 @@ public static String minMaxConstraintViolated(String constraint) { return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED .replace("{constraint}", constraint); } + + // Search filter validation messages + public static final String SEARCH_INVALID_CONNECTOR = "Invalid connector '%s'. Supported values: AND, OR"; + public static final String SEARCH_INVALID_OPERATOR = "Invalid operation '%s'. Supported values: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE"; + public static final String SEARCH_INVALID_FIELD = "Unknown field '%s'. Supported fields: template, identifier, name, relation, property.{name}, relation.{name}, relation.{name}.identifier, relation.{name}.name, relations_as_target.{name}.identifier, relations_as_target.{name}.name"; + public static final String SEARCH_TOO_MANY_CRITERIA = "Search filter exceeds maximum of %d total criteria"; + public static final String SEARCH_NESTING_TOO_DEEP = "Search filter exceeds maximum nesting depth of %d"; + public static final String SEARCH_CRITERION_MISSING_FIELD = "A criterion node must have a non-blank 'field'"; + public static final String SEARCH_CRITERION_MISSING_OPERATION = "A criterion node must have a non-blank 'operation'"; + public static final String SEARCH_CRITERION_MISSING_VALUE = "A criterion node must have a non-blank 'value'"; + public static final String SEARCH_GROUP_MISSING_CONNECTOR = "A group node must have a non-blank 'connector'"; + public static final String SEARCH_GROUP_MISSING_CRITERIA = "A group node must have a non-empty 'criteria' list"; + public static final String SEARCH_QUERY_TOO_LONG = "Search query must not exceed %d characters"; + public static final String SEARCH_NUMERIC_OPERATOR_REQUIRES_PROPERTY = "Operator '%s' is only valid for property.{name} fields"; + public static final String SEARCH_INVALID_SORT_FIELD = "Invalid sort field '%s'. Supported fields: identifier, name, templateIdentifier"; + public static final String SEARCH_PAGE_SIZE_TOO_LARGE = "Page size must not exceed %d"; + public static final String SEARCH_NUMERIC_OPERATOR_INVALID_VALUE = "Value '%s' is not a valid number for operator '%s'"; + public static final String SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH = "Property '%s' in template '%s' is of type %s; operators GT, GTE, LT, LTE require type NUMBER"; + } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryException.java b/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryException.java new file mode 100644 index 0000000..edf295f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryException.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.domain.exception; + +/// Domain exception thrown when a search filter or query contains invalid syntax. +/// +/// **Business semantics:** Signals that the caller provided a malformed search request. +/// This exception should be mapped to HTTP 400 Bad Request by the infrastructure layer. +public class InvalidQueryException extends RuntimeException { + + public InvalidQueryException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java new file mode 100644 index 0000000..7c129df --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java @@ -0,0 +1,48 @@ +package com.decathlon.idp_core.domain.model.entity; + +import java.util.List; + +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; + +/// A node in the search filter tree for entity search queries. +/// +/// **Business semantics:** A filter tree is composed of two types of nodes: +/// - [Group] — a logical group that combines child nodes with a [LogicalConnector] +/// (AND / OR / IN). Children may themselves be groups or leaf criteria, allowing +/// arbitrarily deep nesting. +/// - [Criterion] — a leaf predicate: field value. +/// +/// The root of the tree must be either a [Group] or a single [Criterion]. +/// An empty [Group] matches all entities. +/// +/// **Supported fields for [Criterion]:** +/// - `template` — filters by the entity template identifier +/// - `identifier` — filters by the entity identifier +/// - `name` — filters by the entity name +/// - `property.{name}` — filters by a named property value +/// - `relation.{name}` — filters by target entity identifier of a named relation +/// - `relation.{name}.identifier` — explicit form of the above +/// - `relation.{name}.name` — filters by target entity name of a named relation +/// - `relations_as_target.{name}.identifier` — filters by source entity identifier in a reverse relation +/// - `relations_as_target.{name}.name` — filters by source entity name in a reverse relation +public sealed interface SearchFilterNode { + + /// A logical group combining multiple child [SearchFilterNode]s with a connector. + /// + /// @param connector how child nodes are logically combined + /// @param nodes child nodes; an empty list matches all entities + record Group(LogicalConnector connector, List nodes) implements SearchFilterNode { + public Group { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); + } + } + + /// A leaf predicate in the search filter tree. + /// + /// @param field the entity field to filter on (see [SearchFilterNode] for supported fields) + /// @param operation the comparison operator to apply + /// @param value the value to compare against + record Criterion(String field, SearchOperator operation, String value) implements SearchFilterNode { + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java new file mode 100644 index 0000000..b167f3a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java @@ -0,0 +1,11 @@ +package com.decathlon.idp_core.domain.model.enums; + +/// Logical connectors for combining multiple filter nodes in a search query. +/// +/// **Business semantics:** +/// - [AND] — all child nodes must match +/// - [OR] — at least one child node must match +public enum LogicalConnector { + AND, + OR +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java new file mode 100644 index 0000000..6b861c0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java @@ -0,0 +1,27 @@ +package com.decathlon.idp_core.domain.model.enums; + +/// Operators supported by the entity search query DSL. +/// +/// **Business semantics:** +/// - [EQ] requires exact match (case-insensitive) +/// - [NEQ] requires the field to not exactly match (case-insensitive) +/// - [CONTAINS] requires the field to contain the value (case-insensitive substring) +/// - [NOT_CONTAINS] requires the field to not contain the value +/// - [STARTS_WITH] requires the field to start with the value (case-insensitive) +/// - [ENDS_WITH] requires the field to end with the value (case-insensitive) +/// - [GT] requires the field to be strictly greater than the value +/// - [GTE] requires the field to be greater than or equal to the value +/// - [LT] requires the field to be strictly less than the value +/// - [LTE] requires the field to be less than or equal to the value +public enum SearchOperator { + EQ, + NEQ, + CONTAINS, + NOT_CONTAINS, + STARTS_WITH, + ENDS_WITH, + GT, + GTE, + LT, + LTE +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 02584b9..3d692c2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -10,6 +10,7 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; /// Driven port defining the contract for [Entity] persistence operations. /// @@ -22,6 +23,7 @@ /// - `findByRelationIdIn()` enables reverse relationship navigation /// - `deletePropertiesByTemplateIdentifierAndPropertyName()` must remove all property instances matching the given names for entities of the specified template /// - `deleteRelationsByTemplateIdentifierAndRelationName()` must remove all relation instances matching the given names for entities of the specified template +/// - `search()` searches for entities across all templates using a nested filter tree and optional free-text query. /// /// **Transaction behavior:** Implementations should handle transaction boundaries /// appropriately for the underlying persistence technology. @@ -42,4 +44,6 @@ public interface EntityRepositoryPort { void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames); void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); + + Page search(SearchFilterNode filter, String query, Pageable pageable); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntitySearchService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntitySearchService.java new file mode 100644 index 0000000..7f970ef --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/EntitySearchService.java @@ -0,0 +1,94 @@ +package com.decathlon.idp_core.domain.service; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.InvalidQueryException; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; + +import lombok.AllArgsConstructor; + +/// Validates a [SearchFilterNode] tree for semantic correctness of numeric operators. +/// +/// **Responsibility:** When a caller uses a numeric operator (GT, GTE, LT, LTE) on a +/// property.{name} field, this service verifies that the corresponding property is +/// defined as [PropertyType#NUMBER] in the relevant entity template(s). +/// +/// **Template scope:** Template identifiers are inferred from template EQ criteria +/// anywhere in the filter tree. If no template constraint is present (template-agnostic search), +/// the property-type check is skipped — syntactic validation in the mapper already ensures the +/// value is a valid number. +/// +/// **Error handling:** Throws [InvalidQueryException] (HTTP 400) when a type mismatch is detected. +@Service +@AllArgsConstructor +public class EntitySearchService { + + private static final Set NUMERIC_OPERATORS = Set.of(SearchOperator.GT, SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE); + private static final String PROPERTY_PREFIX = "property."; + private static final String TEMPLATE_FIELD = "template"; + + private final EntityTemplateRepositoryPort entityTemplateRepository; + + /// Validates the filter tree for numeric operator / property type compatibility. + /// + /// @param filter the root of the search filter tree to validate + /// @throws InvalidQueryException when a numeric operator targets a non-NUMBER property + public void validate(SearchFilterNode filter) { + Set numericPropertyNames = collectNumericPropertyCriteria(filter); + if (numericPropertyNames.isEmpty()) { + return; + } + + Set templateIdentifiers = collectTemplateIdentifiers(filter); + if (templateIdentifiers.isEmpty()) { + return; // no template scope — skip type check + } + + for (String templateIdentifier : templateIdentifiers) { + entityTemplateRepository.findByIdentifier(templateIdentifier) + .ifPresent(template -> template.propertiesDefinitions().stream() + .filter(pd -> numericPropertyNames.contains(pd.name())) + .filter(pd -> pd.type() != PropertyType.NUMBER) + .findFirst() + .ifPresent(pd -> { + throw new InvalidQueryException( + ValidationMessages.SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH + .formatted(pd.name(), templateIdentifier, pd.type())); + })); + } + } + + private Set collectNumericPropertyCriteria(SearchFilterNode filter) { + Set names = new HashSet<>(); + collectCriteria(filter) + .filter(c -> NUMERIC_OPERATORS.contains(c.operation())) + .filter(c -> c.field().startsWith(PROPERTY_PREFIX)) + .map(c -> c.field().substring(PROPERTY_PREFIX.length())) + .forEach(names::add); + return names; + } + + private Set collectTemplateIdentifiers(SearchFilterNode filter) { + Set identifiers = new HashSet<>(); + collectCriteria(filter) + .filter(c -> TEMPLATE_FIELD.equals(c.field()) && c.operation() == SearchOperator.EQ) + .map(SearchFilterNode.Criterion::value) + .forEach(identifiers::add); + return identifiers; + } + + private Stream collectCriteria(SearchFilterNode node) { + return switch (node) { + case SearchFilterNode.Criterion c -> Stream.of(c); + case SearchFilterNode.Group g -> g.nodes().stream().flatMap(this::collectCriteria); + }; + } +} 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 b21d871..bbc9099 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 @@ -10,6 +10,7 @@ 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.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; @@ -33,6 +34,7 @@ public class EntityService { private final EntityRepositoryPort entityRepository; private final EntityTemplateRepositoryPort entityTemplateRepository; + private final EntitySearchService entitySearchService; /// Retrieves entities filtered by template with existence validation. /// @@ -94,4 +96,21 @@ public Entity createEntity(@Valid Entity entity) { // Add validations return entityRepository.save(entity); } + + /// Searches for entities across all templates using a nested filter tree and optional free-text query. + /// + /// **Contract:** Executes a global entity search using the provided filter tree and optional text query. + /// Not scoped to a single template; include a template criterion in the filter + /// to scope the result to a specific template. + /// + /// @param filter root node of the search filter tree; an empty group returns all entities + /// @param query optional free-text string searched across identifier, name, templateIdentifier, + /// and all property values; null means no text restriction + /// @param pageable pagination configuration + /// @return paginated entities matching the filter and query + @Transactional + public Page searchEntities(SearchFilterNode filter, String query, Pageable pageable) { + entitySearchService.validate(filter); + return entityRepository.search(filter, query, pageable); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 53aacc8..042beec 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -132,4 +132,13 @@ public class SwaggerDescription { public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + + /// Search API endpoint constants + public static final String ENDPOINT_POST_SEARCH_SUMMARY = "Search entities"; + public static final String ENDPOINT_POST_SEARCH_DESCRIPTION = """ + Search for entities across all templates using a nested filter query. \ + Supports complex logical compositions (AND / OR / IN) of filter criteria on \ + template, identifier, name, properties, relations, and reverse relations."""; + public static final String RESPONSE_SEARCH_SUCCESS = "Entities retrieved successfully"; + public static final String RESPONSE_INVALID_SEARCH_QUERY = "Invalid search filter"; } 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..40c168c 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 @@ -8,6 +8,8 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_SEARCH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_SEARCH_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PAGE_DESCRIPTION; @@ -19,12 +21,20 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_ENTITY_DATA; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_PAGINATION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_SEARCH_QUERY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_SEARCH_SUCCESS; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; +import java.util.Set; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -34,19 +44,22 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.InvalidQueryException; import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; 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.in.EntitySearchRequestDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; 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.entity.EntityDtoInMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntitySearchDomainMapper; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -65,11 +78,15 @@ @RequestMapping("/api/v1/entities") @Tag(name = "Entities Management", description = "Operations related to entity management") @AllArgsConstructor +@Validated public class EntityController { private final EntityService entityService; private final EntityDtoOutMapper entityDtoOutMapper; private final EntityDtoInMapper entityDtoInMapper; + private final EntitySearchDomainMapper entitySearchDomainMapper; + + private static final Set ALLOWED_SORT_FIELDS = Set.of("identifier", "name", "templateIdentifier"); /// Returns paginated entities filtered by template with HTTP pagination support. /// @@ -142,4 +159,54 @@ public EntityDtoOut createEntity(@PathVariable String templateIdentifier, @Reque Entity savedEntity = entityService.createEntity(entity); return entityDtoOutMapper.fromEntity(savedEntity); } + + /// Searches for entities across all templates using a nested filter query. + /// + /// **API contract:** Accepts a JSON body with a nested filter tree, pagination, and + /// sorting parameters. Returns a paginated list of entities matching the filter. + /// No template scoping is applied by default; include a template criterion + /// in the filter to scope results to a specific template. + /// + /// @param searchRequest the search request body with filter, page, size, and sort + /// @return paginated entity DTOs matching the filter + @Operation(summary = ENDPOINT_POST_SEARCH_SUMMARY, description = ENDPOINT_POST_SEARCH_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_SEARCH_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @PostMapping("/search") + @ResponseStatus(OK) + public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { + entitySearchDomainMapper.validateQuery(searchRequest.query()); + SearchFilterNode filter = entitySearchDomainMapper.toDomain(searchRequest.filter()); + Pageable pageable = buildPageable(searchRequest); + Page entities = entityService.searchEntities(filter, searchRequest.query(), pageable); + return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(entities); + } + + private Pageable buildPageable(EntitySearchRequestDtoIn searchRequest) { + int page = searchRequest.page(); + int size = searchRequest.size() > 0 ? searchRequest.size() : 20; + if (size > EntitySearchDomainMapper.MAX_PAGE_SIZE) { + throw new InvalidQueryException( + ValidationMessages.SEARCH_PAGE_SIZE_TOO_LARGE.formatted(EntitySearchDomainMapper.MAX_PAGE_SIZE)); + } + if (searchRequest.sort() == null || searchRequest.sort().isBlank()) { + return PageRequest.of(page, size); + } + Sort sort = parseSortExpression(searchRequest.sort()); + return PageRequest.of(page, size, sort); + } + + private Sort parseSortExpression(String sortExpression) { + String[] parts = sortExpression.split(":"); + String property = parts[0].trim(); + if (!ALLOWED_SORT_FIELDS.contains(property)) { + throw new InvalidQueryException( + ValidationMessages.SEARCH_INVALID_SORT_FIELD.formatted(property)); + } + Sort.Direction direction = (parts.length > 1 && "desc".equalsIgnoreCase(parts[1].trim())) + ? Sort.Direction.DESC + : Sort.Direction.ASC; + return Sort.by(direction, property); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java new file mode 100644 index 0000000..48e132f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java @@ -0,0 +1,67 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Request body for the {@code POST /api/v1/entities/search} endpoint. +/// +/// Supports two complementary search modes that can be combined: +///
    +///
  • {@code query} — a free-text string searched across identifier, name, +/// templateIdentifier, and all property values (case-insensitive CONTAINS).
  • +///
  • {@code filter} — a structured, nested filter tree for precise queries.
  • +///
+/// When both are provided the results must satisfy both (AND semantics). +/// +///

Free-text search example

+///
{@code
+/// { "query": "checkout", "page": 0, "size": 20 }
+/// }
+/// +///

Structured filter example

+///
{@code
+/// {
+///   "filter": {
+///     "connector": "AND",
+///     "criteria": [
+///       { "field": "template",  "operation": "EQ", "value": "microservice" },
+///       { "field": "property.language", "operation": "EQ", "value": "JAVA" }
+///     ]
+///   },
+///   "page": 0,
+///   "size": 20,
+///   "sort": "identifier:asc"
+/// }
+/// }
+@Schema(description = "Request body for the POST /api/v1/entities/search endpoint") +public record EntitySearchRequestDtoIn( + + @Schema(description = "Free-text search string. When present, returns entities whose identifier, name, templateIdentifier, or any property value contains this string (case-insensitive). Can be combined with filter.", example = "checkout") + String query, + + @Schema(description = "Root node of the search filter tree. May be omitted or null to return all entities.") + FilterNodeDtoIn filter, + + @Schema(description = "Zero-based page index. Defaults to 0.", defaultValue = "0", example = "0") + Integer page, + + @Schema(description = "Number of entities per page. Defaults to 20.", defaultValue = "20", example = "20") + Integer size, + + @Schema(description = "Sort expression in the form field:asc|desc, e.g. identifier:asc.", example = "identifier:asc") + String sort +) { + public EntitySearchRequestDtoIn { + if (size == null || size <= 0) { + size = 20; + } + if (page == null || page < 0) { + page = 0; + } + if (query != null) { + query = query.strip(); + if (query.isBlank()) { + query = null; + } + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java new file mode 100644 index 0000000..047bf2d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java @@ -0,0 +1,33 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// A node in the search filter tree, used in the request body of +/// `POST /api/v1/entities/search`. +/// +/// Each node is either a **group** or a **criterion** (leaf node): +/// A **group** must have a `connector` (AND/OR) and a non-empty `criteria` list. +/// A **criterion** must have a `field`, an `operation`, and a `value`. +/// +/// Both types share the same JSON object shape; unused fields should be omitted or set to null. +@Schema(description = "A node in the search filter tree. Either a logical group (connector + criteria) or a leaf criterion (field + operation + value).") +public record FilterNodeDtoIn( + + @Schema(description = "Logical connector for a group node. One of: AND, OR. Required for group nodes.", example = "AND") + String connector, + + @Schema(description = "Child filter nodes for a group node. Required for group nodes (must be non-empty).") + List criteria, + + @Schema(description = "Field to filter on for a criterion node. Required for leaf nodes. Examples: template, identifier, name, relation, property.language, relation.api-link, relation.api-link.identifier, relations_as_target.api-link.name", example = "template") + String field, + + @Schema(description = "Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for leaf nodes.", example = "EQ") + String operation, + + @Schema(description = "Value to compare against for a criterion node. Required for leaf nodes.", example = "microservice") + String value +) { +} 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 70ca673..0235fab 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 @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.InvalidQueryException; import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; @@ -67,6 +68,16 @@ public ResponseEntity handleTemplateNotFoundException(EntityTempl return ResponseEntity.status(NOT_FOUND).body(errorResponse); } + /// Handles domain exception for malformed search filter or query strings. + /// + /// **HTTP mapping:** Maps domain [InvalidQueryException] to HTTP 400 Bad Request + /// so API consumers receive clear feedback about invalid search request syntax. + @ExceptionHandler(InvalidQueryException.class) + public ResponseEntity handleInvalidQueryException(InvalidQueryException ex) { + log.warn("Invalid filter query: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles domain exception when entity templates already exist. /// /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP 409 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 2bd2b04..44a9405 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 @@ -66,6 +66,56 @@ public EntityDtoOut fromEntity(Entity entity) { return fromEntityUsingEntityTemplate(entity, entityTemplate); } + /// Maps a page of domain entities from potentially multiple templates to API DTOs. + /// + /// **Multi-template optimisation:** Resolves templates in batch by grouping entities + /// by their templateIdentifier, then reuses the same summary and relation maps built + /// for the whole page to minimise database round-trips. + /// + /// @param entities paginated domain entities, possibly spanning several templates + /// @return paginated API DTOs with complete relationship data + public Page fromEntitiesSearchPageToDtoPage(Page entities) { + if (entities.isEmpty()) { + return entities.map(entity -> entityDtoOutMapper(entity, Map.of(), Map.of())); + } + + Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); + Map> relationTargetOwnershipsMap = + buildRelationsAsTargetSummaryMapByPage(entities); + + // Batch-load all unique templates referenced by the page + Map templatesByIdentifier = entities.stream() + .map(Entity::templateIdentifier) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toMap( + Function.identity(), + entityTemplateService::getEntityTemplateByIdentifier)); + + return entities.map(entity -> { + EntityTemplate template = templatesByIdentifier.get(entity.templateIdentifier()); + if (template == null) { + return entityDtoOutMapper(entity, pageEntitiesSummaries, relationTargetOwnershipsMap); + } + return fromEntityUsingEntityTemplateAndSummaryMap( + entity, template, pageEntitiesSummaries, relationTargetOwnershipsMap); + }); + } + + private EntityDtoOut entityDtoOutMapper( + Entity entity, + Map summaries, + Map> relationsAsTargetMap) { + return EntityDtoOut.builder() + .templateIdentifier(entity.templateIdentifier()) + .name(entity.name()) + .identifier(entity.identifier()) + .properties(Collections.emptyMap()) + .relations(mapRelationsDto(entity, summaries)) + .relationsAsTarget(mapRelationsAsTargetDto(entity, relationsAsTargetMap)) + .build(); + } + /// Maps paginated domain entities to API DTOs with optimized bulk operations. /// /// **Performance optimization:** Batches template resolution and relationship lookups @@ -88,13 +138,12 @@ public Page fromEntitiesPageToDtoPage(Page entities, pageEntitiesSummaries, relationTargetOwnershipsMap)); } - /// Maps a single entity to its DTO using the provided entity template. /// /// @param entity the entity to map /// @param entityTemplate the template for property type mapping /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { Map props = mapPropertiesDto(entity, entityTemplate); List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); @@ -120,8 +169,7 @@ private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate /// /// @param entity the entity to map /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation - /// targets + /// @param relatedEntitiesSummaries map of entity summaries for relation targets /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return the mapped DTO private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, @@ -145,12 +193,12 @@ private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, E /// Maps the properties of an entity to a map of property names to typed values, /// using the entity template for type conversion. + /// Properties with a null value are excluded from the output. /// /// @param entity the entity whose properties to map /// @param entityTemplate the template for property type mapping /// @return a map of property names to typed values private Map mapPropertiesDto(Entity entity, EntityTemplate entityTemplate) { - if (entity.properties() == null) { return Collections.emptyMap(); } @@ -191,7 +239,7 @@ private Object convertPropertyValue(Property property, PropertyDefinition defini /// Maps the relations of an entity to a map of relation names to lists of target /// entity summaries. /// - /// @param entity the entity whose relations to map + /// @param entity the entity whose relations to map /// @param relatedEntitiesSummaries map of entity summaries for relation targets /// @return a map of relation names to lists of target entity summaries private Map> mapRelationsDto(Entity entity, @@ -207,18 +255,15 @@ private Map> mapRelationsDto(Entity entity, Collectors.toList()))); } - /// /// Maps the relations-as-target for an entity to a map of relation names to /// lists of source entity summaries. /// - /// @param entity the entity whose relations-as-target to - /// map + /// @param entity the entity whose relations-as-target to map /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return a map of relation names to lists of source entity summaries private Map> mapRelationsAsTargetDto(Entity entity, Map> relationTargetOwnershipsMap) { - List relationAsTargetSummaries = relationTargetOwnershipsMap - .get(entity.identifier()); + List relationAsTargetSummaries = relationTargetOwnershipsMap.get(entity.identifier()); if (relationAsTargetSummaries == null) { return Collections.emptyMap(); } @@ -231,10 +276,10 @@ private Map> mapRelationsAsTargetDto(Entity entit Collectors.toList()))); } - /// Builds a map of relation target ownerships for a list of entities, grouping + /// Builds a map of relation target ownerships for a page of entities, grouping /// by target entity identifier. /// - /// @param entitiesPage the list of entities to analyze + /// @param entitiesPage the page of entities to analyze /// @return a map from target entity identifier to list of relation-as-target summaries private Map> buildRelationsAsTargetSummaryMapByPage( Page entitiesPage) { @@ -249,13 +294,11 @@ private Map> buildRelationsAsTargetSummary .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); } - /// /// Builds a map of relation target ownerships for a single entity, grouping by /// target entity identifier. /// /// @param entity the entity to analyze - /// @return a map from target entity identifier to list of relation-as-target - /// summaries + /// @return a map from target entity identifier to list of relation-as-target summaries private Map> buildRelationsAsTargetSummaryMapByEntity(Entity entity) { if (entity == null || entity.identifier() == null) { return Collections.emptyMap(); @@ -266,8 +309,7 @@ private Map> buildRelationsAsTargetSummary .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); } - /// Gets all unique target entity identifiers from the relations of a single - /// entity. + /// Gets all unique target entity identifiers from the relations of a single entity. /// /// @param entity the entity to analyze /// @return a list of unique target entity identifiers @@ -279,9 +321,7 @@ private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { .collect(Collectors.toSet())); } - /// - /// Gets all unique target entity identifiers from the relations of all entities - /// in a page. + /// Gets all unique target entity identifiers from the relations of all entities in a page. /// /// @param entities the page of entities to analyze /// @return a list of unique target entity identifiers @@ -292,11 +332,9 @@ private List getUniqueTargetIdentifiersInPage(Page entities) { : entity.relations().stream() .flatMap(rel -> rel.targetEntityIdentifiers().stream())) .collect(Collectors.toSet())); - } - /// Builds a map of entity summaries for all unique target identifiers in a page - /// of entities. + /// Builds a map of entity summaries for all unique target identifiers in a page of entities. /// /// @param entities the page of entities /// @return a map from entity identifier to summary DTO diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntitySearchDomainMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntitySearchDomainMapper.java new file mode 100644 index 0000000..6adb29a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntitySearchDomainMapper.java @@ -0,0 +1,179 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.InvalidQueryException; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.FilterNodeDtoIn; + +/// Maps a [FilterNodeDtoIn] tree to its domain counterpart [SearchFilterNode]. +/// +/// **Validation responsibilities:** +/// - Validates that each node has the required fields for its type (group vs. criterion). +/// - Validates connector and operation values against known enums. +/// - Validates field names against the supported field syntax. +/// - Enforces safety limits: maximum nesting depth and maximum total criteria count. +/// +/// Throws [InvalidQueryException] for any validation failure so that the caller +/// (the [ApiExceptionHandler]) can translate it to HTTP 400. +@Component +public class EntitySearchDomainMapper { + + public static final int MAX_NESTING_DEPTH = 5; + public static final int MAX_TOTAL_CRITERIA = 50; + public static final int MAX_QUERY_LENGTH = 255; + public static final int MAX_PAGE_SIZE = 500; + + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + private static final Set SIMPLE_FIELDS = Set.of("template", "identifier", "name", "relation"); + private static final Set NUMERIC_OPERATORS = + Set.of(SearchOperator.GT, SearchOperator.GTE, SearchOperator.LT, + SearchOperator.LTE); + + /// Validates the free-text `query` string from the search request. + /// + /// @param query the query string to validate; may be null (no-op) + /// @throws InvalidQueryException when the query exceeds the maximum length + public void validateQuery(String query) { + if (query != null && query.length() > MAX_QUERY_LENGTH) { + throw new InvalidQueryException( + ValidationMessages.SEARCH_QUERY_TOO_LONG.formatted(MAX_QUERY_LENGTH)); + } + } + + /// Converts a nullable [FilterNodeDtoIn] to a [SearchFilterNode]. + /// + /// @param dto the root node DTO; may be null, in which case an empty group is returned + /// @return the domain representation of the filter tree + /// @throws InvalidQueryException when the DTO tree contains validation errors + public SearchFilterNode toDomain(FilterNodeDtoIn dto) { + if (dto == null) { + return new SearchFilterNode.Group(LogicalConnector.AND, List.of()); + } + var counter = new int[]{0}; + return convertNode(dto, 0, counter); + } + + private SearchFilterNode convertNode(FilterNodeDtoIn dto, int depth, int[] criteriaCounter) { + if (depth > MAX_NESTING_DEPTH) { + throw new InvalidQueryException( + ValidationMessages.SEARCH_NESTING_TOO_DEEP.formatted(MAX_NESTING_DEPTH)); + } + if (isGroupNode(dto)) { + return convertGroup(dto, depth, criteriaCounter); + } + return convertCriterion(dto, criteriaCounter); + } + + private boolean isGroupNode(FilterNodeDtoIn dto) { + return dto.connector() != null || dto.criteria() != null; + } + + private SearchFilterNode.Group convertGroup(FilterNodeDtoIn dto, int depth, int[] criteriaCounter) { + if (dto.connector() == null || dto.connector().isBlank()) { + throw new InvalidQueryException(ValidationMessages.SEARCH_GROUP_MISSING_CONNECTOR); + } + if (dto.criteria() == null || dto.criteria().isEmpty()) { + throw new InvalidQueryException(ValidationMessages.SEARCH_GROUP_MISSING_CRITERIA); + } + + var connector = parseConnector(dto.connector()); + List children = dto.criteria().stream() + .map(child -> convertNode(child, depth + 1, criteriaCounter)) + .toList(); + + return new SearchFilterNode.Group(connector, children); + } + + private SearchFilterNode.Criterion convertCriterion(FilterNodeDtoIn dto, int[] criteriaCounter) { + if (dto.field() == null || dto.field().isBlank()) { + throw new InvalidQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_FIELD); + } + if (dto.operation() == null || dto.operation().isBlank()) { + throw new InvalidQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_OPERATION); + } + if (dto.value() == null || dto.value().isBlank()) { + throw new InvalidQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_VALUE); + } + + criteriaCounter[0]++; + if (criteriaCounter[0] > MAX_TOTAL_CRITERIA) { + throw new InvalidQueryException( + ValidationMessages.SEARCH_TOO_MANY_CRITERIA.formatted(MAX_TOTAL_CRITERIA)); + } + + var operator = parseOperator(dto.operation()); + validateField(dto.field()); + validateNumericOperatorConstraints(operator, dto.field(), dto.value()); + + return new SearchFilterNode.Criterion(dto.field(), operator, dto.value()); + } + + private LogicalConnector parseConnector(String raw) { + try { + return LogicalConnector.valueOf(raw.toUpperCase()); + } catch (IllegalArgumentException _) { + throw new InvalidQueryException( + ValidationMessages.SEARCH_INVALID_CONNECTOR.formatted(raw)); + } + } + + private SearchOperator parseOperator(String raw) { + try { + return SearchOperator.valueOf(raw.toUpperCase()); + } catch (IllegalArgumentException _) { + throw new InvalidQueryException( + ValidationMessages.SEARCH_INVALID_OPERATOR.formatted(raw)); + } + } + + private void validateField(String field) { + if (SIMPLE_FIELDS.contains(field)) { + return; + } + if (field.startsWith(PROPERTY_PREFIX) && field.length() > PROPERTY_PREFIX.length()) { + return; + } + if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + validateRelationsAsTargetField(field); + return; + } + if (field.startsWith(RELATION_PREFIX) && field.length() > RELATION_PREFIX.length()) { + return; + } + throw new InvalidQueryException(ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + } + + private void validateNumericOperatorConstraints(SearchOperator operator, String field, String value) { + if (!NUMERIC_OPERATORS.contains(operator)) { + return; + } + if (!field.startsWith(PROPERTY_PREFIX) || field.length() <= PROPERTY_PREFIX.length()) { + throw new InvalidQueryException( + ValidationMessages.SEARCH_NUMERIC_OPERATOR_REQUIRES_PROPERTY.formatted(operator)); + } + try { + new BigDecimal(value); + } catch (NumberFormatException _) { + throw new InvalidQueryException( + ValidationMessages.SEARCH_NUMERIC_OPERATOR_INVALID_VALUE.formatted(value, operator)); + } + } + + private void validateRelationsAsTargetField(String field) { + String rest = field.substring(RELATIONS_AS_TARGET_PREFIX.length()); + int dot = rest.indexOf('.'); + if (dot <= 0 || dot == rest.length() - 1) { + throw new InvalidQueryException(ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 680c482..a646dee 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -5,15 +5,19 @@ import java.util.Optional; import java.util.UUID; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; +import com.decathlon.idp_core.infrastructure.adapters.persistence.specification.EntitySearchSpecification; import lombok.RequiredArgsConstructor; @@ -64,4 +68,13 @@ public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateI public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames) { jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, relationNames); } + + @Override + public Page search(SearchFilterNode filter, String query, Pageable pageable) { + Specification spec = EntitySearchSpecification.of(filter); + if (query != null && !query.isBlank()) { + spec = spec.and(EntitySearchSpecification.globalTextSearch(query)); + } + return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 44e8ac9..761b6b3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -17,7 +18,7 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; @Repository -public interface JpaEntityRepository extends JpaRepository { +public interface JpaEntityRepository extends JpaRepository, JpaSpecificationExecutor { @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") List findByIdentifierIn(List identifiers); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java new file mode 100644 index 0000000..5deba0a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -0,0 +1,315 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; + +import java.math.BigDecimal; +import java.util.List; + +import org.hibernate.query.criteria.HibernateCriteriaBuilder; +import org.springframework.data.jpa.domain.Specification; + +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/// Builds a JPA [Specification] for [EntityJpaEntity] from a [SearchFilterNode] tree. +/// +/// **Query strategy:** +/// - [SearchFilterNode.Group] nodes are translated recursively: AND → Specification::and, +/// OR → Specification::or. +/// - [SearchFilterNode.Criterion] nodes are translated based on the field prefix: +/// - `template` → direct predicate on templateIdentifier +/// - `identifier` / `name` → direct predicates on the entity root +/// - `property.{name}` → INNER JOIN on the `properties` collection +/// - `relation.{name}` / `relation.{name}.identifier|name` → JOIN on +/// `relations` with optional sub-query for target entity properties +/// - `relations_as_target.{name}.identifier|name` → correlated sub-query +/// that finds entities targeted by qualifying reverse relations +/// +/// **Security:** LIKE-based operators ([SearchOperator#CONTAINS], [SearchOperator#NOT_CONTAINS], +/// [SearchOperator#STARTS_WITH], [SearchOperator#ENDS_WITH]) escape SQL wildcards (`%` and +/// `_`) in user-supplied values to prevent unintended pattern matching. +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class EntitySearchSpecification { + + private static final char LIKE_ESCAPE_CHAR = '\\'; + private static final String TEMPLATE_IDENTIFIER = "templateIdentifier"; + private static final String IDENTIFIER = "identifier"; + private static final String NAME = "name"; + private static final String RELATION = "relation"; + private static final String RELATIONS = "relations"; + private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + + /// Builds a [Specification] from the root [SearchFilterNode]. + /// + /// @param filter the root of the search filter tree + /// @return a composed [Specification] matching the filter tree + public static Specification of(SearchFilterNode filter) { + return build(filter); + } + + /// Builds a global free-text search [Specification] that matches entities whose + /// `identifier`, `name`, `templateIdentifier`, or any property value contains the given string (case-insensitive). + /// + /// The four conditions are combined with OR so that a match on any field is sufficient. + /// The "any property" branch uses a correlated EXISTS subquery to avoid row multiplication. + /// + /// @param query the search string; must be non-null and non-blank + /// @return a [Specification] implementing the global text search + public static Specification globalTextSearch(String query) { + String escaped = escapeLikeWildcards(query.toLowerCase()); + String pattern = "%" + escaped + "%"; + + Specification byIdentifier = + (root, q, cb) -> cb.like(cb.lower(root.get(IDENTIFIER)), pattern, LIKE_ESCAPE_CHAR); + + Specification byName = + (root, q, cb) -> cb.like(cb.lower(root.get(NAME)), pattern, LIKE_ESCAPE_CHAR); + + Specification byTemplate = + (root, q, cb) -> cb.like(cb.lower(root.get(TEMPLATE_IDENTIFIER)), pattern, LIKE_ESCAPE_CHAR); + + Specification byAnyProperty = (root, queryCtx, cb) -> { + // Correlated EXISTS: does this entity have at least one property whose value matches? + var sub = queryCtx.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var propJoin = subRoot.join("properties"); + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + cb.like(cb.lower(propJoin.get("value").as(String.class)), pattern, LIKE_ESCAPE_CHAR) + ); + return cb.exists(sub); + }; + + return byIdentifier.or(byName).or(byTemplate).or(byAnyProperty); + } + + private static Specification build(SearchFilterNode node) { + return switch (node) { + case SearchFilterNode.Group g -> buildGroup(g); + case SearchFilterNode.Criterion c -> buildCriterion(c); + }; + } + + private static Specification buildGroup(SearchFilterNode.Group group) { + var nodes = group.nodes(); + if (nodes.isEmpty()) { + return (root, query, cb) -> cb.conjunction(); // empty group matches all + } + + List> specs = nodes.stream().map(EntitySearchSpecification::build).toList(); + + return switch (group.connector()) { + case AND -> specs.stream().reduce(Specification::and).orElseThrow(); + case OR -> specs.stream().reduce(Specification::or).orElseThrow(); + }; + } + + // --- Field-based criterion dispatch --- + + private static Specification buildCriterion(SearchFilterNode.Criterion c) { + var field = c.field(); + if ("template".equals(field)) { + return (root, query, cb) -> buildPredicate(cb, root.get(TEMPLATE_IDENTIFIER), c.operation(), c.value()); + } + if (IDENTIFIER.equals(field)) { + return (root, query, cb) -> buildPredicate(cb, root.get(IDENTIFIER), c.operation(), c.value()); + } + if (NAME.equals(field)) { + return (root, query, cb) -> buildPredicate(cb, root.get(NAME), c.operation(), c.value()); + } + if (field.startsWith(PROPERTY_PREFIX)) { + return propertySpec(c, field.substring(PROPERTY_PREFIX.length())); + } + if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + return relationsAsTargetSpec(c, field.substring(RELATIONS_AS_TARGET_PREFIX.length())); + } + if (RELATION.equals(field)) { + return relationNameSpec(c); + } + if (field.startsWith(RELATION_PREFIX)) { + return relationSpec(c, field.substring(RELATION_PREFIX.length())); + } + throw new IllegalArgumentException("Unknown search field: " + field); + } + + // --- Property spec --- + + private static Specification propertySpec(SearchFilterNode.Criterion c, String propertyName) { + return (root, query, cb) -> { + query.distinct(true); + Join propJoin = root.join("properties"); + return cb.and( + cb.equal(propJoin.get(NAME), propertyName), + buildPredicate(cb, propJoin.get("value"), c.operation(), c.value()) + ); + }; + } + + // --- Relation specs --- + + private static Specification relationNameSpec(SearchFilterNode.Criterion c) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + return buildPredicate(cb, relJoin.get(NAME), c.operation(), c.value()); + }; + } + + private static Specification relationSpec(SearchFilterNode.Criterion c, String relationPart) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex > 0) { + // relation.{name}.{identifier|name} → filter by target entity property with a subquery + String relationName = relationPart.substring(0, dotIndex); + String property = relationPart.substring(dotIndex + 1); + return relationPropertySpec(c, relationName, property); + } + // relation.{name} → filter by target entity identifier + return relationEntitySpec(c, relationPart); + } + + private static Specification relationEntitySpec(SearchFilterNode.Criterion c, String relationName) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + return cb.and( + cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, targetJoin, c.operation(), c.value()) + ); + }; + } + + private static Specification relationPropertySpec( + SearchFilterNode.Criterion c, String relationName, String property) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + + // Subquery: find target entity identifiers whose identifier/name matches + var subquery = query.subquery(String.class); + var subRoot = subquery.from(EntityJpaEntity.class); + subquery.select(subRoot.get(IDENTIFIER)) + .where(buildPredicate(cb, subRoot.get(property), c.operation(), c.value())); + + return cb.and( + cb.equal(relJoin.get(NAME), relationName), + cb.in(targetIdJoin).value(subquery) + ); + }; + } + + // --- Relations-as-target specs --- + + private static Specification relationsAsTargetSpec( + SearchFilterNode.Criterion c, String relPart) { + int dotIndex = relPart.indexOf('.'); + if (dotIndex <= 0) { + throw new IllegalArgumentException( + "Invalid field 'relations_as_target." + relPart + + "': expected form relations_as_target.{relationName}.{identifier|name}"); + } + String relationName = relPart.substring(0, dotIndex); + String property = relPart.substring(dotIndex + 1); // identifier or name + + return (root, query, cb) -> { + // Subquery: collect target identifiers from relations named + // whose source entity's matches the criterion. + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where( + cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, sourceRoot.get(property), c.operation(), c.value()) + ); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } + + // --- Predicate builder --- + + private static Predicate buildPredicate( + CriteriaBuilder cb, + Expression field, + SearchOperator operator, + String value) { + if (isNumericOperator(operator)) { + return buildNumericPredicate(cb, field, operator, new BigDecimal(value)); + } + Expression stringField = field.as(String.class); + return switch (operator) { + case EQ -> cb.equal(cb.lower(stringField), value.toLowerCase()); + case NEQ -> cb.notEqual(cb.lower(stringField), value.toLowerCase()); + case CONTAINS -> { + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.like(cb.lower(stringField), "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case NOT_CONTAINS -> { + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.notLike(cb.lower(stringField), "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case STARTS_WITH -> { + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.like(cb.lower(stringField), escaped + "%", LIKE_ESCAPE_CHAR); + } + case ENDS_WITH -> { + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.like(cb.lower(stringField), "%" + escaped, LIKE_ESCAPE_CHAR); + } + default -> throw new IllegalStateException("Unhandled operator: " + operator); + }; + } + + private static boolean isNumericOperator(SearchOperator operator) { + return switch (operator) { + case GT, GTE, LT, LTE -> true; + default -> false; + }; + } + + private static Predicate buildNumericPredicate( + CriteriaBuilder cb, + Expression field, + SearchOperator operator, + BigDecimal numericValue) { + // Use HibernateCriteriaBuilder.cast() to generate an explicit SQL CAST(field AS NUMERIC). + // The property value column is VARCHAR; without an explicit cast PostgreSQL would reject + // the comparison with a numeric literal. + Expression numericField = + ((HibernateCriteriaBuilder) cb).cast( + (org.hibernate.query.criteria.JpaExpression) field, BigDecimal.class); + return switch (operator) { + case GT -> cb.greaterThan(numericField, numericValue); + case GTE -> cb.greaterThanOrEqualTo(numericField, numericValue); + case LT -> cb.lessThan(numericField, numericValue); + case LTE -> cb.lessThanOrEqualTo(numericField, numericValue); + default -> throw new IllegalStateException("Not a numeric operator: " + operator); + }; + } + + /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are + /// treated as literal characters rather than pattern metacharacters. + static String escapeLikeWildcards(String value) { + return value + .replace(String.valueOf(LIKE_ESCAPE_CHAR), LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) + .replace("%", LIKE_ESCAPE_CHAR + "%") + .replace("_", LIKE_ESCAPE_CHAR + "_"); + } + +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/EntitySearchServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/EntitySearchServiceTest.java new file mode 100644 index 0000000..403f6bc --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/EntitySearchServiceTest.java @@ -0,0 +1,203 @@ +package com.decathlon.idp_core.domain.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +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.InvalidQueryException; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +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.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; + +/// Unit tests for [EntitySearchService]. +@DisplayName("SearchFilterValidationService") +class EntitySearchServiceTest { + + private final EntityTemplateRepositoryPort repository = mock(EntityTemplateRepositoryPort.class); + private final EntitySearchService service = new EntitySearchService(repository); + + private PropertyDefinition prop(String name, PropertyType type) { + return new PropertyDefinition(UUID.randomUUID(), name, "desc", type, false, null); + } + + private EntityTemplate template(String identifier, PropertyDefinition... props) { + return new EntityTemplate(UUID.randomUUID(), identifier, identifier, null, List.of(props), List.of()); + } + + @Nested + @DisplayName("No numeric operators — no validation triggered") + class NoNumericOperatorsTests { + + @Test + @DisplayName("filter with only EQ operators does not throw") + void eq_only_doesNotThrow() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("property.lifecycle", SearchOperator.EQ, "production") + )); + assertThatCode(() -> service.validate(filter)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("empty filter does not throw") + void emptyFilter_doesNotThrow() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of()); + assertThatCode(() -> service.validate(filter)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Numeric operators without a template constraint") + class NoTemplateConstraintTests { + + @Test + @DisplayName("GT on property without template constraint does not throw (can't validate)") + void gt_noTemplateConstraint_doesNotThrow() { + var filter = new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080"); + assertThatCode(() -> service.validate(filter)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("GT on property with only non-EQ template constraint does not throw") + void gt_templateConstraintNotEq_doesNotThrow() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.CONTAINS, "service"), + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080") + )); + assertThatCode(() -> service.validate(filter)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Numeric operators with a template constraint (type check enabled)") + class WithTemplateConstraintTests { + + @Test + @DisplayName("GT on a NUMBER property does not throw") + void gt_numberProperty_doesNotThrow() { + when(repository.findByIdentifier("web-service")) + .thenReturn(Optional.of(template("web-service", prop("port", PropertyType.NUMBER)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"), + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080") + )); + assertThatCode(() -> service.validate(filter)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("GTE, LT, LTE on a NUMBER property do not throw") + void allNumericOperators_numberProperty_doesNotThrow() { + when(repository.findByIdentifier("ws")) + .thenReturn(Optional.of(template("ws", prop("score", PropertyType.NUMBER)))); + + for (SearchOperator op : List.of( + SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE)) { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "ws"), + new SearchFilterNode.Criterion("property.score", op, "5") + )); + assertThatCode(() -> service.validate(filter)) + .as("operator %s should not throw for NUMBER property", op) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("GT on a STRING property throws InvalidQueryException") + void gt_stringProperty_throws() { + when(repository.findByIdentifier("web-service")) + .thenReturn(Optional.of(template("web-service", + prop("programmingLanguage", PropertyType.STRING), + prop("port", PropertyType.NUMBER)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"), + new SearchFilterNode.Criterion("property.programmingLanguage", SearchOperator.GT, "5") + )); + assertThatThrownBy(() -> service.validate(filter)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("programmingLanguage") + .hasMessageContaining("web-service") + .hasMessageContaining("STRING"); + } + + @Test + @DisplayName("GT on a BOOLEAN property throws InvalidQueryException") + void gt_booleanProperty_throws() { + when(repository.findByIdentifier("svc")) + .thenReturn(Optional.of(template("svc", prop("isActive", PropertyType.BOOLEAN)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "svc"), + new SearchFilterNode.Criterion("property.isActive", SearchOperator.LTE, "1") + )); + assertThatThrownBy(() -> service.validate(filter)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("isActive") + .hasMessageContaining("BOOLEAN"); + } + + @Test + @DisplayName("unknown template (not found) does not throw — template may not exist yet") + void unknownTemplate_doesNotThrow() { + when(repository.findByIdentifier("unknown")).thenReturn(Optional.empty()); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "unknown"), + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "80") + )); + assertThatCode(() -> service.validate(filter)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("property not defined in template does not throw — may be optional") + void propertyNotInTemplate_doesNotThrow() { + when(repository.findByIdentifier("ws")) + .thenReturn(Optional.of(template("ws", prop("port", PropertyType.NUMBER)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "ws"), + new SearchFilterNode.Criterion("property.undefinedProp", SearchOperator.GT, "5") + )); + assertThatCode(() -> service.validate(filter)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Nested filter trees") + class NestedTreeTests { + + @Test + @DisplayName("GT on STRING property nested inside OR group throws") + void gt_stringProperty_nestedInOr_throws() { + when(repository.findByIdentifier("svc")) + .thenReturn(Optional.of(template("svc", prop("name", PropertyType.STRING)))); + + var inner = new SearchFilterNode.Group(LogicalConnector.OR, List.of( + new SearchFilterNode.Criterion("property.name", SearchOperator.GT, "5") + )); + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "svc"), + inner + )); + assertThatThrownBy(() -> service.validate(filter)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("name") + .hasMessageContaining("STRING"); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 9675a33..8495356 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -10,12 +10,16 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import com.decathlon.idp_core.AbstractIntegrationTest; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntitySearchDomainMapper; /// Integration tests for the EntityController REST API endpoints. /// These tests verify the behavior of entity retrieval endpoints, including @@ -169,4 +173,668 @@ void postEntity_201() throws Exception { } + @Nested + @DisplayName("POST /api/v1/entities/search") + class SearchEntitiesTests { + + private static final String SEARCH_PATH = "/api/v1/entities/search"; + private static final String SEARCH_JSON_PATH = ENTITY_JSON_FILES_TEST_PATH + "search/"; + + @Test + @DisplayName("Should return 401 without authentication") + void search_401_withoutAuth() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_template_and_property.json"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Should search entities by template AND property (EQ)") + @WithMockUser + void search_200_byTemplateAndProperty() throws Exception { + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_template_and_property.json"))) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byTemplateAndProperty.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } + + @Test + @DisplayName("Should search entities using OR connector across templates") + @WithMockUser + void search_200_orTemplates() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_or_templates.json"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page.total_elements").value(2)); + } + + @Test + @DisplayName("Should search entities using OR connector on multiple templates") + @WithMockUser + void search_200_inTemplates() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_in_templates.json"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page.total_elements").value(2)); + } + + @Test + @DisplayName("Should search entities by relations_as_target identifier") + @WithMockUser + void search_200_byRelationsAsTarget() throws Exception { + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_relations_as_target.json"))) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationsAsTarget.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } + + @Test + @DisplayName("Should search entities using STARTS_WITH operator") + @WithMockUser + void search_200_startsWith() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_starts_with.json"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("web-api-1")); + } + + @Test + @DisplayName("Should search entities using NEQ operator") + @WithMockUser + void search_200_neq() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_neq.json"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("web-api-2")); + } + + @Test + @DisplayName("Should return empty content when no entities match") + @WithMockUser + void search_200_noMatch() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "identifier", + "operation": "EQ", + "value": "non-existent-entity-xyz" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(0)) + .andExpect(jsonPath("$.page.total_elements").value(0)); + } + + @Test + @DisplayName("Should return all entities when filter is null") + @WithMockUser + void search_200_nullFilter() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "page": 0, + "size": 5 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)); + } + + @Test + @DisplayName("Should return paginated results respecting size parameter") + @WithMockUser + void search_200_paginated() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "template", + "operation": "EQ", + "value": "monitoring-service" + }, + "page": 0, + "size": 3 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(3)) + .andExpect(jsonPath("$.page.size").value(3)) + .andExpect(jsonPath("$.page.total_elements").value(6)) + .andExpect(jsonPath("$.page.total_pages").value(2)); + } + + @Test + @DisplayName("Should return 400 for invalid connector value") + @WithMockUser + void search_400_invalidConnector() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "INVALID_CONNECTOR", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" } + ] + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("Invalid connector 'INVALID_CONNECTOR'. Supported values: AND, OR")); + } + + @Test + @DisplayName("Should return 400 for invalid operation value") + @WithMockUser + void search_400_invalidOperation() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "identifier", + "operation": "LIKE", + "value": "api" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("Invalid operation 'LIKE'. Supported values: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE")); + } + + @Test + @DisplayName("Should return 400 for invalid field name") + @WithMockUser + void search_400_invalidField() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "unknownField", + "operation": "EQ", + "value": "value" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Should return 400 when criterion is missing field") + @WithMockUser + void search_400_missingField() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "operation": "EQ", + "value": "microservice" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("A criterion node must have a non-blank 'field'")); + } + + @Test + @DisplayName("Should return 400 when group is missing criteria") + @WithMockUser + void search_400_groupMissingCriteria() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("A group node must have a non-empty 'criteria' list")); + } + + @Test + @DisplayName("Should support sort parameter") + @WithMockUser + void search_200_withSort() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "template", + "operation": "EQ", + "value": "monitoring-service" + }, + "page": 0, + "size": 6, + "sort": "name:desc" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(6)) + .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")); + } + + @Test + @DisplayName("Should support nested AND/OR filter composition") + @WithMockUser + void search_200_nestedFilter() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { + "connector": "OR", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "template", "operation": "EQ", "value": "batch-job" } + ] + }, + { "field": "identifier", "operation": "EQ", "value": "microservice-1" } + ] + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("microservice-1")); + } + + @Test + @DisplayName("Should find entities by query matching identifier") + @WithMockUser + void search_200_queryByIdentifier() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": "web-api", "page": 0, "size": 20 } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.page.total_elements").value(2)); + } + + @Test + @DisplayName("Should find entities by query matching name (case-insensitive)") + @WithMockUser + void search_200_queryByName() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": "Web API", "page": 0, "size": 20 } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.page.total_elements").value(2)); + } + + @Test + @DisplayName("Should find entities by query matching a property value") + @WithMockUser + void search_200_queryByPropertyValue() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": "JAVA", "page": 0, "size": 20 } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("web-api-1")); + } + + @Test + @DisplayName("Should combine query and filter with AND semantics") + @WithMockUser + void search_200_queryAndFilter() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "query": "JAVA", + "filter": { + "field": "template", + "operation": "EQ", + "value": "web-service" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("web-api-1")); + } + + @Test + @DisplayName("Should treat blank query as no-op and return all entities") + @WithMockUser + void search_200_blankQueryIsNoOp() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": " ", "page": 0, "size": 5 } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(5)); + } + + @Test + @DisplayName("Should return 400 when query exceeds maximum length") + @WithMockUser + void search_400_queryTooLong() throws Exception { + String tooLong = "x".repeat(256); + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": "%s", "page": 0, "size": 20 } + """.formatted(tooLong))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("Search query must not exceed 255 characters")); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when GT operator is used on a non-property field") + void search_400_numericOperator_onNonPropertyField() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "GT", "value": "5" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.containsString("GT"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when GT operator is used with a non-numeric value") + void search_400_numericOperator_nonNumericValue() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "property.port", "operation": "GT", "value": "not-a-number" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.containsString("not-a-number"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when GTE is used on a STRING-typed property with a known template") + void search_400_numericOperator_onStringProperty() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.programmingLanguage", "operation": "GTE", "value": "5" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers.containsString("programmingLanguage"), + org.hamcrest.Matchers.containsString("STRING")))); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 and match correct entities when GT used on a NUMBER property") + void search_200_numericGt_onNumberProperty() throws Exception { + // web-api-1 has port=8080, web-api-2 has port=9090; GT 8085 should return only web-api-2 + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.port", "operation": "GT", "value": "8085" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.total_elements").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("web-api-2")); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 and match all seeded entities when LTE used with upper bound covering all") + void search_200_numericLte_onNumberProperty_allMatch() throws Exception { + // Both web-api-1 (port=8080) and web-api-2 (port=9090) are <= 9999. + // Other test methods (e.g. postEntity_201) may create additional web-service entities + // in the same shared DB, so we only assert at-least-2 rather than an exact count. + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.port", "operation": "LTE", "value": "9999" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.total_elements", + org.hamcrest.Matchers.greaterThanOrEqualTo(2))); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 when page and size are omitted from the request body") + void search_200_noPageOrSize_usesDefaults() throws Exception { + // Omitting page and size must not cause a 400 JSON parse error (primitive int vs null). + // The record defaults should kick in: page=0, size=20. + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" } + ] + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when size exceeds the maximum allowed value") + void search_400_pageSizeTooLarge() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "page": 0, "size": 501 } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description") + .value("Page size must not exceed %d".formatted(EntitySearchDomainMapper.MAX_PAGE_SIZE))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when sort field is not in the allowed list") + void search_400_invalidSortField() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "page": 0, "size": 20, "sort": "badField:asc" } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("Invalid sort field 'badField'. Supported fields: identifier, name, templateIdentifier")); + } + + @Test + @WithMockUser + @DisplayName("Should return entities that have a relation with an exact name match") + void search_200_byRelationNameEq() throws Exception { + // web-api-1 has relation "api-link"; web-api-2 does not + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_relation_name_eq.json"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("web-api-1")); + } + + @Test + @WithMockUser + @DisplayName("Should return entities that have a relation whose name contains the given value") + void search_200_byRelationNameContains() throws Exception { + // both web-api-1 and web-api-2 have a relation named "database" + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_relation_name_contains.json"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntitySearchDomainMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntitySearchDomainMapperTest.java new file mode 100644 index 0000000..12a1355 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntitySearchDomainMapperTest.java @@ -0,0 +1,378 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +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.InvalidQueryException; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.FilterNodeDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntitySearchDomainMapper; + +/// Unit tests for [EntitySearchDomainMapper]. +@DisplayName("EntitySearchDomainMapper") +class EntitySearchDomainMapperTest { + + private final EntitySearchDomainMapper mapper = new EntitySearchDomainMapper(); + + @Nested + @DisplayName("toDomain() — null and empty inputs") + class NullAndEmptyTests { + + @Test + @DisplayName("null DTO returns empty AND group") + void null_returnsEmptyGroup() { + var result = mapper.toDomain(null); + assertThat(result).isInstanceOf(SearchFilterNode.Group.class); + var group = (SearchFilterNode.Group) result; + assertThat(group.connector()).isEqualTo(LogicalConnector.AND); + assertThat(group.nodes()).isEmpty(); + } + } + + @Nested + @DisplayName("toDomain() — criterion leaf node") + class CriterionTests { + + @Test + @DisplayName("valid criterion is correctly mapped") + void validCriterion_mapped() { + var dto = new FilterNodeDtoIn(null, null, "template", "EQ", "microservice"); + var result = mapper.toDomain(dto); + assertThat(result).isInstanceOf(SearchFilterNode.Criterion.class); + var criterion = (SearchFilterNode.Criterion) result; + assertThat(criterion.field()).isEqualTo("template"); + assertThat(criterion.operation()).isEqualTo(SearchOperator.EQ); + assertThat(criterion.value()).isEqualTo("microservice"); + } + + @Test + @DisplayName("operation is case-insensitive") + void operation_caseInsensitive() { + var dto = new FilterNodeDtoIn(null, null, "identifier", "contains", "api"); + var result = (SearchFilterNode.Criterion) mapper.toDomain(dto); + assertThat(result.operation()).isEqualTo(SearchOperator.CONTAINS); + } + + @Test + @DisplayName("throws when field is null") + void nullField_throws() { + var dto = new FilterNodeDtoIn(null, null, null, "EQ", "value"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("field"); + } + + @Test + @DisplayName("throws when operation is null") + void nullOperation_throws() { + var dto = new FilterNodeDtoIn(null, null, "identifier", null, "value"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("operation"); + } + + @Test + @DisplayName("throws when value is null") + void nullValue_throws() { + var dto = new FilterNodeDtoIn(null, null, "identifier", "EQ", null); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("value"); + } + + @Test + @DisplayName("throws for invalid operation string") + void invalidOperation_throws() { + var dto = new FilterNodeDtoIn(null, null, "identifier", "LIKE", "api"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("LIKE"); + } + + @Test + @DisplayName("throws for unknown field") + void unknownField_throws() { + var dto = new FilterNodeDtoIn(null, null, "badField", "EQ", "value"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("badField"); + } + } + + @Nested + @DisplayName("toDomain() — group nodes") + class GroupTests { + + @Test + @DisplayName("valid AND group is correctly mapped") + void validAndGroup_mapped() { + var child1 = new FilterNodeDtoIn(null, null, "template", "EQ", "microservice"); + var child2 = new FilterNodeDtoIn(null, null, "identifier", "CONTAINS", "api"); + var dto = new FilterNodeDtoIn("AND", List.of(child1, child2), null, null, null); + + var result = mapper.toDomain(dto); + assertThat(result).isInstanceOf(SearchFilterNode.Group.class); + var group = (SearchFilterNode.Group) result; + assertThat(group.connector()).isEqualTo(LogicalConnector.AND); + assertThat(group.nodes()).hasSize(2); + } + + @Test + @DisplayName("connector is case-insensitive") + void connector_caseInsensitive() { + var child = new FilterNodeDtoIn(null, null, "template", "EQ", "microservice"); + var dto = new FilterNodeDtoIn("or", List.of(child), null, null, null); + var group = (SearchFilterNode.Group) mapper.toDomain(dto); + assertThat(group.connector()).isEqualTo(LogicalConnector.OR); + } + + @Test + @DisplayName("'IN' is rejected as an unsupported connector") + void inConnector_rejectedAsInvalidConnector() { + var child = new FilterNodeDtoIn(null, null, "template", "EQ", "microservice"); + var dto = new FilterNodeDtoIn("IN", List.of(child), null, null, null); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("IN"); + } + + @Test + @DisplayName("throws for missing connector in group") + void missingConnector_throws() { + var child = new FilterNodeDtoIn(null, null, "template", "EQ", "microservice"); + var dto = new FilterNodeDtoIn(null, List.of(child), null, null, null); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("connector"); + } + + @Test + @DisplayName("throws for empty criteria list in group") + void emptyCriteria_throws() { + var dto = new FilterNodeDtoIn("AND", List.of(), null, null, null); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("criteria"); + } + + @Test + @DisplayName("throws for invalid connector string") + void invalidConnector_throws() { + var child = new FilterNodeDtoIn(null, null, "template", "EQ", "microservice"); + var dto = new FilterNodeDtoIn("NAND", List.of(child), null, null, null); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("NAND"); + } + } + + @Nested + @DisplayName("toDomain() — valid fields") + class FieldValidationTests { + + @Test + @DisplayName("'template' field is accepted") + void template_accepted() { + assertThat(criterionFor("template")).isNotNull(); + } + + @Test + @DisplayName("'identifier' field is accepted") + void identifier_accepted() { + assertThat(criterionFor("identifier")).isNotNull(); + } + + @Test + @DisplayName("'name' field is accepted") + void name_accepted() { + assertThat(criterionFor("name")).isNotNull(); + } + + @Test + @DisplayName("'property.{name}' field is accepted") + void propertyField_accepted() { + assertThat(criterionFor("property.language")).isNotNull(); + } + + @Test + @DisplayName("'relation.{name}' field is accepted") + void relationField_accepted() { + assertThat(criterionFor("relation.api-link")).isNotNull(); + } + + @Test + @DisplayName("'relation.{name}.identifier' field is accepted") + void relationIdentifierField_accepted() { + assertThat(criterionFor("relation.api-link.identifier")).isNotNull(); + } + + @Test + @DisplayName("'relations_as_target.{name}.identifier' field is accepted") + void relationsAsTargetIdentifierField_accepted() { + assertThat(criterionFor("relations_as_target.api-link.identifier")).isNotNull(); + } + + @Test + @DisplayName("'relations_as_target.{name}.name' field is accepted") + void relationsAsTargetNameField_accepted() { + assertThat(criterionFor("relations_as_target.api-link.name")).isNotNull(); + } + + @Test + @DisplayName("'relations_as_target' without subfield throws") + void relationsAsTarget_missingSubfield_throws() { + var dto = new FilterNodeDtoIn(null, null, "relations_as_target.api-link", "EQ", "value"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class); + } + + private SearchFilterNode criterionFor(String field) { + return mapper.toDomain(new FilterNodeDtoIn(null, null, field, "EQ", "value")); + } + } + + @Nested + @DisplayName("toDomain() — safety limits") + class SafetyLimitsTests { + + @Test + @DisplayName("throws when total criteria exceed maximum") + void tooManyCriteria_throws() { + var innerCriteria = new java.util.ArrayList(); + for (int i = 0; i <= EntitySearchDomainMapper.MAX_TOTAL_CRITERIA; i++) { + innerCriteria.add(new FilterNodeDtoIn(null, null, "template", "EQ", "v" + i)); + } + var dto = new FilterNodeDtoIn("OR", innerCriteria, null, null, null); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining(String.valueOf(EntitySearchDomainMapper.MAX_TOTAL_CRITERIA)); + } + + @Test + @DisplayName("throws when nesting exceeds maximum depth") + void nestingTooDeep_throws() { + FilterNodeDtoIn node = new FilterNodeDtoIn(null, null, "template", "EQ", "v"); + for (int i = 0; i <= EntitySearchDomainMapper.MAX_NESTING_DEPTH; i++) { + node = new FilterNodeDtoIn("AND", List.of(node), null, null, null); + } + var root = node; + assertThatThrownBy(() -> mapper.toDomain(root)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining(String.valueOf(EntitySearchDomainMapper.MAX_NESTING_DEPTH)); + } + } + + @Nested + @DisplayName("toDomain() — numeric operator validation") + class NumericOperatorTests { + + @Test + @DisplayName("GT on property.{name} with a numeric value is accepted") + void gt_onProperty_numericValue_accepted() { + var dto = new FilterNodeDtoIn(null, null, "property.port", "GT", "8080"); + assertThat(mapper.toDomain(dto)).isInstanceOf(SearchFilterNode.Criterion.class); + } + + @Test + @DisplayName("GTE on property.{name} with a decimal value is accepted") + void gte_onProperty_decimalValue_accepted() { + var dto = new FilterNodeDtoIn(null, null, "property.score", "GTE", "1.5"); + assertThat(mapper.toDomain(dto)).isInstanceOf(SearchFilterNode.Criterion.class); + } + + @Test + @DisplayName("GT on 'template' field throws — numeric ops only on property.{name}") + void gt_onTemplateField_throws() { + var dto = new FilterNodeDtoIn(null, null, "template", "GT", "5"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("GT"); + } + + @Test + @DisplayName("LT on 'identifier' field throws — numeric ops only on property.{name}") + void lt_onIdentifierField_throws() { + var dto = new FilterNodeDtoIn(null, null, "identifier", "LT", "5"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("LT"); + } + + @Test + @DisplayName("'MIN' is rejected as an unsupported operator") + void min_rejectedAsInvalidOperator() { + var dto = new FilterNodeDtoIn(null, null, "name", "MIN", "5"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("MIN"); + } + + @Test + @DisplayName("GT on property.{name} with a non-numeric value throws") + void gt_nonNumericValue_throws() { + var dto = new FilterNodeDtoIn(null, null, "property.port", "GT", "abc"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("abc") + .hasMessageContaining("GT"); + } + + @Test + @DisplayName("LTE on property.{name} with blank non-numeric value throws") + void lte_nonNumericValueWithSpecialChars_throws() { + var dto = new FilterNodeDtoIn(null, null, "property.size", "LTE", "10MB"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("10MB"); + } + + @Test + @DisplayName("'MAX' is rejected as an unsupported operator") + void max_rejectedAsInvalidOperator() { + var dto = new FilterNodeDtoIn(null, null, "relation.api-link", "MAX", "5"); + assertThatThrownBy(() -> mapper.toDomain(dto)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining("MAX"); + } + } + + @Nested + @DisplayName("validateQuery()") + class ValidateQueryTests { + + @Test + @DisplayName("null query does not throw") + void nullQuery_doesNotThrow() { + mapper.validateQuery(null); + } + + @Test + @DisplayName("query within limit does not throw") + void shortQuery_doesNotThrow() { + mapper.validateQuery("checkout"); + } + + @Test + @DisplayName("query at exact limit does not throw") + void queryAtLimit_doesNotThrow() { + mapper.validateQuery("x".repeat(EntitySearchDomainMapper.MAX_QUERY_LENGTH)); + } + + @Test + @DisplayName("query exceeding limit throws InvalidQueryException") + void queryOverLimit_throws() { + String tooLong = "x".repeat(EntitySearchDomainMapper.MAX_QUERY_LENGTH + 1); + assertThatThrownBy(() -> mapper.validateQuery(tooLong)) + .isInstanceOf(InvalidQueryException.class) + .hasMessageContaining(String.valueOf(EntitySearchDomainMapper.MAX_QUERY_LENGTH)); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java new file mode 100644 index 0000000..7dc6185 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java @@ -0,0 +1,297 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +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 org.springframework.data.jpa.domain.Specification; + +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; + +/// Unit tests for [EntitySearchSpecification]. +/// +/// Tests the static specification building logic, wildcard escaping, and +/// edge cases for various field types and operators. +/// Integration-level behavior is verified in [EntityControllerTest]. +@DisplayName("EntitySearchSpecification") +class EntitySearchSpecificationTest { + + @Nested + @DisplayName("escapeLikeWildcards") + class EscapeLikeWildcardsTests { + + @Test + @DisplayName("escapes percent sign") + void escapes_percent() { + assertThat(EntitySearchSpecification.escapeLikeWildcards("100%")) + .isEqualTo("100\\%"); + } + + @Test + @DisplayName("escapes underscore") + void escapes_underscore() { + assertThat(EntitySearchSpecification.escapeLikeWildcards("my_value")) + .isEqualTo("my\\_value"); + } + + @Test + @DisplayName("escapes backslash before other wildcards") + void escapes_backslash() { + assertThat(EntitySearchSpecification.escapeLikeWildcards("path\\to%file")) + .isEqualTo("path\\\\to\\%file"); + } + + @Test + @DisplayName("returns plain string unchanged") + void leaves_plainString_unchanged() { + assertThat(EntitySearchSpecification.escapeLikeWildcards("hello")) + .isEqualTo("hello"); + } + + @ParameterizedTest(name = "escapes ''{0}'' correctly") + @ValueSource(strings = {"%", "_", "%%", "__", "%_", "_%"}) + @DisplayName("escapes various wildcard combinations") + void escapes_wildcardCombinations(String input) { + String escaped = EntitySearchSpecification.escapeLikeWildcards(input); + String stripped = escaped.replace("\\%", "").replace("\\_", "").replace("\\\\", ""); + assertThat(stripped) + .doesNotContain("%") + .doesNotContain("_"); + } + } + + @Nested + @DisplayName("of() — empty and null filter") + class EmptyFilterTests { + + @Test + @DisplayName("empty group returns non-null specification") + void emptyGroup_returnsSpec() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of()); + Specification spec = EntitySearchSpecification.of(filter); + assertThat(spec).isNotNull(); + } + + @Test + @DisplayName("single criterion returns non-null specification") + void singleCriterion_returnsSpec() { + var filter = new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"); + Specification spec = EntitySearchSpecification.of(filter); + assertThat(spec).isNotNull(); + } + } + + @Nested + @DisplayName("of() — group connectors") + class GroupConnectorTests { + + @Test + @DisplayName("AND group returns non-null specification") + void andGroup_returnsSpec() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("identifier", SearchOperator.CONTAINS, "api") + )); + assertThat(EntitySearchSpecification.of(filter)).isNotNull(); + } + + @Test + @DisplayName("OR group returns non-null specification") + void orGroup_returnsSpec() { + var filter = new SearchFilterNode.Group(LogicalConnector.OR, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service") + )); + assertThat(EntitySearchSpecification.of(filter)).isNotNull(); + } + + @Test + @DisplayName("nested group returns non-null specification") + void nestedGroup_returnsSpec() { + var inner = new SearchFilterNode.Group(LogicalConnector.OR, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service") + )); + var outer = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + inner, + new SearchFilterNode.Criterion("property.language", SearchOperator.EQ, "JAVA") + )); + assertThat(EntitySearchSpecification.of(outer)).isNotNull(); + } + } + + @Nested + @DisplayName("of() — field types") + class FieldTypeTests { + + @Test + @DisplayName("template field returns non-null spec") + void templateField_returnsSpec() { + assertThat(specFor("template", SearchOperator.EQ, "microservice")).isNotNull(); + } + + @Test + @DisplayName("identifier field returns non-null spec") + void identifierField_returnsSpec() { + assertThat(specFor("identifier", SearchOperator.EQ, "my-entity")).isNotNull(); + } + + @Test + @DisplayName("name field returns non-null spec") + void nameField_returnsSpec() { + assertThat(specFor("name", SearchOperator.CONTAINS, "Service")).isNotNull(); + } + + @Test + @DisplayName("property.{name} field returns non-null spec") + void propertyField_returnsSpec() { + assertThat(specFor("property.language", SearchOperator.EQ, "JAVA")).isNotNull(); + } + + @Test + @DisplayName("relation.{name} field returns non-null spec") + void relationField_returnsSpec() { + assertThat(specFor("relation.api-link", SearchOperator.EQ, "microservice-1")).isNotNull(); + } + + @Test + @DisplayName("relation.{name}.identifier field returns non-null spec") + void relationIdentifierField_returnsSpec() { + assertThat(specFor("relation.api-link.identifier", SearchOperator.EQ, "microservice-1")).isNotNull(); + } + + @Test + @DisplayName("relation.{name}.name field returns non-null spec") + void relationNameField_returnsSpec() { + assertThat(specFor("relation.api-link.name", SearchOperator.CONTAINS, "Microservice")).isNotNull(); + } + + @Test + @DisplayName("relations_as_target.{name}.identifier field returns non-null spec") + void relationsAsTargetIdentifierField_returnsSpec() { + assertThat(specFor("relations_as_target.api-link.identifier", SearchOperator.EQ, "web-api-1")).isNotNull(); + } + + @Test + @DisplayName("relations_as_target.{name}.name field returns non-null spec") + void relationsAsTargetNameField_returnsSpec() { + assertThat(specFor("relations_as_target.api-link.name", SearchOperator.CONTAINS, "Web")).isNotNull(); + } + + @Test + @DisplayName("bare 'relation' field (filter on relation name) returns non-null spec") + void bareRelationField_returnsSpec() { + assertThat(specFor("relation", SearchOperator.CONTAINS, "api-link")).isNotNull(); + } + + @Test + @DisplayName("unknown field throws IllegalArgumentException") + void unknownField_throwsException() { + var criterion = new SearchFilterNode.Criterion("unknown_field", SearchOperator.EQ, "value"); + assertThatThrownBy(() -> EntitySearchSpecification.of(criterion)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown_field"); + } + } + + @Nested + @DisplayName("of() — all operators") + class OperatorTests { + + @Test + @DisplayName("EQ operator returns non-null spec") + void eq_returnsSpec() { + assertThat(specFor("identifier", SearchOperator.EQ, "val")).isNotNull(); + } + + @Test + @DisplayName("NEQ operator returns non-null spec") + void neq_returnsSpec() { + assertThat(specFor("identifier", SearchOperator.NEQ, "val")).isNotNull(); + } + + @Test + @DisplayName("CONTAINS operator returns non-null spec") + void contains_returnsSpec() { + assertThat(specFor("name", SearchOperator.CONTAINS, "service")).isNotNull(); + } + + @Test + @DisplayName("NOT_CONTAINS operator returns non-null spec") + void notContains_returnsSpec() { + assertThat(specFor("name", SearchOperator.NOT_CONTAINS, "legacy")).isNotNull(); + } + + @Test + @DisplayName("STARTS_WITH operator returns non-null spec") + void startsWith_returnsSpec() { + assertThat(specFor("name", SearchOperator.STARTS_WITH, "Web")).isNotNull(); + } + + @Test + @DisplayName("ENDS_WITH operator returns non-null spec") + void endsWith_returnsSpec() { + assertThat(specFor("name", SearchOperator.ENDS_WITH, "Service")).isNotNull(); + } + + @Test + @DisplayName("GT operator returns non-null spec") + void gt_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.GT, "1.0")).isNotNull(); + } + + @Test + @DisplayName("GTE operator returns non-null spec") + void gte_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.GTE, "1.0")).isNotNull(); + } + + @Test + @DisplayName("LT operator returns non-null spec") + void lt_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.LT, "2.0")).isNotNull(); + } + + @Test + @DisplayName("LTE operator returns non-null spec") + void lte_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.LTE, "2.0")).isNotNull(); + } + } + + private static Specification specFor(String field, SearchOperator op, String value) { + return EntitySearchSpecification.of(new SearchFilterNode.Criterion(field, op, value)); + } + + @Nested + @DisplayName("globalTextSearch()") + class GlobalTextSearchTests { + + @Test + @DisplayName("returns non-null specification for a plain query") + void plainQuery_returnsSpec() { + assertThat(EntitySearchSpecification.globalTextSearch("checkout")).isNotNull(); + } + + @Test + @DisplayName("returns non-null specification for a query with LIKE wildcards") + void queryWithWildcards_returnsSpec() { + assertThat(EntitySearchSpecification.globalTextSearch("a%b_c")).isNotNull(); + } + + @Test + @DisplayName("returns non-null specification for an upper-case query") + void upperCaseQuery_returnsSpec() { + assertThat(EntitySearchSpecification.globalTextSearch("JAVA")).isNotNull(); + } + } +} diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 45e62ff..8750dc4 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -16,3 +16,66 @@ VALUES ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); + +-- Properties for web-api-1 (language=JAVA, environment=PROD) +INSERT INTO idp_core.property (id, name, value) +VALUES + ('aa000000-0000-0000-0000-000000000001', 'programmingLanguage', 'JAVA'), + ('aa000000-0000-0000-0000-000000000002', 'environment', 'PROD'); +INSERT INTO idp_core.entity_properties (entity_id, property_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000001'), + ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000002'); + +-- Properties for web-api-2 (language=PYTHON, environment=DEV) +INSERT INTO idp_core.property (id, name, value) +VALUES + ('aa000000-0000-0000-0000-000000000003', 'programmingLanguage', 'PYTHON'), + ('aa000000-0000-0000-0000-000000000004', 'environment', 'DEV'); +INSERT INTO idp_core.entity_properties (entity_id, property_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000003'), + ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000004'); + +-- Numeric (port) properties for web-api-1 and web-api-2 — used by numeric operator integration tests +INSERT INTO idp_core.property (id, name, value) +VALUES + ('aa000000-0000-0000-0000-000000000005', 'port', '8080'), + ('aa000000-0000-0000-0000-000000000006', 'port', '9090'); +INSERT INTO idp_core.entity_properties (entity_id, property_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000005'), + ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000006'); + +-- Relations for web-api-1 (database -> database-service, targetTemplateIdentifier = database-service) +INSERT INTO idp_core.relation (id, name, target_template_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); +INSERT INTO idp_core.entity_relations (entity_id, relation_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); + +-- Relations for web-api-2 (database -> cache-service, targetTemplateIdentifier = cache-service) +INSERT INTO idp_core.relation (id, name, target_template_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); +INSERT INTO idp_core.entity_relations (entity_id, relation_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); + +-- api-link relation for web-api-1 targeting microservice-1 (supports q=relation=api-link;relation.api-link.name:microservice) +INSERT INTO idp_core.relation (id, name, target_template_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); +INSERT INTO idp_core.entity_relations (entity_id, relation_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_in_templates.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_in_templates.json new file mode 100644 index 0000000..0e0d5c7 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_in_templates.json @@ -0,0 +1,11 @@ +{ + "filter": { + "connector": "OR", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "template", "operation": "EQ", "value": "batch-job" } + ] + }, + "page": 0, + "size": 20 +} diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_neq.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_neq.json new file mode 100644 index 0000000..ee724e9 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_neq.json @@ -0,0 +1,12 @@ +{ + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "identifier", "operation": "STARTS_WITH", "value": "web-api" }, + { "field": "identifier", "operation": "NEQ", "value": "web-api-1" } + ] + }, + "page": 0, + "size": 20 +} diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_or_templates.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_or_templates.json new file mode 100644 index 0000000..0e0d5c7 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_or_templates.json @@ -0,0 +1,11 @@ +{ + "filter": { + "connector": "OR", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "template", "operation": "EQ", "value": "batch-job" } + ] + }, + "page": 0, + "size": 20 +} diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_relation_name_contains.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_relation_name_contains.json new file mode 100644 index 0000000..17f5db1 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_relation_name_contains.json @@ -0,0 +1,9 @@ +{ + "filter": { + "field": "relation", + "operation": "CONTAINS", + "value": "database" + }, + "page": 0, + "size": 20 +} diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_relation_name_eq.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_relation_name_eq.json new file mode 100644 index 0000000..22b30c3 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_relation_name_eq.json @@ -0,0 +1,9 @@ +{ + "filter": { + "field": "relation", + "operation": "EQ", + "value": "api-link" + }, + "page": 0, + "size": 20 +} diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target.json new file mode 100644 index 0000000..0773375 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target.json @@ -0,0 +1,11 @@ +{ + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "relations_as_target.api-link.identifier", "operation": "EQ", "value": "web-api-1" } + ] + }, + "page": 0, + "size": 20 +} diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_starts_with.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_starts_with.json new file mode 100644 index 0000000..9c8bf92 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_starts_with.json @@ -0,0 +1,11 @@ +{ + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "name", "operation": "STARTS_WITH", "value": "Web API 1" } + ] + }, + "page": 0, + "size": 20 +} diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_template_and_property.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_template_and_property.json new file mode 100644 index 0000000..302a750 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_template_and_property.json @@ -0,0 +1,11 @@ +{ + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.programmingLanguage", "operation": "EQ", "value": "JAVA" } + ] + }, + "page": 0, + "size": 20 +} diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json new file mode 100644 index 0000000..2b5f2bc --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json @@ -0,0 +1,25 @@ +{ + "content": [ + { + "identifier": "microservice-1", + "name": "Microservice 1", + "properties": {}, + "relations": {}, + "relations_as_target": { + "api-link": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + } + ] + }, + "template_identifier": "microservice" + } + ], + "page": { + "size": 20, + "number": 0, + "total_elements": 1, + "total_pages": 1 + } +} diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json new file mode 100644 index 0000000..7259174 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json @@ -0,0 +1,20 @@ +{ + "content": [ + { + "identifier": "web-api-1", + "name": "Web API 1", + "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080.0 }, + "relations": { + "database": [ + { "identifier": "database-service-1", "name": "Database Service 1" } + ], + "api-link": [ + { "identifier": "microservice-1", "name": "Microservice 1" } + ] + }, + "relations_as_target": {}, + "template_identifier": "web-service" + } + ], + "page": { "size": 20, "number": 0, "total_elements": 1, "total_pages": 1 } +} From dd5536af7e8207a636d3295cd216a28158157ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 11:34:08 +0200 Subject: [PATCH 31/51] feat(entity-graph): add a entity graph service and endpoint add include flag for getting properties or not in graph repository call --- .../port/EntityGraphRepositoryPort.java | 5 ++++- .../entity_graph/EntityGraphService.java | 9 ++++----- .../PostgresEntityGraphAdapter.java | 12 +++++++---- .../entity_graph/EntityGraphServiceTest.java | 20 +++++++++---------- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index e1ae73f..c66ba2d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -29,9 +29,12 @@ public interface EntityGraphRepositoryPort { /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity within its template /// @param depth the maximum traversal depth (1-10) + /// @param includeProperties when true, entity properties are loaded along with relations; + /// when false, only relations are fetched for a leaner query /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found Map findEntityGraph( String templateIdentifier, String entityIdentifier, - int depth); + int depth, + boolean includeProperties); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index e32f1ae..66ee2f9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -64,9 +64,10 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - // Optimized batch fetch: load all entities in the graph keyed by composite key + // Optimized batch fetch: load all entities in the graph keyed by composite key. + // Properties are fetched only when explicitly requested to avoid unnecessary I/O. Map entityMap = entityGraphRepositoryPort - .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth); + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); @@ -111,10 +112,8 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Include properties only when explicitly requested to keep responses lean List properties = includeProperties ? entity.properties() : List.of(); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - properties, outboundRelations, inboundRelations); - } + properties, outboundRelations, inboundRelations); } /// Looks up a composite key from the map by identifier alone. /// Falls back to a synthetic key if no match is found (entity not in graph). diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index 8527027..deeb2b6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -37,7 +37,8 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { public Map findEntityGraph( String templateIdentifier, String entityIdentifier, - int depth) { + int depth, + boolean includeProperties) { // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( templateIdentifier, entityIdentifier, depth); @@ -52,11 +53,14 @@ public Map findEntityGraph( .distinct() .toList(); - // Step 3: batch-load entities with relations, then properties in separate queries - // to avoid Hibernate's MultipleBagFetchException + // Step 3: batch-load entities with relations, then optionally properties in a separate + // query. Properties are skipped when not requested to avoid the extra round-trip and + // keep payloads lean. The two-query split also avoids Hibernate's MultipleBagFetchException. List jpaEntities = jpaEntityRepository.findAllByIdentifierInWithRelations(identifiers); - jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + if (includeProperties) { + jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + } // Step 4: map to domain and key by composite key for O(1) lookup return jpaEntities.stream() diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 0d0efbf..7dbaf9d 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -94,7 +94,7 @@ void shouldReturnLeafNodeWhenNoRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); @@ -119,7 +119,7 @@ void shouldResolveOutboundRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db @@ -142,7 +142,7 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), api)); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); @@ -166,7 +166,7 @@ void shouldResolveInboundRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(DB_TEMPLATE, "postgres")) .thenReturn(Optional.of(db)); - when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db @@ -192,12 +192,12 @@ void shouldClampDepthBelowOne() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1); + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); } @Test @@ -207,12 +207,12 @@ void shouldClampDepthAboveTen() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10); + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); } } @@ -233,7 +233,7 @@ void shouldReturnLeafNodeAtDepthBoundary() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db, @@ -265,7 +265,7 @@ void shouldResolveMultipleNamedRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db, From f2d571b86553463db2648bc6e79c0262373ccba0 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Wed, 20 May 2026 11:59:21 +0200 Subject: [PATCH 32/51] feat(core): fix static swagger --- docs/src/static/swagger.yaml | 151 +++++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 50 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 8f0b4db..e83ec2d 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -44,7 +44,8 @@ paths: tags: - Entities Templates Management summary: Update an existing template by template identifier - description: Update the details of an existing template identified by its unique string identifier + description: Update the details of an existing template identified by its unique + string identifier operationId: updateTemplate parameters: - name: identifier @@ -118,7 +119,8 @@ paths: default: '20' - name: sort in: query - description: Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc. + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc.' content: '*/*': schema: @@ -195,7 +197,8 @@ paths: type: string - name: sort in: query - description: Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc. + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc.' content: '*/*': schema: @@ -245,12 +248,35 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '404': + description: Template not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Entity already exists in this template + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: get: tags: - Entities Management summary: Get entity by entity template and identifier - description: Retrieve a specific entity using its string identifier and its template identifier + description: Retrieve a specific entity using its string identifier and its + template identifier operationId: getEntity parameters: - name: templateIdentifier @@ -278,39 +304,6 @@ paths: $ref: '#/components/schemas/ErrorResponse' components: schemas: - EntityTemplateCreateDtoIn: - type: object - description: Input DTO for creating an entity template - properties: - identifier: - type: string - description: Unique Entity Template identifier - example: service - minLength: 1 - name: - type: string - description: Unique Entity Template name - example: Service - maxLength: 255 - minLength: 1 - pattern: ^[a-zA-Z0-9 _-]+$ - description: - type: string - description: Entity Template description - example: A comprehensive service template - properties_definitions: - type: array - description: List of property definitions for this template - items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' - relations_definitions: - type: array - description: List of relation definitions for this template - items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' - required: - - identifier - - name EntityTemplateUpdateDtoIn: type: object description: Input DTO for updating an entity template @@ -366,8 +359,7 @@ components: example: true default: false rules: - allOf: - - $ref: '#/components/schemas/PropertyRulesDtoIn' + $ref: '#/components/schemas/PropertyRulesDtoIn' description: Property validation rules required: - description @@ -494,8 +486,7 @@ components: description: Whether this property is required example: true rules: - allOf: - - $ref: '#/components/schemas/PropertyRulesDtoOut' + $ref: '#/components/schemas/PropertyRulesDtoOut' description: Property validation rules example: Property validation rules PropertyRulesDtoOut: @@ -561,29 +552,96 @@ components: type: boolean description: Whether this relation can have multiple targets example: true + ErrorResponse: + type: object + properties: + error: + type: string + errorDescription: + type: string + EntityTemplateCreateDtoIn: + type: object + description: Input DTO for creating an entity template + properties: + identifier: + type: string + description: Unique Entity Template identifier + example: service + minLength: 1 + name: + type: string + description: Unique Entity Template name + example: Service + maxLength: 255 + minLength: 0 + pattern: ^[a-zA-Z0-9 _-]+$ + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + $ref: '#/components/schemas/PropertyDefinitionDtoIn' + relations_definitions: + type: array + description: List of relation definitions for this template + items: + $ref: '#/components/schemas/RelationDefinitionDtoIn' + required: + - identifier + - name EntityDtoIn: type: object + description: Input DTO for creating or updating an entity properties: name: type: string + description: Name of the entity + example: my-web-service + minLength: 1 identifier: type: string + description: Unique identifier of the entity within the template scope + example: my-web-service + minLength: 1 properties: type: object - additionalProperties: true + additionalProperties: + type: string + description: Map of property name to value for this entity + example: + port: '8080' + environment: dev relations: type: array + description: List of relations for this entity items: $ref: '#/components/schemas/RelationDtoIn' + required: + - identifier + - name RelationDtoIn: type: object + description: Input DTO for an entity relation instance properties: name: type: string + description: Name of the relation (must match a template relation definition) + example: depends-on + minLength: 1 target_entity_identifiers: type: array + description: List of target entity identifiers for this relation + example: + - web-api-1 + - web-api-2 items: type: string + required: + - name + - target_entity_identifiers EntityDtoOut: type: object properties: @@ -595,7 +653,7 @@ components: type: string properties: type: object - additionalProperties: true + additionalProperties: {} relations: type: object additionalProperties: @@ -615,13 +673,6 @@ components: type: string name: type: string - ErrorResponse: - type: object - properties: - error: - type: string - errorDescription: - type: string PageableObject: type: object properties: From 07d460149d1aaa1184365e018dd1ef475121c1c2 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Wed, 20 May 2026 12:43:45 +0200 Subject: [PATCH 33/51] feat(core): fix static swagger --- docs/src/static/swagger.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index e83ec2d..d097966 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -313,7 +313,7 @@ components: description: Unique Entity Template name example: Service maxLength: 255 - minLength: 1 + minLength: 0 pattern: ^[a-zA-Z0-9 _-]+$ description: type: string From 32dbb64af5b6804809ae8e6210178ead1f870cc0 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Wed, 20 May 2026 13:21:42 +0200 Subject: [PATCH 34/51] feat(core): fix static swagger --- docs/src/static/swagger.yaml | 47 ++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index d097966..fe7bb92 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -224,52 +224,53 @@ paths: description: Create a new entity in the system with the provided information operationId: createEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - requestBody: + - in: path + name: templateIdentifier required: true + schema: + minLength: 1 + type: string + requestBody: content: application/json: schema: - $ref: '#/components/schemas/EntityDtoIn' + "$ref": "#/components/schemas/EntityDtoIn" + required: true responses: '201': - description: Entity created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" + description: Entity created successfully '400': - description: Invalid entity data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" + description: Invalid entity data provided '401': description: Unauthorized - Missing or invalid token '403': description: Insufficient rights '404': - description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" + description: Template not found with the provided identifier '409': - description: Entity already exists in this template content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" + description: Entity already exists in this template '500': - description: Unexpected server-side failure content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" + description: Unexpected server-side failure /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: get: tags: @@ -313,7 +314,7 @@ components: description: Unique Entity Template name example: Service maxLength: 255 - minLength: 0 + minLength: 1 pattern: ^[a-zA-Z0-9 _-]+$ description: type: string From 505241f7e1a7cdc280142f8247dddf51c3cd87e4 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Wed, 20 May 2026 13:30:20 +0200 Subject: [PATCH 35/51] feat(core): add post entities endpoint and validation --- docker-compose.yml | 3 +- docs/src/concepts/entities.md | 264 ++++++++--- .../code/domain-infrastructure.md | 7 +- docs/src/static/swagger.yaml | 440 ++++++++++++------ .../domain/constant/ValidationMessages.java | 34 +- .../entity/EntityAlreadyExistsException.java | 26 ++ .../{ => entity}/EntityNotFoundException.java | 8 +- .../entity/EntityValidationException.java | 43 ++ ...pertyDefinitionRulesConflictException.java | 25 + .../idp_core/domain/model/entity/Entity.java | 14 +- .../domain/model/entity/Property.java | 8 +- .../model/entity_template/EntityTemplate.java | 5 + .../domain/port/EntityRepositoryPort.java | 2 + .../domain/service/EntityService.java | 97 ---- .../domain/service/entity/EntityService.java | 110 +++++ .../entity/EntityValidationService.java | 96 ++++ .../domain/service/entity/Violations.java | 35 ++ .../EntityTemplateService.java | 2 +- .../PropertyDefinitionValidationService.java | 1 - .../property/PropertyValidationService.java | 140 ++++++ .../api/configuration/CorsProperties.java | 8 +- .../api/configuration/SwaggerDescription.java | 16 + .../api/controller/EntityController.java | 50 +- .../adapters/api/dto/in/EntityDtoIn.java | 53 ++- .../api/handler/ApiExceptionHandler.java | 52 ++- .../api/mapper/entity/EntityDtoInMapper.java | 47 +- .../api/mapper/entity/EntityDtoOutMapper.java | 51 +- .../persistence/PostgresEntityAdapter.java | 9 +- .../mapper/EntityPersistenceMapper.java | 2 + .../model/entity/EntityJpaEntity.java | 4 +- .../repository/JpaEntityRepository.java | 2 + ..._entity_identifier_unique_to_composite.sql | 9 + .../service/entity/EntityServiceTest.java | 164 +++++++ .../entity/EntityValidationServiceTest.java | 231 +++++++++ .../PropertyValidationServiceTest.java | 336 +++++++++++++ .../api/controller/EntityControllerTest.java | 142 +++++- .../EntityTemplateControllerTest.java | 4 - .../api/handler/ApiExceptionHandlerTest.java | 157 ++++++- ...stEntityTemplate_400_properties_empty.json | 6 + ...late_400_withoutPropertiesDefinitions.json | 6 + .../json/entity/v1/postEntity_201.json | 16 +- .../entity/v1/postEntity_201_minimal.json | 4 + .../v1/postEntity_201_with_relations.json | 14 + .../v1/postEntity_400_identifier_missing.json | 7 + .../v1/postEntity_400_name_missing.json | 7 + .../postEntity_400_property_value_blank.json | 8 + .../postEntity_400_relation_name_blank.json | 11 + .../entity/v1/postEntity_409_duplicate.json | 6 + 48 files changed, 2325 insertions(+), 457 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityNotFoundException.java (79%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java delete mode 100644 src/main/java/com/decathlon/idp_core/domain/service/EntityService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java create mode 100644 src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java create mode 100644 src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json create mode 100644 src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json diff --git a/docker-compose.yml b/docker-compose.yml index be4859d..139089d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,8 @@ --- -version: "3.8" services: postgres: - image: postgres:14 + image: postgres:18 environment: POSTGRES_USER: idpcore POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-idpcore_password} diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 4a78945..92598c0 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -3,17 +3,17 @@ title: Entities description: Understand Entities - instances of Entity Templates with actual data --- -Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity is the house built from that blueprint. +Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity +is the house built from that blueprint. ## Overview An Entity contains: -- **Identity** - Unique identifier and title +- **Identity** - Unique identifier and name - **Template Reference** - Which template it instantiates - **Properties** - Actual values for the template's property definitions - **Relations** - Links to other entities -- **Audit Fields** - Creation/modification timestamps and actors ```mermaid flowchart LR @@ -36,26 +36,26 @@ flowchart LR ### Complete Example -Here's an entity instantiated from the `sonar_project` template: +Here's an entity instantiated from the `web-service` template: ```json { - "identifier": "decathlon_my-backend-project", - "title": "My Backend Project", - "template": "sonar_project", + "identifier": "my-web-service", + "name": "my-web-service", + "template_identifier": "web-service", "properties": { - "project_name": "My Backend Project", - "last_analysis_date": "2025-11-28T12:20:38+0000", - "issues_number": 137, - "loc": 20000 + "port": "8080", + "environment": "dev" }, "relations": { - "github_repository": "my-backend-repo" + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + } + ] }, - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt" + "relations_as_target": {} } ``` @@ -63,17 +63,104 @@ Here's an entity instantiated from the `sonar_project` template: ## Core Fields -| Field | Type | Description | -| ------------ | -------- | -------------------------------------------- | -| `identifier` | String | Unique identifier for this entity | -| `title` | String | Human-readable name | -| `template` | String | The Entity Template this entity instantiates | -| `properties` | Object | Key-value pairs of property data | -| `relations` | Object | Links to other entities | -| `created_at` | DateTime | When the entity was created | -| `created_by` | String | Who created the entity | -| `updated_at` | DateTime | Last modification time | -| `updated_by` | String | Who last modified the entity | +| Field | Type | Description | +|-----------------------|----------|----------------------------------------------| +| `identifier` | String | Unique identifier within the template scope | +| `name` | String | Human-readable name | +| `template_identifier` | String | The Entity Template this entity instantiates | +| `properties` | Object | Key-value pairs of property data | +| `relations` | Object | Links to other entities (grouped by name) | + +--- + +## Creating an Entity + +You create an entity by sending a `POST` request to the entities endpoint, specifying the template identifier in the URL +path. + +### Endpoint + +```text +POST /api/v1/entities/{templateIdentifier} +``` + +### Request Body + +```json +{ + "name": "my-web-service", + "identifier": "my-web-service", + "properties": { + "port": "8080", + "environment": "dev" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + } + ] +} +``` + +### Validation + +IDP-Core validates entities at two levels: **syntactic validation** at the API boundary and **semantic validation** +against the template definition. + +#### Syntactic Validation (API Layer) + +The API enforces basic structural rules on the request body before any business logic runs: + +| Field | Rule | Error Message | +|-----------------------------------------|---------------------|--------------------------------------| +| `name` | Required, not blank | Entity name is mandatory | +| `identifier` | Required, not blank | Entity identifier is mandatory | +| `relations[].name` | Required, not blank | Relation name is mandatory | +| `relations[].target_entity_identifiers` | Required, not null | Relation target identifiers required | + +If any rule fails, the API returns `400 Bad Request` with a description of the violation. + +#### Semantic Validation (Domain Layer) + +After syntactic checks pass, the domain service validates the entity against its template definition: + +- **Template existence** - The template identifier must match an existing template. Returns `404 Not Found` if the + template does not exist. +- **Property value types** - Values must conform to the property definition type (STRING, NUMBER, BOOLEAN). +- **Property rules** - Values must satisfy the template's property rules (min/max length, format, regex, enum). +- **Required properties** - All properties marked as required in the template must be present. +- **Duplicate check** - An entity with the same identifier must not already exist for the template. Returns + `409 Conflict` if it does. + +### Response Codes + +| Code | Description | +|-------|----------------------------------------------------------------| +| `201` | Entity created successfully | +| `400` | Invalid request body or validation failure | +| `401` | Missing or invalid authentication token | +| `403` | Insufficient permissions | +| `404` | Template not found for the given identifier | +| `409` | An entity with this identifier already exists for the template | +| `500` | Unexpected server error | + +### Minimal Example + +You can create an entity with only the required fields: + +```json +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} +``` + +Properties and relations are optional in the request body. The domain layer validates that all *required* properties (as +defined in the template) are present. --- @@ -84,17 +171,17 @@ Properties contain the actual data values. The structure follows the template's ```json { "properties": { - "project_name": "My Backend Project", // STRING - "issues_number": 137, // NUMBER - "loc": 20000, // NUMBER - "last_analysis_date": "2025-11-28..." // STRING (date-time) + "project_name": "My Backend Project", + "issues_number": 137, + "loc": 20000, + "last_analysis_date": "2025-11-28..." } } ``` -### Validation +### Validation of properties -System validates values against the template's property rules: +The system validates values against the template's property rules: - Required properties must be present - Types must match: STRING, NUMBER, or BOOLEAN @@ -104,51 +191,119 @@ System validates values against the template's property rules: ## Relations -Relations link entities together, forming a graph. It references the entity identifiers of related entities. +Relations link entities together, forming a graph. Each relation references the entity identifiers of related entities. -### One-to-One Relations (`to_many: false`) +### Creating Relations -For consistency, even single relations are represented as arrays: +When creating an entity, you specify relations as an array of objects, each with a `name` and a list of +`target_entity_identifiers`: ```json { - "relations": { - "owned_by": ["platform-team"] - } + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + }, + { + "name": "owned-by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -### One-to-Many Relations (`to_many: true`) +### Relations in Responses -When multiple related entities are allowed, you can list several identifiers in the relation array: +In API responses, relations are grouped by name and include summary information about each target entity: ```json { "relations": { - "components": ["frontend", "backend", "database"] + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + }, + { + "identifier": "web-api-2", + "name": "Web API 2" + } + ] + }, + "relations_as_target": { + "depends-on": [ + { + "identifier": "frontend-app", + "name": "Frontend App" + } + ] } } ``` ---- +The `relations_as_target` field shows reverse relationships—other entities that reference this entity. -## Audit Fields +### One-to-One Relations (`to_many: false`) -Every entity tracks who created/modified it and when: +For consistency, even single relations are represented as arrays: ```json { - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "auth0|65c1d23377c9bea7d7adc415", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "webhook_integration_sonar" + "relations": [ + { + "name": "owned_by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -The `created_by` and `updated_by` fields contain: +### One-to-Many Relations (`to_many: true`) -- User IDs for manual operations -- Integration IDs for automated data ingestion +When multiple related entities are allowed, list several identifiers: + +```json +{ + "relations": [ + { + "name": "components", + "target_entity_identifiers": [ + "frontend", + "backend", + "database" + ] + } + ] +} +``` + +--- + +## Retrieving Entities + +### List Entities by Template + +Retrieve a paginated list of entities for a given template: + +```text +GET /api/v1/entities/{templateIdentifier}?page=0&size=20&sort=identifier,asc +``` + +### Get Entity by Identifier + +Retrieve a specific entity using its template and entity identifiers: + +```text +GET /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier} +``` --- @@ -157,7 +312,8 @@ The `created_by` and `updated_by` fields contain: Because templates are configured at runtime, the entity structure is **dynamic**: > [!WARNING] -> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure depends on the template configuration. +> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure +> depends on the template configuration. > > This means: > @@ -168,4 +324,4 @@ Because templates are configured at runtime, the entity structure is **dynamic** - **[Properties](properties.md)** - Property types and validation rules - **[Relations](relations.md)** - How entities connect -- **[Calculated Properties](calculated-properties.md)** - Automatic computations +- **[API Reference](../api/index.md)** - Interactive Swagger UI documentation diff --git a/docs/src/contributing/code/domain-infrastructure.md b/docs/src/contributing/code/domain-infrastructure.md index 8b6a25c..ad1dbf9 100644 --- a/docs/src/contributing/code/domain-infrastructure.md +++ b/docs/src/contributing/code/domain-infrastructure.md @@ -33,7 +33,12 @@ domain/ │ ├── EntityTemplateRepositoryPort │ └── RelationRepositoryPort └── service/ # Domain services logic orchestration - ├── EntityService + ├── entity/ + │ ├── EntityService # Orchestrates entity CRUD with validation + │ ├── EntityValidationService # Entity validation pipeline (template, uniqueness, structure, rules) + │ └── Violations # Mutable accumulator of validation violation messages + ├── property/ + │ └── PropertyValidationService # Validates property values against type and rules (STRING, NUMBER, BOOLEAN) ├── EntityTemplateService └── RelationService ``` diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 37c9d48..fe7bb92 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -9,6 +9,8 @@ security: - clientId: [] - bearer: [] tags: + - name: Entities Management + description: Operations related to entity management - name: Entities Templates Management description: Operations related to entity template management paths: @@ -42,7 +44,8 @@ paths: tags: - Entities Templates Management summary: Update an existing template by template identifier - description: Update the details of an existing template identified by its unique string identifier + description: Update the details of an existing template identified by its unique + string identifier operationId: updateTemplate parameters: - name: identifier @@ -51,11 +54,11 @@ paths: schema: type: string requestBody: + required: true content: application/json: schema: $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' - required: true responses: '200': description: Template update successfully @@ -116,7 +119,8 @@ paths: default: '20' - name: sort in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc.' content: '*/*': schema: @@ -142,11 +146,11 @@ paths: description: Create a new template in the system with the provided information operationId: createTemplate requestBody: + required: true content: application/json: schema: $ref: '#/components/schemas/EntityTemplateCreateDtoIn' - required: true responses: '201': description: Template created successfully @@ -160,52 +164,158 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' -components: - schemas: - EntityTemplateCreateDtoIn: - type: object - description: Input DTO for creating an entity template - properties: - identifier: - type: string - description: Unique Entity Template identifier - example: service - minLength: 1 - name: - type: string - description: Unique Entity Template name - example: Service - maxLength: 255 + /api/v1/entities/{templateIdentifier}: + get: + tags: + - Entities Management + summary: Get entities by template identifier + description: Retrieve a paginated list of entities with optional sorting + operationId: getEntities + parameters: + - name: page + in: query + required: false + description: Page number for pagination. Defaults to 0. + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + required: false + description: Number of items per page. Defaults to 20. + content: + '*/*': + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc.' + content: + '*/*': + schema: + type: string + default: identifier,asc + responses: + '200': + description: Paginated entities retrieved successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityPageResponse' + '400': + description: Invalid pagination parameters + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Entities Management + summary: Create a new entity + description: Create a new entity in the system with the provided information + operationId: createEntity + parameters: + - in: path + name: templateIdentifier + required: true + schema: minLength: 1 - pattern: "^[a-zA-Z0-9 _-]+$" - description: type: string - description: Entity Template description - example: A comprehensive service template - properties_definitions: - type: array - description: List of property definitions for this template - items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' - relations_definitions: - type: array - description: List of relation definitions for this template - items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' - required: - - identifier - - name + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/EntityDtoIn" + required: true + responses: + '201': + content: + "*/*": + schema: + "$ref": "#/components/schemas/EntityDtoOut" + description: Entity created successfully + '400': + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + description: Invalid entity data provided + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '404': + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + description: Template not found with the provided identifier + '409': + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + description: Entity already exists in this template + '500': + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + description: Unexpected server-side failure + /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + get: + tags: + - Entities Management + summary: Get entity by entity template and identifier + description: Retrieve a specific entity using its string identifier and its + template identifier + operationId: getEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string + responses: + '200': + description: Entity found + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + schemas: EntityTemplateUpdateDtoIn: type: object description: Input DTO for updating an entity template properties: name: type: string - description: Entity Template name + description: Unique Entity Template name example: Service maxLength: 255 minLength: 1 - pattern: "^[a-zA-Z0-9 _-]+$" + pattern: ^[a-zA-Z0-9 _-]+$ description: type: string description: Entity Template description @@ -246,9 +356,9 @@ components: example: STRING required: type: boolean - default: false description: Whether this property is required example: true + default: false rules: $ref: '#/components/schemas/PropertyRulesDtoIn' description: Property validation rules @@ -315,14 +425,14 @@ components: minLength: 1 required: type: boolean - default: false description: Whether this relation is required example: false + default: false to_many: type: boolean - default: false description: Whether this relation can have multiple targets example: true + default: false required: - name - target_template_identifier @@ -356,11 +466,6 @@ components: type: object description: Output DTO for property definition properties: - id: - type: string - format: uuid - description: Unique identifier of the property definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Property name @@ -389,11 +494,6 @@ components: type: object description: Output DTO for property validation rules properties: - id: - type: string - format: uuid - description: Unique identifier of the property rules - example: 123e4567-e89b-12d3-a456-426614174000 format: type: string description: Format of the property @@ -437,11 +537,6 @@ components: type: object description: Output DTO for relation definition properties: - id: - type: string - format: uuid - description: Unique identifier of the relation definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Name of the relation @@ -463,88 +558,128 @@ components: properties: error: type: string - enum: - - 100 CONTINUE - - 101 SWITCHING_PROTOCOLS - - 102 PROCESSING - - 103 EARLY_HINTS - - 103 CHECKPOINT - - 200 OK - - 201 CREATED - - 202 ACCEPTED - - 203 NON_AUTHORITATIVE_INFORMATION - - 204 NO_CONTENT - - 205 RESET_CONTENT - - 206 PARTIAL_CONTENT - - 207 MULTI_STATUS - - 208 ALREADY_REPORTED - - 226 IM_USED - - 300 MULTIPLE_CHOICES - - 301 MOVED_PERMANENTLY - - 302 FOUND - - 302 MOVED_TEMPORARILY - - 303 SEE_OTHER - - 304 NOT_MODIFIED - - 305 USE_PROXY - - 307 TEMPORARY_REDIRECT - - 308 PERMANENT_REDIRECT - - 400 BAD_REQUEST - - 401 UNAUTHORIZED - - 402 PAYMENT_REQUIRED - - 403 FORBIDDEN - - 404 NOT_FOUND - - 405 METHOD_NOT_ALLOWED - - 406 NOT_ACCEPTABLE - - 407 PROXY_AUTHENTICATION_REQUIRED - - 408 REQUEST_TIMEOUT - - 409 CONFLICT - - 410 GONE - - 411 LENGTH_REQUIRED - - 412 PRECONDITION_FAILED - - 413 PAYLOAD_TOO_LARGE - - 413 REQUEST_ENTITY_TOO_LARGE - - 414 URI_TOO_LONG - - 414 REQUEST_URI_TOO_LONG - - 415 UNSUPPORTED_MEDIA_TYPE - - 416 REQUESTED_RANGE_NOT_SATISFIABLE - - 417 EXPECTATION_FAILED - - 418 I_AM_A_TEAPOT - - 419 INSUFFICIENT_SPACE_ON_RESOURCE - - 420 METHOD_FAILURE - - 421 DESTINATION_LOCKED - - 422 UNPROCESSABLE_ENTITY - - 423 LOCKED - - 424 FAILED_DEPENDENCY - - 425 TOO_EARLY - - 426 UPGRADE_REQUIRED - - 428 PRECONDITION_REQUIRED - - 429 TOO_MANY_REQUESTS - - 431 REQUEST_HEADER_FIELDS_TOO_LARGE - - 451 UNAVAILABLE_FOR_LEGAL_REASONS - - 500 INTERNAL_SERVER_ERROR - - 501 NOT_IMPLEMENTED - - 502 BAD_GATEWAY - - 503 SERVICE_UNAVAILABLE - - 504 GATEWAY_TIMEOUT - - 505 HTTP_VERSION_NOT_SUPPORTED - - 506 VARIANT_ALSO_NEGOTIATES - - 507 INSUFFICIENT_STORAGE - - 508 LOOP_DETECTED - - 509 BANDWIDTH_LIMIT_EXCEEDED - - 510 NOT_EXTENDED - - 511 NETWORK_AUTHENTICATION_REQUIRED errorDescription: type: string + EntityTemplateCreateDtoIn: + type: object + description: Input DTO for creating an entity template + properties: + identifier: + type: string + description: Unique Entity Template identifier + example: service + minLength: 1 + name: + type: string + description: Unique Entity Template name + example: Service + maxLength: 255 + minLength: 0 + pattern: ^[a-zA-Z0-9 _-]+$ + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + $ref: '#/components/schemas/PropertyDefinitionDtoIn' + relations_definitions: + type: array + description: List of relation definitions for this template + items: + $ref: '#/components/schemas/RelationDefinitionDtoIn' + required: + - identifier + - name + EntityDtoIn: + type: object + description: Input DTO for creating or updating an entity + properties: + name: + type: string + description: Name of the entity + example: my-web-service + minLength: 1 + identifier: + type: string + description: Unique identifier of the entity within the template scope + example: my-web-service + minLength: 1 + properties: + type: object + additionalProperties: + type: string + description: Map of property name to value for this entity + example: + port: '8080' + environment: dev + relations: + type: array + description: List of relations for this entity + items: + $ref: '#/components/schemas/RelationDtoIn' + required: + - identifier + - name + RelationDtoIn: + type: object + description: Input DTO for an entity relation instance + properties: + name: + type: string + description: Name of the relation (must match a template relation definition) + example: depends-on + minLength: 1 + target_entity_identifiers: + type: array + description: List of target entity identifiers for this relation + example: + - web-api-1 + - web-api-2 + items: + type: string + required: + - name + - target_entity_identifiers + EntityDtoOut: + type: object + properties: + template_identifier: + type: string + name: + type: string + identifier: + type: string + properties: + type: object + additionalProperties: {} + relations: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + relations_as_target: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + EntitySummaryDto: + type: object + properties: + identifier: + type: string + name: + type: string PageableObject: type: object properties: offset: type: integer format: int64 - sort: - $ref: '#/components/schemas/SortObject' - unpaged: - type: boolean paged: type: boolean pageNumber: @@ -553,6 +688,10 @@ components: pageSize: type: integer format: int32 + sort: + $ref: '#/components/schemas/SortObject' + unpaged: + type: boolean SortObject: type: object properties: @@ -572,15 +711,46 @@ components: $ref: '#/components/schemas/EntityTemplateDtoOut' pageable: $ref: '#/components/schemas/PageableObject' + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 last: type: boolean - totalPages: + size: type: integer format: int32 + number: + type: integer + format: int32 + sort: + $ref: '#/components/schemas/SortObject' + first: + type: boolean + numberOfElements: + type: integer + format: int32 + empty: + type: boolean + EntityPageResponse: + type: object + description: Paginated response containing Entity objects + properties: + content: + type: array + items: + $ref: '#/components/schemas/EntityDtoOut' + pageable: + $ref: '#/components/schemas/PageableObject' totalElements: type: integer format: int64 - first: + totalPages: + type: integer + format: int32 + last: type: boolean size: type: integer @@ -590,6 +760,8 @@ components: format: int32 sort: $ref: '#/components/schemas/SortObject' + first: + type: boolean numberOfElements: type: integer format: int32 @@ -602,7 +774,7 @@ components: name: clientId flows: clientCredentials: - tokenUrl: https://my-oauth-server.com/as/token.oauth2 + tokenUrl: http://localhost:8080/auth/token bearer: type: http description: bearer authentication 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 c023292..0d011a5 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,6 +23,25 @@ public class ValidationMessages { public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; + public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; + public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; + public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; + public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; + public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; + public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; + public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; + public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; + public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + + // 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_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"; + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; @@ -34,15 +53,14 @@ public class ValidationMessages { public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; - // 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_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"; + // Entity input validation messages + public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; - public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + // Entity creation validation messages + public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; + public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; + public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; // Helper method to construct rules incompatibility message public static String rulesAreIncompatible(String rule1, String rule2) { diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java new file mode 100644 index 0000000..8243748 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.domain.exception.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_ALREADY_EXISTS; + +import com.decathlon.idp_core.domain.model.entity.Entity; + +/// Domain exception for duplicate [Entity] business entities within the same template context. +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an Entity that already exist within a specific template context. +/// This enforces the business invariant that entities must be unique within their template context. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require unique entities within a template context +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity +public class EntityAlreadyExistsException extends RuntimeException { + + /// Constructs a new exception with template and entity identifiers. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityName the duplicate entity name + public EntityAlreadyExistsException(String templateIdentifier, String entityName) { + super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java similarity index 79% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java index 2942d91..42c60f6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java @@ -1,4 +1,8 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NOT_FOUND; + +import com.decathlon.idp_core.domain.model.entity.Entity; /// Domain exception for missing [Entity] business entities. /// @@ -20,7 +24,7 @@ public class EntityNotFoundException extends RuntimeException { /// @param templateIdentifier the identifier of the template /// @param entityIdentifier the identifier of the entity public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { - super(String.format("Entity not found with template identifier %s and entity identifier '%s'", templateIdentifier, entityIdentifier)); + super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java new file mode 100644 index 0000000..42756f0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -0,0 +1,43 @@ +package com.decathlon.idp_core.domain.exception.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_VALIDATION_FAILED; + +import java.util.List; + +import lombok.Getter; + +/// Domain exception for entity schema validation failures +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an entity, or update an entity, with property values that +/// do not conform to the validation rules defined in the entity's template. +/// This includes violations of required properties, type mismatches, and template rules +/// This enforces the business invariant that entities must conform to the validation +/// rules defined in their template's property definitions and relation constraints. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require valid property values +/// that conform to template rules +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity +@Getter +public class EntityValidationException extends RuntimeException { + + /** + * -- GETTER -- + * Returns the list of individual validation violation messages. + * /// + * /// + * @return immutable list of violation messages + */ + private final List violations; + + /// Constructs a new exception with a list of validation violation messages. + /// + /// @param violations the list of validation error messages + public EntityValidationException(List violations) { + super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); + this.violations = List.copyOf(violations); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java new file mode 100644 index 0000000..3ce489e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.domain.exception.property; + +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. +/// +/// **Usage patterns:** +/// - Property template creation with invalid rules +/// - Property template updates introducing rule conflicts +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 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/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2ec901e..8fc58b4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -1,10 +1,14 @@ package com.decathlon.idp_core.domain.model.entity; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY; import java.util.List; import java.util.UUID; +import org.springframework.validation.annotation.Validated; + import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import jakarta.validation.constraints.NotBlank; @@ -19,18 +23,24 @@ /// /// Ubiquitous language: An Entity is a materialized instance of a template schema, /// containing actual values that comply with the template's structure and rules. + public record Entity( UUID id, @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, - + @NotBlank(message = ENTITY_NAME_MANDATORY) String name, - + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, List properties, List relations ) { + /// Compact constructor defensively copies mutable collections to keep the record immutable. + public Entity { + properties = properties != null ? List.copyOf(properties) : List.of(); + relations = relations != null ? List.copyOf(relations) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 4c15dcd..5e7281e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -1,7 +1,6 @@ package com.decathlon.idp_core.domain.model.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; import java.util.UUID; @@ -20,15 +19,16 @@ /// **Business invariants:** /// - Property names must match a [PropertyDefinition] name in the entity's template /// - Property values must satisfy all validation rules from [PropertyRules] -/// - Required properties cannot have empty values -/// - Property types must align with the template's [PropertyType] definition +/// - Required properties cannot have null/blank values +/// - Property values must be typed according to the template's [PropertyType] definition +/// (carried as [Object] so the original JSON type — String, Number, Boolean — is preserved +/// for strict type-mismatch detection at validation time). public record Property( UUID id, @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - @NotBlank(message = PROPERTY_VALUE_MANDATORY) String value ) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 9a0fb0b..04967db 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java @@ -43,4 +43,9 @@ public record EntityTemplate( List relationsDefinitions ) { + /// Compact constructor defensively copies mutable collections to preserve immutability. + public EntityTemplate { + propertiesDefinitions = propertiesDefinitions != null ? List.copyOf(propertiesDefinitions) : List.of(); + relationsDefinitions = relationsDefinitions != null ? List.copyOf(relationsDefinitions) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 02584b9..7ba98f5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -33,6 +33,8 @@ public interface EntityRepositoryPort { Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); List findByIdentifierIn(List identifiers); 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 deleted file mode 100644 index b21d871..0000000 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.decathlon.idp_core.domain.service; - -import java.util.List; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -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; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; - -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; -import lombok.AllArgsConstructor; - -/// Domain service orchestrating [Entity] business operations and validations. -/// -/// **Business purpose:** Coordinates entity lifecycle management while enforcing -/// business rules and maintaining data consistency across the entity-template domain. -/// Serves as the primary entry point for entity operations from application layer. -/// -/// **Key responsibilities:** -/// - Entity retrieval with template validation -/// - Entity creation with business rule enforcement -/// - Entity summary generation for efficient queries -/// - Relationship integrity validation -@Service -@AllArgsConstructor -public class EntityService { - private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; - - /// Retrieves entities filtered by template with existence validation. - /// - /// **Contract:** Returns paginated entities that conform to the specified template. - /// Template existence is validated first to ensure meaningful results. - /// - /// @param pageable pagination configuration for large entity sets - /// @param templateIdentifier business identifier of the entity template - /// @return paginated entities matching the template - /// @throws EntityTemplateNotFoundException when template doesn't exist - @Transactional - public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - } - - /// Provides lightweight entity summaries for efficient bulk operations. - /// - /// **Contract:** Returns summary projections without full entity data, - /// optimized for UI lists and relationship resolution scenarios. - /// - /// @param identifiers business identifiers of entities to summarize - /// @return lightweight entity summaries for the specified identifiers - public List getEntitiesSummariesByIndentifiers(List identifiers) { - return entityRepository.findByIdentifierIn(identifiers); - } - - /// Retrieves a specific entity with template and entity validation. - /// - /// **Contract:** Returns the entity identified by both template and entity identifiers. - /// Validates template existence first, then entity existence, ensuring referential integrity. - /// - /// @param templateIdentifier business identifier of the entity template - /// @param entityIdentifier unique business identifier of the entity within template - /// @return the entity matching both identifiers - /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when entity doesn't exist - @Transactional - public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifier, String entityIdentifier) { - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } - return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, - entityIdentifier)); - } - - /// Creates and persists a new entity with business validation. - /// - /// **Contract:** Validates entity structure against template rules and persists - /// the entity. Future enhancement will include comprehensive business rule validation. - /// - /// @param entity validated entity to create and persist - /// @return the persisted entity with generated identifiers - public Entity createEntity(@Valid Entity entity) { - // Add validations - return entityRepository.save(entity); - } -} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java new file mode 100644 index 0000000..72e40ad --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -0,0 +1,110 @@ +package com.decathlon.idp_core.domain.service.entity; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +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.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +/// Domain service orchestrating [Entity] business operations and validations. +/// +/// **Business purpose:** Coordinates entity lifecycle management while enforcing +/// business rules and maintaining data consistency across the entity-template domain. +/// Serves as the primary entry point for entity operations from application layer. +/// +/// **Key responsibilities:** +/// - Entity retrieval with template validation +/// - Entity creation with business rule enforcement +/// - Entity data integrity validation (entity, properties, relations) +/// - Entity summary generation for efficient queries +@Service +@Validated +@RequiredArgsConstructor +public class EntityService { + private final EntityRepositoryPort entityRepository; + private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; + + /// Retrieves entities filtered by template with existence validation. + /// + /// **Contract:** Returns paginated entities that conform to the specified template. + /// Template existence is validated first to ensure meaningful results. + /// + /// @param pageable pagination configuration for large entity sets + /// @param templateIdentifier business identifier of the entity template + /// @return paginated entities matching the template + /// @throws EntityTemplateNotFoundException when template doesn't exist + @Transactional + public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + + } + + /// Provides lightweight entity summaries for efficient bulk operations. + /// + /// **Contract:** Returns summary projections without full entity data, + /// optimized for UI lists and relationship resolution scenarios. + /// + /// @param identifiers business identifiers of entities to summarize + /// @return lightweight entity summaries for the specified identifiers + public List getEntitiesSummariesByIndentifiers(List identifiers) { + return entityRepository.findByIdentifierIn(identifiers); + } + + /// Retrieves a specific entity with template and entity validation. + /// + /// **Contract:** Returns the entity identified by both template and entity identifiers. + /// Validates template existence first, then entity existence, ensuring referential integrity. + /// + /// @param templateIdentifier business identifier of the entity template + /// @param entityIdentifier unique business identifier of the entity within template + /// @return the entity matching both identifiers + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when entity doesn't exist + @Transactional + public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, + entityIdentifier)); + } + + /// Creates and persists a new entity with business validation. + /// + /// **Contract:** Resolves the referenced template (single round-trip — combined + /// existence check and fetch), enforces entity identifier uniqueness within the + /// template scope, then validates entity/property data integrity against the + /// resolved template before persisting. + /// + /// @param entity validated entity to create and persist + /// @return the persisted entity with generated identifiers + /// @throws EntityTemplateNotFoundException when the referenced template doesn't exist + /// @throws EntityAlreadyExistsException when an entity with the same identifier already exists for this template + /// @throws EntityValidationException when entity, property, or relation data is invalid + @Transactional + public Entity createEntity(@Valid Entity entity) { + EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); + entityValidationService.validateForCreation(entity, template); + return entityRepository.save(entity); + } + + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java new file mode 100644 index 0000000..8143e6c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -0,0 +1,96 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +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.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +import lombok.AllArgsConstructor; + +/// Domain validator for [Entity] aggregates. +/// +/// Validation pipeline: +/// 1. Existence checks (template found, entity not duplicated). +/// 2. Syntactic checks on the entity itself (name/identifier, nested properties, relations). +/// 3. Template-driven semantic checks (required, type, rules). +@Service +@AllArgsConstructor +public class EntityValidationService { + + private final EntityRepositoryPort entityRepository; + private final PropertyValidationService propertyValidationService; + + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// **Contract:** the caller is responsible for resolving the [EntityTemplate] + /// (typically via [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) + /// and passing it in. This avoids a redundant database round-trip and clarifies + /// the dependency graph of the validation service. + /// + /// @param entity the entity to validate + /// @param template the already-resolved template the entity must conform to + /// @throws EntityValidationException when one or more validation rules are violated + /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template + void validateForCreation(Entity entity, EntityTemplate template) { + validateUniqueness(entity); + validateAgainstTemplate(template, entity.properties()); + } + + /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. + /// @param template the entity template whose property definitions are used for validation + /// @param properties the list of properties from the entity to validate + private void validateAgainstTemplate(EntityTemplate template, + List properties) { + Violations violations = new Violations(); + List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); + Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() + .filter(p -> p.name() != null) + .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); + + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null + || property.value() == null + || (property.value().isBlank()); + + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); + } + continue; + } + + propertyValidationService + .validatePropertyValue(definition, property.value()) + .forEach(violations::add); + } + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); + } + } + + /// Checks for existing entity with same template and identifier to prevent duplicates. + /// @param entity the entity to check for existence + /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists + private void validateUniqueness(final Entity entity) { + if (entity.identifier() != null + && entityRepository + .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) + .isPresent()) { + throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java new file mode 100644 index 0000000..92a3dd6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -0,0 +1,35 @@ +package com.decathlon.idp_core.domain.service.entity; +import java.util.ArrayList; +import java.util.List; + +/// Mutable accumulator of validation violation messages. +/// +/// Centralises message formatting and indexed-prefix handling so domain +/// validators stay focused on the rule they enforce rather than on string +/// concatenation. Not thread-safe; intended for short-lived per-request use. +final class Violations { + private final List messages = new ArrayList<>(); + void add(String message) { + messages.add(message); + } + void add(String template, Object... args) { + messages.add(template.formatted(args)); + } + void addIfBlank(String value, String message) { + if (value == null || value.isBlank()) { + messages.add(message); + } + } + + /// Adds a violation prefixed with the indexed collection name, e.g. + /// `Property[2]: Property name is mandatory`. + void addIndexed(String collection, int index, String message) { + messages.add("%s[%d]: %s".formatted(collection, index, message)); + } + boolean isEmpty() { + return messages.isEmpty(); + } + List asList() { + return List.copyOf(messages); + } +} 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 8b2b677..3528e14 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 @@ -13,9 +13,9 @@ import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -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.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.model.entity_template.PropertyRules; 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 cd1d6c7..79647cc 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 @@ -15,7 +15,6 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; - 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; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java new file mode 100644 index 0000000..604891c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -0,0 +1,140 @@ +package com.decathlon.idp_core.domain.service.property; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_ENUM_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_FORMAT_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REGEX_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_MISMATCH; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; + +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; + +/** + * Domain service validating entity property values against template definitions. + */ +@Service +public class PropertyValidationService { + + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + + /// Cache of compiled regex patterns keyed by their source string. + /// Avoids recompiling the same pattern on every property validation call. + private final Map patternCache = new ConcurrentHashMap<>(); + + /** + * Validates a concrete property value against its property definition. + * The value's runtime Java type is checked first against the expected + * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, + * BOOLEAN ⇒ {@link Boolean}). When the type matches, the value is + * normalized to a string and the type-specific rules are evaluated. + * + * @param propertyDefinition property definition with expected type and optional rules + * @param rawValue raw property value preserving its original JSON type + * @return list of violations for this value; empty when valid + */ + public List validatePropertyValue(PropertyDefinition propertyDefinition, Object rawValue) { + return switch (propertyDefinition.type()) { + case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); + }; + } + + + private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + if (!(rawValue instanceof String stringValue)) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minLength() != null && stringValue.length() < rules.minLength()) { + violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); + } + if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { + violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); + } + if (rules.regex() != null + && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(stringValue).matches()) { + violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); + } + if (rules.enumValues() != null && !rules.enumValues().isEmpty() + && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { + violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); + } + if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { + violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); + } + + return List.copyOf(violations); + } + + private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + final BigDecimal parsedValue; + switch (rawValue) { + case Number number -> parsedValue = new BigDecimal(number.toString()); + case String string -> { + try { + parsedValue = new BigDecimal(string); + } catch (NumberFormatException _) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + } + case null, default -> { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.minValue())) < 0) { + violations.add(PROPERTY_MIN_VALUE_VIOLATION.formatted(propertyName, rules.minValue())); + } + if (rules.maxValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.maxValue())) > 0) { + violations.add(PROPERTY_MAX_VALUE_VIOLATION.formatted(propertyName, rules.maxValue())); + } + + return List.copyOf(violations); + } + + private List validateBooleanPropertyValue(String propertyName, Object rawValue) { + if (rawValue instanceof Boolean) { + return List.of(); + } + if (rawValue instanceof String string + && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { + return List.of(); + } + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + } + + private boolean matchesFormat(PropertyFormat format, String value) { + return switch (format) { + case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); + case URL -> URL_PATTERN.matcher(value).matches(); + }; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index e81360a..6109722 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -11,11 +11,7 @@ public record CorsProperties( List allowedOriginPatterns ) { public CorsProperties { - if (allowedOriginPatterns == null) { - allowedOriginPatterns = List.of(); - } - if (allowedOrigins == null) { - allowedOrigins = List.of(); - } + allowedOrigins = allowedOrigins != null ? List.copyOf(allowedOrigins) : List.of(); + allowedOriginPatterns = allowedOriginPatterns != null ? List.copyOf(allowedOriginPatterns) : List.of(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 53aacc8..d645725 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -26,9 +26,12 @@ public class SwaggerDescription { public static final String NO_CONTENT_CODE = "204"; public static final String PARTIAL_CONTENT_CODE = "206"; public static final String BAD_REQUEST_CODE = "400"; + public static final String UNAUTHORIZED_CODE = "401"; + public static final String FORBIDDEN_CODE = "403"; public static final String NOT_FOUND_CODE = "404"; public static final String CONFLICT_CODE = "409"; public static final String SERVICE_UNAVAILABLE_CODE = "503"; + public static final String INTERNAL_SERVER_ERROR_CODE = "500"; /// Entity Template API endpoint constants public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; @@ -78,11 +81,15 @@ public class SwaggerDescription { public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; + public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; public static final String RESPONSE_ENTITY_FOUND = "Entity found"; public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; + public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; + public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; // --- Schema (class) descriptions --- @@ -95,6 +102,8 @@ public class SwaggerDescription { public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; + public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; + public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; // --- Field descriptions (shared) --- public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; @@ -104,6 +113,13 @@ public class SwaggerDescription { public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; + public static final String FIELD_ENTITY_NAME = "Name of the entity"; + public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; + public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; + public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; + public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; + public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; + public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; public static final String FIELD_PROPERTY_NAME = "Property name"; public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; 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..f9f8d90 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 @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.BAD_REQUEST_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CONFLICT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CREATED_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_SUMMARY; @@ -8,20 +9,29 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PAGE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SIZE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SORT_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITIES_PAGINATED_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CONFLICT; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CREATED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_FOUND; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INSUFFICIENT_RIGHTS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_ENTITY_DATA; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_PAGINATION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNAUTHORIZED; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -35,7 +45,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.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; @@ -43,7 +53,6 @@ import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoInMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -51,7 +60,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.AllArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; /// REST API adapter providing entity management endpoints. /// @@ -64,7 +74,7 @@ @RestController @RequestMapping("/api/v1/entities") @Tag(name = "Entities Management", description = "Operations related to entity management") -@AllArgsConstructor +@RequiredArgsConstructor public class EntityController { private final EntityService entityService; @@ -77,14 +87,14 @@ public class EntityController { /// Supports standard REST pagination parameters and returns appropriate HTTP status codes. /// Template validation is handled by the domain service layer. /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control /// @param templateIdentifier template filter for entity scope limitation /// @return paginated entity DTOs optimized for API consumers @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @@ -105,19 +115,19 @@ public Page getEntities( /// Returns HTTP 404 if either template or entity doesn't exist, maintaining REST semantics. /// /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context + /// @param entityIdentifier unique business identifier within template context /// @return entity DTO with full property and relationship data @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut getEntity( @PathVariable String templateIdentifier, @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAnIdentifier(templateIdentifier, entityIdentifier); + Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); return entityDtoOutMapper.fromEntity(entity); } @@ -128,16 +138,22 @@ public EntityDtoOut getEntity( /// and returns HTTP 201 on success, HTTP 400 for validation errors. /// /// @param templateIdentifier target template identifier for entity creation context - /// @param entityDtoIn entity creation payload with properties and relationships + /// @param entityDtoIn entity creation payload with properties and relationships /// @return created entity DTO with server-generated identifiers @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) @PostMapping("/{templateIdentifier}") @ResponseStatus(CREATED) - public EntityDtoOut createEntity(@PathVariable String templateIdentifier, @RequestBody EntityDtoIn entityDtoIn) { + public EntityDtoOut createEntity( + @NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityDtoIn entityDtoIn) { + Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); Entity savedEntity = entityService.createEntity(entity); return entityDtoOutMapper.fromEntity(savedEntity); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 33bd356..7587711 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -1,34 +1,79 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_PROPERTIES; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATIONS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_TARGETS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_IN; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_RELATION_IN; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; + import java.util.List; import java.util.Map; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +/// Input DTO for creating a new entity within a template scope. +/// +/// **Infrastructure validation:** Performs syntactic validation at the API boundary +/// using Jakarta Validation annotations. Semantic validation (schema conformance +/// against template definitions) is handled by the domain service layer. @Data +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = SCHEMA_ENTITY_IN) public class EntityDtoIn { + + @NotBlank(message = ENTITY_NAME_MANDATORY) + @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") private String name; + + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") private String identifier; - private Map properties; + + @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") + private Map properties; + + @Valid + @Schema(description = FIELD_ENTITY_RELATIONS) private List relations; + /// Input DTO for an entity relation instance. + /// + /// **Infrastructure validation:** Validates relation name presence and target + /// identifiers at the API boundary before domain-level schema checks. @Data + @Builder @NoArgsConstructor @AllArgsConstructor - - @Builder @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = SCHEMA_ENTITY_RELATION_IN) public static class RelationDtoIn { + + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) + @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") private String name; + + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) + @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") private List targetEntityIdentifiers; } } 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 70ca673..f373c15 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,15 +15,18 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.HandlerMethodValidationException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; 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; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -31,7 +34,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.servlet.NoHandlerFoundException; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -181,6 +183,40 @@ public ResponseEntity handleRelationCannotTargetItselfException( return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } + /// Handles validation exceptions from Spring MVC handler method parameters. + /// + /// **Error aggregation:** Combines multiple validation error messages into a single + /// user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + log.warn("Handler method validation error: {}", ex.getMessage()); + String errorMessage = ex.getAllErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities already exist. + /// + /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 + /// status indicating business rule conflict for duplicate entities. + @ExceptionHandler(EntityAlreadyExistsException.class) + public ResponseEntity handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { + log.warn("Entity already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity validation fails. + /// + /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status with aggregated + /// validation error messages for client correction. + @ExceptionHandler(EntityValidationException.class) + public ResponseEntity handleEntityValidationException(EntityValidationException ex) { + log.warn("Entity validation failed: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles Bean Validation constraint violations from domain model validation. /// /// **Error aggregation:** Combines multiple constraint violation messages into @@ -232,12 +268,6 @@ public ResponseEntity handleEntityNotFoundException(EntityNotFoun ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); return ResponseEntity.status(NOT_FOUND).body(errorResponse); } - - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity handleNotFound(NoHandlerFoundException e) { - return createErrorResponse(NOT_FOUND, "Resource not found: " + e.getRequestURL()); - } - private String parseHttpMessageNotReadableError(String originalMessage) { if (originalMessage == null) { return "Invalid request body format"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index a5e6b8f..5548ec0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -11,8 +12,6 @@ import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting API request DTOs to domain [Entity] objects. /// /// **Infrastructure mapping responsibilities:** @@ -28,38 +27,35 @@ /// /// **API contract support:** Enables clean separation between API request format /// and internal domain model structure for maintainable API evolution. - @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoInMapper { + + /// Converts an entity creation request DTO to a domain entity. + /// + /// @param entityDtoIn the entity creation request payload + /// @param entityTemplateIdentifier the target template identifier + /// @return the mapped domain entity with audit fields populated public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemplateIdentifier) { List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> { - String value; - if (entry.getValue() != null) { - value = String.valueOf(entry.getValue()); - } else { - value = null; - } - return new Property( - null, - entry.getKey(), - value - ); - }) - .toList(); + .map((Map.Entry entry) -> new Property( + null, + entry.getKey(), + entry.getValue() + )) + .toList(); List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() : entityDtoIn.getRelations().stream() - .map(relDto -> new Relation( - null, - relDto.getName(), - null, // targetTemplateIdentifier not available in DTO - relDto.getTargetEntityIdentifiers() - )) - .toList(); + .map(relDto -> new Relation( + null, + relDto.getName(), + null, + relDto.getTargetEntityIdentifiers() + )) + .toList(); return new Entity( null, @@ -70,5 +66,4 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp relations ); } - } 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 2bd2b04..be15c3c 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 @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; @@ -20,14 +21,12 @@ 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.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.RelationService; +import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting domain [Entity] objects to API DTOs. /// /// **Infrastructure mapping responsibilities:** @@ -46,7 +45,7 @@ /// - Integrates with Jackson for JSON serialization patterns /// - Stateless design ensures thread safety in web containers @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoOutMapper { private final EntityTemplateService entityTemplateService; @@ -72,11 +71,11 @@ public EntityDtoOut fromEntity(Entity entity) { /// to minimize database queries. Builds summary maps for efficient relationship /// resolution across the entire page. /// - /// @param entities paginated domain entities from repository layer + /// @param entities paginated domain entities from repository layer /// @param entityTemplateIdentifier template identifier for batch template resolution /// @return paginated API DTOs with complete relationship data public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { + String entityTemplateIdentifier) { Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( @@ -94,7 +93,7 @@ public Page fromEntitiesPageToDtoPage(Page entities, /// @param entity the entity to map /// @param entityTemplate the template for property type mapping /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { Map props = mapPropertiesDto(entity, entityTemplate); List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); @@ -120,13 +119,13 @@ private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate /// /// @param entity the entity to map /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation - /// targets + /// @param relatedEntitiesSummaries map of entity summaries for relation + /// targets /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return the mapped DTO private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, - Map relatedEntitiesSummaries, - Map> relationTargetOwnershipsMap) { + Map relatedEntitiesSummaries, + Map> relationTargetOwnershipsMap) { Map props = mapPropertiesDto(entity, entityTemplate); Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaries); @@ -191,20 +190,20 @@ private Object convertPropertyValue(Property property, PropertyDefinition defini /// Maps the relations of an entity to a map of relation names to lists of target /// entity summaries. /// - /// @param entity the entity whose relations to map + /// @param entity the entity whose relations to map /// @param relatedEntitiesSummaries map of entity summaries for relation targets /// @return a map of relation names to lists of target entity summaries private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { + Map relatedEntitiesSummaries) { return entity.relations() == null ? Collections.emptyMap() : entity.relations().stream() - .collect(Collectors.groupingBy( - Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() + .collect(Collectors.groupingBy( + Relation::name, + Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() .map(relatedEntitiesSummaries::get) .filter(Objects::nonNull), - Collectors.toList()))); + Collectors.toList()))); } /// @@ -212,11 +211,11 @@ private Map> mapRelationsDto(Entity entity, /// lists of source entity summaries. /// /// @param entity the entity whose relations-as-target to - /// map + /// map /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return a map of relation names to lists of source entity summaries private Map> mapRelationsAsTargetDto(Entity entity, - Map> relationTargetOwnershipsMap) { + Map> relationTargetOwnershipsMap) { List relationAsTargetSummaries = relationTargetOwnershipsMap .get(entity.identifier()); if (relationAsTargetSummaries == null) { @@ -275,8 +274,8 @@ private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { return entity.relations() == null ? Collections.emptyList() : new ArrayList<>(entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream()) - .collect(Collectors.toSet())); + .flatMap(rel -> rel.targetEntityIdentifiers().stream()) + .collect(Collectors.toSet())); } /// @@ -290,7 +289,7 @@ private List getUniqueTargetIdentifiersInPage(Page entities) { .flatMap(entity -> entity.relations() == null ? Stream.empty() : entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream())) + .flatMap(rel -> rel.targetEntityIdentifiers().stream())) .collect(Collectors.toSet())); } @@ -313,10 +312,10 @@ private Map buildEntitiesSummariesMap(List tar return targetIdentifiers.isEmpty() ? Collections.emptyMap() : entityService.getEntitiesSummariesByIndentifiers(targetIdentifiers) - .stream() - .collect(Collectors.toMap( - EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); + .stream() + .collect(Collectors.toMap( + EntitySummary::identifier, + es -> new EntitySummaryDto(es.identifier(), es.name()))); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 680c482..2a877ee 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -40,9 +40,16 @@ public Optional findByTemplateIdentifierAndIdentifier(String templateIde .map(mapper::toDomain); } + @Override + public Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName) { + return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) + .map(mapper::toDomain); + } + @Override public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { - return jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable).map(mapper::toDomain); + var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return pageableEntity.map(mapper::toDomain); } @Override diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 120fd65..cc7edac 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -1,7 +1,9 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; +import org.mapstruct.Named; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index cd3f143..848693d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -21,7 +21,9 @@ @jakarta.persistence.Entity @Data -@Table(name = "entity") +@Table(name = "entity", uniqueConstraints = { + @UniqueConstraint(columnNames = {"identifier", "template_identifier"}) +}) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 44e8ac9..97675e9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -27,6 +27,8 @@ public interface JpaEntityRepository extends JpaRepository findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); @Modifying(clearAutomatically = true, flushAutomatically = true) diff --git a/src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql b/src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql new file mode 100644 index 0000000..11255aa --- /dev/null +++ b/src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql @@ -0,0 +1,9 @@ +-- Change unique constraint on entity table: +-- Drop the unique constraint on identifier alone +-- Add a composite unique constraint on (identifier, template_identifier) +-- This allows the same identifier to exist across different templates + +ALTER TABLE entity DROP CONSTRAINT entity_identifier_key; + +ALTER TABLE entity ADD CONSTRAINT entity_identifier_template_identifier_key + UNIQUE (identifier, template_identifier); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java new file mode 100644 index 0000000..22b4cb9 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -0,0 +1,164 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +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.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityService Tests") +class EntityServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + + @Mock + private EntityValidationService entityValidationService; + + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + + @Mock + private EntityTemplateService entityTemplateService; + + @InjectMocks + private EntityService entityService; + + @Test + @DisplayName("Should return entities page by template identifier") + void shouldReturnEntitiesByTemplateIdentifier() { + var pageable = Pageable.ofSize(10); + var entity = entity("template-a", "entity-a", "Entity A"); + var page = new PageImpl<>(List.of(entity)); + + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(page); + + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); + + assertSame(page, result); + verify(entityRepository).findByTemplateIdentifier("template-a", pageable); + } + + @Test + @DisplayName("Should return entity summaries by identifiers") + void shouldReturnEntitySummariesByIdentifiers() { + var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); + when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); + + var result = entityService.getEntitiesSummariesByIndentifiers(List.of("service-a")); + + assertEquals(summaries, result); + verify(entityRepository).findByIdentifierIn(List.of("service-a")); + } + + @Test + @DisplayName("Should return entity by template and identifier") + void shouldReturnEntityByTemplateAndIdentifier() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + + assertSame(entity, result); + verify(entityTemplateValidationService).validateTemplateExists("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + } + + @Test + @DisplayName("Should throw when entity is not found for template") + void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); + } + + @Test + @DisplayName("Should create entity when validations pass") + void shouldCreateEntityWhenValidationsPass() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.save(entity)).thenReturn(entity); + + var result = entityService.createEntity(entity); + + assertSame(entity, result); + + InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityValidationService).validateForCreation(entity, template); + inOrder.verify(entityRepository).save(entity); + verifyNoInteractions(entityTemplateValidationService); + } + + @Test + @DisplayName("Should not save when entity already exists") + void shouldNotSaveWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); + var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); + + assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityValidationService).validateForCreation(entity, template); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should stop immediately when template does not exist") + void shouldStopWhenTemplateDoesNotExistOnCreate() { + var entity = entity("missing-template", "catalog-api", "Catalog API"); + + when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) + .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); + verifyNoInteractions(entityValidationService); + verifyNoMoreInteractions(entityRepository); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java new file mode 100644 index 0000000..6411bd6 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -0,0 +1,231 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +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; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityValidationService Tests") +class EntityValidationServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + + @Mock + private PropertyValidationService propertyValidationService; + + @InjectMocks + private EntityValidationService entityValidationService; + + @Test + @DisplayName("Should throw when entity with same identifier already exists") + void shouldThrowWhenEntityAlreadyExists() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateForCreation(entity, template)); + } + + @Test + @DisplayName("Should not query repository when identifier is null") + void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); + + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + + @Test + @DisplayName("Should aggregate property requirements and rule violations") + void shouldAggregateAllViolationsDuringValidateForCreation() { + var portDefinition = new PropertyDefinition( + UUID.randomUUID(), + "port", + "Port", + PropertyType.NUMBER, + true, + new PropertyRules(null, null, null, null, null, null, 65535, 1024)); + + var requiredDefinition = new PropertyDefinition( + UUID.randomUUID(), + "ownerEmail", + "Owner email", + PropertyType.STRING, + true, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(requiredDefinition, portDefinition), + List.of()); + + var entity = entity( + "web-service", + " ", // Blank identifier (handled by Jakarta, not this service) + " ", // Blank name (handled by Jakarta, not this service) + List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), + List.of()); // No relations + + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); + + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateForCreation(entity, template)); + + // Expecting exactly 2 errors: the missing required property, and the invalid port value. + assertEquals(2, exception.getViolations().size()); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(0)); + assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(1)); + + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + } + + @Test + @DisplayName("Should validate entity successfully when no violations") + void shouldValidateForCreationSuccessfullyWhenNoViolations() { + var versionDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(versionDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), + null); + + + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + } + + @Test + @DisplayName("Should skip property rule validation for missing optional property") + void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { + var optionalDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(optionalDefinition), + List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + verifyNoInteractions(propertyValidationService); + } + + @Test + @DisplayName("Should validate property of type STRING with a numeric string value '1234'") + void shouldValidateStringPropertyWithNumericStringValue() { + var stringDefinition = new PropertyDefinition( + UUID.randomUUID(), + "versionCode", + "Version Code as String", + PropertyType.STRING, + false, + null + ); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(stringDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), + null); + when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); + } + + private Entity entity( + String templateIdentifier, + String identifier, + String name, + List properties, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, relations); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java new file mode 100644 index 0000000..14ed3f6 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -0,0 +1,336 @@ +package com.decathlon.idp_core.domain.service.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +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.constant.ValidationMessages; +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("PropertyValidationService Tests") +class PropertyValidationServiceTest { + + private final PropertyValidationService service = new PropertyValidationService(); + + @Nested + @DisplayName("STRING validation") + class StringValidationTests { + + @Test + @DisplayName("Should report type mismatch when STRING value is null") + void shouldReportTypeMismatchWhenStringValueIsNull() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, null); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } + + @Test + @DisplayName("Should return no violations when STRING has no rules") + void shouldReturnNoViolationsWhenStringHasNoRules() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, "hello"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when STRING value satisfies all rules") + void shouldReturnNoViolationsWhenStringPassesAllRules() { + var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); + var definition = propertyDefinition("env", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "dev"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minLength violation") + void shouldReportMinLengthViolation() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "ab"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report maxLength violation") + void shouldReportMaxLengthViolation() { + var rules = new PropertyRules(null, null, null, null, 5, null, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "too-long-value"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report regex violation") + void shouldReportRegexViolation() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "abc"); + + assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); + } + + @Test + @DisplayName("Should accept value matching regex") + void shouldAcceptValueMatchingRegex() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "12345"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report enum violation when value not in allowed list") + void shouldReportEnumViolation() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "UNKNOWN"); + + assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); + } + + @Test + @DisplayName("Should accept enum value with case-insensitive match") + void shouldAcceptEnumValueCaseInsensitive() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "active"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should skip enum check when enumValues is empty") + void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { + var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "anything"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid EMAIL") + void shouldReportFormatViolationForInvalidEmail() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-an-email"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); + } + + @Test + @DisplayName("Should accept valid EMAIL format") + void shouldAcceptValidEmailFormat() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "user@example.com"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid URL") + void shouldReportFormatViolationForInvalidUrl() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-a-url"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); + } + + @Test + @DisplayName("Should accept valid URL format") + void shouldAcceptValidUrlFormat() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report multiple violations at once") + void shouldReportMultipleStringViolations() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "AA"); + + assertEquals(4, violations.size()); + } + + @Test + @DisplayName("Should use cached pattern for repeated regex validations") + void shouldUseCachedPatternForRepeatedRegex() { + var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + // Validate twice with the same regex to exercise the cache + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); + + assertEquals(List.of(), violations1); + assertEquals(List.of(), violations2); + } + } + + @Nested + @DisplayName("NUMBER validation") + class NumberValidationTests { + + @Test + @DisplayName("Should report type mismatch for non-numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "not-a-number"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER has no rules") + void shouldReturnNoViolationsWhenNumberHasNoRules() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "42"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER is within bounds") + void shouldReturnNoViolationsWhenNumberIsWithinBounds() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("score", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "50"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minValue violation") + void shouldReportMinValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "3"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + } + + @Test + @DisplayName("Should report maxValue violation") + void shouldReportMaxValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "15"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); + } + + @Test + @DisplayName("Should report both minValue and maxValue violations") + void shouldReportBothMinAndMaxViolations() { + // minValue > maxValue edge case — value below min triggers min violation + var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); + var definition = propertyDefinition("range", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "7"); + + // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation + assertEquals(2, violations.size()); + } + + @Test + @DisplayName("Should accept decimal number values") + void shouldAcceptDecimalNumberValues() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "99.5"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") + void shouldReportTypeMismatchWhenBooleanSentForNumber() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "true"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); + } + } + + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { + + @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") + @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) + void shouldAcceptValidBooleanValues(String value) { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, value); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "yes"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); + } + + @Test + @DisplayName("Should report type mismatch when a number is sent for a BOOLEAN property") + void shouldReportTypeMismatchWhenNumberSentForBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "42"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); + } + } + + private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { + return new PropertyDefinition(null, name, "description", type, true, rules); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 9675a33..ccee22d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -17,21 +17,19 @@ import com.decathlon.idp_core.AbstractIntegrationTest; - /// Integration tests for the EntityController REST API endpoints. - /// These tests verify the behavior of entity retrieval endpoints, including - /// pagination, authentication, and lookup by template identifier and entity - /// identifier. +/// Integration tests for the EntityController REST API endpoints. +/// These tests verify the behavior of entity retrieval endpoints, including +/// pagination, authentication, and lookup by template identifier and entity +/// identifier. public class EntityControllerTest extends AbstractIntegrationTest { - @Autowired - private MockMvc mockMvc; - private static final String TEMPLATE_IDENTIFIER = "web-service"; private static final String ENTITY_IDENTIFIER = "web-api-2"; private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; - + @Autowired + private MockMvc mockMvc; /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated /// retrieval). @@ -44,9 +42,9 @@ class GetEntitiesByTemplateIdentifierTests { @WithMockUser void getEntities_paginated_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("page", "0") - .param("size", "15") - .accept(APPLICATION_JSON)) + .param("page", "0") + .param("size", "15") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -63,7 +61,7 @@ void getEntities_paginated_200() throws Exception { @WithMockUser void getEntities_paginated_404_when_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -71,7 +69,7 @@ void getEntities_paginated_404_when_non_existent_template() throws Exception { @DisplayName("Should return 401 without authentication") void getTemplates_paginated_401_without_user_token() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isUnauthorized()); } @@ -81,10 +79,10 @@ void getTemplates_paginated_401_without_user_token() throws Exception { void getEntities_paginated_200_custom() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) + .param("page", "1") + .param("size", "5") + .param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content.length()").value(1)) @@ -100,7 +98,7 @@ void getEntities_paginated_200_custom() throws Exception { @WithMockUser void getEntities_invalid_pagination_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -124,7 +122,7 @@ class GetEntitiesByTemplateAndEntityIdentifierTests { @WithMockUser void getEntityByTemplateAndIdentifier_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) @@ -136,7 +134,7 @@ void getEntityByTemplateAndIdentifier_200() throws Exception { @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -145,7 +143,7 @@ void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } } @@ -159,14 +157,108 @@ class PostEntitiesTests { @DisplayName("Should create entity and return 201") void postEntity_201() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) .andExpect(status().isCreated()) .andReturn(); } + @Test + @WithMockUser() + @DisplayName("Should return 400 when required template properties are missing") + void postEntity_400_when_required_properties_missing() throws Exception { + var payload = """ + { + "name": "web-service-missing-required", + "identifier": "web-service-missing-required", + "properties": { + "port": "8080" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property type does not match template") + void postEntity_400_when_property_type_mismatch() throws Exception { + var payload = """ + { + "name": "web-service-invalid-type", + "identifier": "web-service-invalid-type", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "not-a-number", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property rules are not respected") + void postEntity_400_when_property_rules_not_respected() throws Exception { + var payload = """ + { + "name": "web-service-invalid-rules", + "identifier": "web-service-invalid-rules", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "invalid-email", + "port": "80", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "invalid-url", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match required format EMAIL"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match required format URL"), + org.hamcrest.Matchers.containsString("Property 'port' value must be greater than or equal to 1024") + ))); + } + } } 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 f8a5a02..c6b2a23 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 @@ -311,10 +311,6 @@ void postTemplate_400_name_invalid_pattern() throws Exception { /// This test verifies that: /// - Validation error message indicates property definitions are /// @throws Exception if the MockMvc request fails - /// Tests the POST /api/v1/entity-templates endpoint when property name field is - /// missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @DisplayName("Returns 400 when property name is missing") 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 880ade3..dd840bc 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,10 +25,13 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; 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.exception.entity.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; - import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -105,6 +108,94 @@ void shouldHandleEntityTemplateAlreadyExistsException() { assertEquals(HttpStatus.CONFLICT.name(), body.getError()); assertEquals(expectedMessage, body.getErrorDescription()); } + + /// Tests the handling of [EntityAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the original domain exception message + @Test + @DisplayName("Should handle EntityAlreadyExistsException with 409 status") + void shouldHandleEntityAlreadyExistsException() { + // Given + EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", "api-gateway"); + + // When + ResponseEntity response = exceptionHandler.handleEntityAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle EntityValidationException with 400 status") + void shouldHandleEntityValidationException() { + EntityValidationException exception = new EntityValidationException(java.util.List.of("Invalid property")); + + ResponseEntity response = exceptionHandler.handleEntityValidationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNameAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and description + @Test + @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateNameAlreadyExistsException() { + // Given + String name = "Duplicate Name"; + EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException(name); + + // When + ResponseEntity response = exceptionHandler.handleEntityTemplateNameAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityNotFoundException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the entity-specific context message + @Test + @DisplayName("Should handle EntityNotFoundException with 404 status") + void shouldHandleEntityNotFoundException() { + // Given + EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); + + // When + ResponseEntity response = exceptionHandler.handleEntityNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } } @Nested @@ -226,6 +317,45 @@ private ConstraintViolation createMockConstraintViolation(String message @DisplayName("HTTP Message Exception Handling") class HttpMessageExceptionTests { + /// Provides test data for [HttpMessageNotReadableException] scenarios. + /// Each argument contains: input message and expected error description. + static Stream httpMessageNotReadableExceptionTestData() { + return Stream.of( + Arguments.of( + "Required request body is missing: public ResponseEntity", + "Request body is required" + ), + Arguments.of( + "JSON parse error: Unexpected character", + "Invalid JSON format in request body" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_TYPE' for property 'type'" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_FORMAT' for property 'format'" + ), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body" + ), + Arguments.of( + "Cannot deserialize value of type `com.example.SomeType`: some other error", + "Invalid type: expected SomeType" + ), + Arguments.of( + "Something completely unexpected happened", + "Invalid request body format" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", + "Invalid value for property 'type'" + ) + ); + } + /// Tests the handling of [HttpMessageNotReadableException] when exception message is null. /// /// **This test verifies that:** @@ -252,29 +382,6 @@ void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { assertEquals("Invalid request body format", body.getErrorDescription()); } - /// Provides test data for [HttpMessageNotReadableException] scenarios. - /// Each argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of( - "Required request body is missing: public ResponseEntity", - "Request body is required" - ), - Arguments.of( - "JSON parse error: Unexpected character", - "Invalid JSON format in request body" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'" - ), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body" - ) - ); - } - /// Parameterized test for handling [HttpMessageNotReadableException] with various error scenarios. /// /// **This test verifies that different types of HttpMessageNotReadableException are properly @@ -290,7 +397,7 @@ static Stream httpMessageNotReadableExceptionTestData() { /// - User-friendly error description is provided /// - Error response structure is consistent /// - /// @param originalMessage the original exception message to be processed + /// @param originalMessage the original exception message to be processed /// @param expectedErrorDescription the expected user-friendly error description @ParameterizedTest @MethodSource("httpMessageNotReadableExceptionTestData") diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json new file mode 100644 index 0000000..4c00a50 --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json @@ -0,0 +1,6 @@ +{ + "identifier": "temp-test-0", + "description": "This is a test template", + "properties_definitions": [], + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json new file mode 100644 index 0000000..996e560 --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json @@ -0,0 +1,6 @@ +{ + "identifier": "web-service", + "name": "web-service", + "description": "This is a test template", + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json index 82367a2..3593858 100644 --- a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json @@ -1,9 +1,15 @@ { - "name": "microservice-2", - "identifier": "microservice-2", + "name": "web-service-valid-1", + "identifier": "web-service-valid-1", "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", "port": "8080", - "environment": "dev" - }, - "relations": [] + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } } diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json new file mode 100644 index 0000000..678a6bf --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json @@ -0,0 +1,4 @@ +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json new file mode 100644 index 0000000..8643862 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json @@ -0,0 +1,14 @@ +{ + "name": "microservice-with-relations", + "identifier": "microservice-with-relations", + "properties": { + "port": "9090", + "environment": "staging" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": ["web-api-1"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json new file mode 100644 index 0000000..20e6fe2 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json @@ -0,0 +1,7 @@ +{ + "name": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json new file mode 100644 index 0000000..7c0b057 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json @@ -0,0 +1,7 @@ +{ + "identifier": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json new file mode 100644 index 0000000..570184e --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json @@ -0,0 +1,8 @@ +{ + "name": "entity-prop-no-value", + "identifier": "entity-prop-no-value", + "properties": { + "applicationName": "" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json new file mode 100644 index 0000000..9bb5cbc --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json @@ -0,0 +1,11 @@ +{ + "name": "entity-rel-no-name", + "identifier": "entity-rel-no-name", + "properties": {}, + "relations": [ + { + "name": "", + "target_entity_identifiers": ["some-target"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json new file mode 100644 index 0000000..e850f2b --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json @@ -0,0 +1,6 @@ +{ + "name": "Web API 1 duplicate", + "identifier": "web-api-1", + "properties": {}, + "relations": [] +} From 5107f10677c68a754fc7af3053f01a43949cb7a4 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Wed, 20 May 2026 15:55:35 +0200 Subject: [PATCH 36/51] feat: add indexes and update spec builder for performance --- .../EntitySearchSpecification.java | 139 +++++++++++------- .../V3_4__add_search_performance_indexes.sql | 69 +++++++++ 2 files changed, 158 insertions(+), 50 deletions(-) create mode 100644 src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java index 5deba0a..8175c1d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -9,7 +9,6 @@ import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.model.enums.SearchOperator; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; -import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; import jakarta.persistence.criteria.CriteriaBuilder; @@ -29,15 +28,21 @@ /// - [SearchFilterNode.Criterion] nodes are translated based on the field prefix: /// - `template` → direct predicate on templateIdentifier /// - `identifier` / `name` → direct predicates on the entity root -/// - `property.{name}` → INNER JOIN on the `properties` collection -/// - `relation.{name}` / `relation.{name}.identifier|name` → JOIN on -/// `relations` with optional sub-query for target entity properties -/// - `relations_as_target.{name}.identifier|name` → correlated sub-query +/// - `property.{name}` → correlated EXISTS subquery on the `properties` collection +/// - `relation.{name}` / `relation.{name}.identifier|name` → correlated EXISTS subquery +/// on `relations` with optional nested IN subquery for target entity properties +/// - `relations_as_target.{name}.identifier|name` → correlated IN subquery /// that finds entities targeted by qualifying reverse relations /// +/// **Performance:** All collection-based filters use EXISTS subqueries instead of JOINs. +/// This eliminates row multiplication (an entity with N properties and M relations would +/// otherwise produce N×M rows requiring DISTINCT), making pagination and count queries +/// significantly cheaper. +/// /// **Security:** LIKE-based operators ([SearchOperator#CONTAINS], [SearchOperator#NOT_CONTAINS], -/// [SearchOperator#STARTS_WITH], [SearchOperator#ENDS_WITH]) escape SQL wildcards (`%` and -/// `_`) in user-supplied values to prevent unintended pattern matching. +/// [SearchOperator#STARTS_WITH], [SearchOperator#ENDS_WITH]) use PostgreSQL `ILIKE` for +/// case-insensitive matching. SQL wildcards (`%` and `_`) in user-supplied values are escaped +/// to prevent unintended pattern matching. EQ and NEQ use `LOWER()` with functional btree indexes. @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class EntitySearchSpecification { @@ -65,21 +70,23 @@ public static Specification of(SearchFilterNode filter) { /// /// The four conditions are combined with OR so that a match on any field is sufficient. /// The "any property" branch uses a correlated EXISTS subquery to avoid row multiplication. + /// All comparisons use `ILIKE` so that `pg_trgm` GIN indexes can be leveraged. /// /// @param query the search string; must be non-null and non-blank /// @return a [Specification] implementing the global text search public static Specification globalTextSearch(String query) { - String escaped = escapeLikeWildcards(query.toLowerCase()); + // No toLowerCase() needed — ILIKE is inherently case-insensitive. + String escaped = escapeLikeWildcards(query); String pattern = "%" + escaped + "%"; Specification byIdentifier = - (root, q, cb) -> cb.like(cb.lower(root.get(IDENTIFIER)), pattern, LIKE_ESCAPE_CHAR); + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(IDENTIFIER), pattern, LIKE_ESCAPE_CHAR); Specification byName = - (root, q, cb) -> cb.like(cb.lower(root.get(NAME)), pattern, LIKE_ESCAPE_CHAR); + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(NAME), pattern, LIKE_ESCAPE_CHAR); Specification byTemplate = - (root, q, cb) -> cb.like(cb.lower(root.get(TEMPLATE_IDENTIFIER)), pattern, LIKE_ESCAPE_CHAR); + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(TEMPLATE_IDENTIFIER), pattern, LIKE_ESCAPE_CHAR); Specification byAnyProperty = (root, queryCtx, cb) -> { // Correlated EXISTS: does this entity have at least one property whose value matches? @@ -89,7 +96,7 @@ public static Specification globalTextSearch(String query) { sub.select(cb.literal(1)) .where( cb.equal(subRoot.get("id"), root.get("id")), - cb.like(cb.lower(propJoin.get("value").as(String.class)), pattern, LIKE_ESCAPE_CHAR) + ((HibernateCriteriaBuilder) cb).ilike(propJoin.get("value").as(String.class), pattern, LIKE_ESCAPE_CHAR) ); return cb.exists(sub); }; @@ -150,12 +157,18 @@ private static Specification buildCriterion(SearchFilterNode.Cr private static Specification propertySpec(SearchFilterNode.Criterion c, String propertyName) { return (root, query, cb) -> { - query.distinct(true); - Join propJoin = root.join("properties"); - return cb.and( - cb.equal(propJoin.get(NAME), propertyName), - buildPredicate(cb, propJoin.get("value"), c.operation(), c.value()) - ); + // Correlated EXISTS: does this entity have a property with the given name and value? + // Using EXISTS instead of JOIN avoids row multiplication and removes the need for DISTINCT. + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var propJoin = subRoot.join("properties"); + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + cb.equal(propJoin.get(NAME), propertyName), + buildPredicate(cb, propJoin.get("value"), c.operation(), c.value()) + ); + return cb.exists(sub); }; } @@ -163,9 +176,16 @@ private static Specification propertySpec(SearchFilterNode.Crit private static Specification relationNameSpec(SearchFilterNode.Criterion c) { return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - return buildPredicate(cb, relJoin.get(NAME), c.operation(), c.value()); + // Correlated EXISTS: does this entity have at least one relation whose name matches? + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var relJoin = subRoot.join(RELATIONS); + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + buildPredicate(cb, relJoin.get(NAME), c.operation(), c.value()) + ); + return cb.exists(sub); }; } @@ -183,33 +203,46 @@ private static Specification relationSpec(SearchFilterNode.Crit private static Specification relationEntitySpec(SearchFilterNode.Criterion c, String relationName) { return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - return cb.and( - cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, targetJoin, c.operation(), c.value()) - ); + // Correlated EXISTS: does this entity have a relation named + // whose target entity identifier matches the criterion? + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var relJoin = subRoot.join(RELATIONS); + var targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, targetJoin, c.operation(), c.value()) + ); + return cb.exists(sub); }; } private static Specification relationPropertySpec( SearchFilterNode.Criterion c, String relationName, String property) { return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - - // Subquery: find target entity identifiers whose identifier/name matches - var subquery = query.subquery(String.class); - var subRoot = subquery.from(EntityJpaEntity.class); - subquery.select(subRoot.get(IDENTIFIER)) - .where(buildPredicate(cb, subRoot.get(property), c.operation(), c.value())); - - return cb.and( - cb.equal(relJoin.get(NAME), relationName), - cb.in(targetIdJoin).value(subquery) - ); + // Correlated EXISTS: does this entity have a relation named + // whose target identifier appears in the set of entity identifiers + // whose matches the criterion? + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var relJoin = subRoot.join(RELATIONS); + var targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + + // Inner scalar subquery: entity identifiers whose identifier/name satisfies the criterion. + var innerSubquery = query.subquery(String.class); + var innerRoot = innerSubquery.from(EntityJpaEntity.class); + innerSubquery.select(innerRoot.get(IDENTIFIER)) + .where(buildPredicate(cb, innerRoot.get(property), c.operation(), c.value())); + + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + cb.equal(relJoin.get(NAME), relationName), + cb.in(targetIdJoin).value(innerSubquery) + ); + return cb.exists(sub); }; } @@ -252,25 +285,29 @@ private static Predicate buildPredicate( if (isNumericOperator(operator)) { return buildNumericPredicate(cb, field, operator, new BigDecimal(value)); } + HibernateCriteriaBuilder hcb = (HibernateCriteriaBuilder) cb; Expression stringField = field.as(String.class); return switch (operator) { + // EQ / NEQ use lower() + functional btree index (V3_4) for optimal equality matching. case EQ -> cb.equal(cb.lower(stringField), value.toLowerCase()); case NEQ -> cb.notEqual(cb.lower(stringField), value.toLowerCase()); + // LIKE operators use ILIKE so that pg_trgm GIN indexes (V3_5) can be leveraged. + // No pre-lowercasing of the value — ILIKE is inherently case-insensitive. case CONTAINS -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.like(cb.lower(stringField), "%" + escaped + "%", LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); } case NOT_CONTAINS -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.notLike(cb.lower(stringField), "%" + escaped + "%", LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value); + yield hcb.notIlike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); } case STARTS_WITH -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.like(cb.lower(stringField), escaped + "%", LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, escaped + "%", LIKE_ESCAPE_CHAR); } case ENDS_WITH -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.like(cb.lower(stringField), "%" + escaped, LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, "%" + escaped, LIKE_ESCAPE_CHAR); } default -> throw new IllegalStateException("Unhandled operator: " + operator); }; @@ -305,6 +342,8 @@ private static Predicate buildNumericPredicate( /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are /// treated as literal characters rather than pattern metacharacters. + /// Used by both `ILIKE`-based operators and `LOWER() LIKE`-based comparisons. + /// The value does **not** need to be pre-lowercased for `ILIKE` operators. static String escapeLikeWildcards(String value) { return value .replace(String.valueOf(LIKE_ESCAPE_CHAR), LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) diff --git a/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql b/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql new file mode 100644 index 0000000..2006ec4 --- /dev/null +++ b/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql @@ -0,0 +1,69 @@ +-- Flyway migration script: add search performance indexes +-- Purpose: Support the EXISTS-based search specifications and ILIKE-based operators +-- with index scans instead of full table scans. +-- +-- Strategy: +-- - Btree functional lower(col) indexes → EQ / NEQ comparisons using LOWER() +-- - Btree indexes on relation columns → exact equality lookups in EXISTS subqueries +-- - pg_trgm GIN indexes → ILIKE CONTAINS / ENDS_WITH / STARTS_WITH + +-- Enable pg_trgm for GIN trigram indexes used by ILIKE operators +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- ── Relation indexes ──────────────────────────────────────────────────────── + +-- Exact equality on relation name (used in all relation EXISTS subqueries) +CREATE INDEX idx_relation_name + ON relation (name); + +COMMENT ON INDEX idx_relation_name IS 'Supports exact relation name equality in EXISTS subqueries'; + +-- GIN trigram index for ILIKE-based relation name searches (CONTAINS, ENDS_WITH, STARTS_WITH) +CREATE INDEX idx_relation_name_trgm + ON relation USING GIN (name gin_trgm_ops); + +COMMENT ON INDEX idx_relation_name_trgm IS 'GIN trigram index for ILIKE pattern matching on relation name'; + +-- Reverse-relation lookup: target entity identifier in relationsAsTargetSpec +CREATE INDEX idx_relation_target_entities_identifier + ON relation_target_entities (target_entity_identifier); + +COMMENT ON INDEX idx_relation_target_entities_identifier IS 'Supports reverse relation lookups by target entity identifier'; + +-- ── Entity indexes ────────────────────────────────────────────────────────── + +-- Functional btree indexes for EQ / NEQ which use LOWER(col) = lower_value +CREATE INDEX idx_entity_name_lower + ON entity (lower(name)); + +CREATE INDEX idx_entity_identifier_lower + ON entity (lower(identifier)); + +CREATE INDEX idx_entity_template_identifier_lower + ON entity (lower(template_identifier)); + +COMMENT ON INDEX idx_entity_name_lower IS 'Supports LOWER(name) = value equality comparisons (EQ operator)'; +COMMENT ON INDEX idx_entity_identifier_lower IS 'Supports LOWER(identifier) = value equality comparisons (EQ operator)'; +COMMENT ON INDEX idx_entity_template_identifier_lower IS 'Supports LOWER(template_identifier) = value equality comparisons (EQ operator)'; + +-- GIN trigram indexes for ILIKE-based searches (CONTAINS, ENDS_WITH, STARTS_WITH) +CREATE INDEX idx_entity_name_trgm + ON entity USING GIN (name gin_trgm_ops); + +CREATE INDEX idx_entity_identifier_trgm + ON entity USING GIN (identifier gin_trgm_ops); + +CREATE INDEX idx_entity_template_identifier_trgm + ON entity USING GIN (template_identifier gin_trgm_ops); + +COMMENT ON INDEX idx_entity_name_trgm IS 'GIN trigram index for ILIKE pattern matching on entity name'; +COMMENT ON INDEX idx_entity_identifier_trgm IS 'GIN trigram index for ILIKE pattern matching on entity identifier'; +COMMENT ON INDEX idx_entity_template_identifier_trgm IS 'GIN trigram index for ILIKE pattern matching on entity template identifier'; + +-- ── Property indexes ──────────────────────────────────────────────────────── + +-- GIN trigram index for ILIKE-based property value searches +CREATE INDEX idx_property_value_trgm + ON property USING GIN (value gin_trgm_ops); + +COMMENT ON INDEX idx_property_value_trgm IS 'GIN trigram index for ILIKE pattern matching on property value'; From 6942ddcc55e3f7d57d9d0bd36f3319cc14b71194 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Wed, 20 May 2026 16:06:16 +0200 Subject: [PATCH 37/51] feat: add indexes and update spec builder for performance --- .../db/migration/V3_4__add_search_performance_indexes.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql b/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql index 2006ec4..aae20f1 100644 --- a/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql +++ b/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql @@ -8,7 +8,7 @@ -- - pg_trgm GIN indexes → ILIKE CONTAINS / ENDS_WITH / STARTS_WITH -- Enable pg_trgm for GIN trigram indexes used by ILIKE operators -CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA public; -- ── Relation indexes ──────────────────────────────────────────────────────── From b25cfffbb71d931561137fb33f0aa164532cc297 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Wed, 20 May 2026 17:00:36 +0200 Subject: [PATCH 38/51] fix: rollback flyway extension edit --- .../db/migration/V3_4__add_search_performance_indexes.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql b/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql index aae20f1..2006ec4 100644 --- a/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql +++ b/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql @@ -8,7 +8,7 @@ -- - pg_trgm GIN indexes → ILIKE CONTAINS / ENDS_WITH / STARTS_WITH -- Enable pg_trgm for GIN trigram indexes used by ILIKE operators -CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA public; +CREATE EXTENSION IF NOT EXISTS pg_trgm; -- ── Relation indexes ──────────────────────────────────────────────────────── From 3b8c995643b1a3ce02b96d05b99a3c6cb8df8f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 17:53:46 +0200 Subject: [PATCH 39/51] feat(entity-graph): add a entity graph service and endpoint --- .../idp_core/domain/model/entity/Entity.java | 6 + .../domain/model/entity/EntityGraphNode.java | 0 .../model/entity/EntityGraphRelation.java | 0 .../model/entity_template/EntityTemplate.java | 6 + .../port/EntityGraphRepositoryPort.java | 6 + .../entity_graph/EntityGraphService.java | 78 +++-- .../api/configuration/CorsProperties.java | 6 +- .../api/configuration/SwaggerDescription.java | 1 + .../api/controller/EntityGraphController.java | 20 +- .../dto/out/entity/EntityGraphNodeDtoOut.java | 0 .../out/entity/EntityGraphNodeFlatDtoOut.java | 8 +- .../out/entity/EntityGraphRelationDtoOut.java | 0 .../entity/EntityGraphDtoOutMapper.java | 0 .../entity/EntityGraphFlatDtoOutMapper.java | 53 ++- .../PostgresEntityGraphAdapter.java | 8 +- .../repository/JpaEntityRepository.java | 52 +++ .../entity_graph/EntityGraphServiceTest.java | 312 ++++++++++++------ .../api/controller/EntityControllerTest.java | 8 +- .../controller/EntityGraphControllerTest.java | 155 +++++++++ .../test/R__2_Insert_entities_test_data.sql | 50 ++- 20 files changed, 605 insertions(+), 164 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index ab10abe..2292ecd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -36,4 +36,10 @@ public record Entity( List relations ) { + /// Compact constructor: defensively copies mutable lists to prevent external mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public Entity { + properties = properties == null ? List.of() : List.copyOf(properties); + relations = relations == null ? List.of() : List.copyOf(relations); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 9a0fb0b..2d694f1 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java @@ -43,4 +43,10 @@ public record EntityTemplate( List relationsDefinitions ) { + /// Compact constructor: defensively copies mutable lists to prevent external mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public EntityTemplate { + propertiesDefinitions = propertiesDefinitions == null ? List.of() : List.copyOf(propertiesDefinitions); + relationsDefinitions = relationsDefinitions == null ? List.of() : List.copyOf(relationsDefinitions); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index c66ba2d..82996a2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -31,7 +31,13 @@ public interface EntityGraphRepositoryPort { /// @param depth the maximum traversal depth (1-10) /// @param includeProperties when true, entity properties are loaded along with relations; /// when false, only relations are fetched for a leaner query + /// @param relationNames when non-empty, only edges whose relation name is in this set are + /// traversed; when empty, all relation types are followed /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found + /// Relation name filtering is intentionally NOT pushed into this port. + /// The CTE always traverses all relation types so that nodes reachable via + /// any path are loaded. Edge filtering is applied in the service layer so + /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. Map findEntityGraph( String templateIdentifier, String entityIdentifier, diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 66ee2f9..4d989ee 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -1,7 +1,11 @@ package com.decathlon.idp_core.domain.service.entity_graph; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,7 +36,11 @@ /// **Design decisions:** /// - Uses depth-limited traversal to prevent unbounded recursion /// - Optimized with recursive CTE and batch loading to minimize database queries -/// - Does not detect cycles — relies on depth limit to terminate +/// - A per-request `visitedNodeIds` set prevents exponential recursion: without it, +/// inbound relation scanning would re-expand already-visited nodes at every depth +/// level, producing O(2^depth) calls even for small graphs (OOM at depth ≥ 10). +/// - The service always returns the full unfiltered graph tree. Relation name filtering +/// is a presentation concern applied by the mapper layer. @Service @RequiredArgsConstructor public class EntityGraphService { @@ -44,76 +52,81 @@ public class EntityGraphService { /// Builds the relationship graph for an entity starting from its composite key. /// - /// **Optimization:** Uses a recursive CTE to fetch all entities in the graph in 2 queries - /// (1 for composite key pairs, 1 for batch loading), regardless of depth. - /// /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) - /// @param includeProperties when true, each graph node carries the entity's full property list; - /// when false, properties are omitted to reduce response size - /// @return the root graph node with resolved relations + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @param includeProperties when true, each graph node carries the entity's full property list + /// @return the root graph node with all resolved relations /// @throws EntityNotFoundException when no entity matches the given identifiers @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, boolean includeProperties) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); - // Verify root entity exists before fetching the graph Entity rootEntity = entityRepositoryPort .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - // Optimized batch fetch: load all entities in the graph keyed by composite key. - // Properties are fetched only when explicitly requested to avoid unnecessary I/O. Map entityMap = entityGraphRepositoryPort .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); - // Build the graph from pre-loaded entities (no more database calls) - return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties); + // One shared visited set per request — each node is fully expanded at most once, + // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. + Set visitedNodeIds = new HashSet<>(); + + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, visitedNodeIds); } /// Builds a graph node from a pre-loaded entity map (no database calls). - /// Recursively resolves both outbound and inbound relations from the cached entities. + /// + /// [visitedNodeIds] tracks nodes that have already been fully built in this traversal. + /// When a node is encountered again (cycle or shared reference), a stub leaf is returned + /// immediately to cut the recursion — preventing the exponential blowup that arises from + /// inbound scanning re-expanding the same nodes at every depth level. private EntityGraphNode buildGraphNode(EntityCompositeKey key, Map entityMap, int remainingDepth, - boolean includeProperties) { + boolean includeProperties, + Set visitedNodeIds) { Entity entity = entityMap.get(key); if (entity == null) { return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), List.of(), List.of(), List.of()); } + // Guard: return a stub leaf if this node was already fully built in another branch. + // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). + var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); + if (!visitedNodeIds.add(nodeId)) { + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + List.of(), List.of(), List.of()); + } + if (remainingDepth <= 0) { return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), List.of(), List.of(), List.of()); } - // Resolve outbound relations from pre-loaded entities List outboundRelations = entity.relations().stream() .map(relation -> new EntityGraphRelation( relation.name(), relation.targetEntityIdentifiers().stream() - .map(targetId -> { - // Relations only store identifier; look up by identifier across all entries - EntityCompositeKey targetKey = findKeyByIdentifier(targetId, entityMap); - return buildGraphNode(targetKey, entityMap, remainingDepth - 1, includeProperties); - }) + .map(targetId -> buildGraphNode( + findKeyByIdentifier(targetId, entityMap), + entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) .toList() )) .toList(); - // Resolve inbound relations from pre-loaded entities List inboundRelations = buildRelationsAsTargetFromMap( - entity.identifier(), entityMap, remainingDepth - 1, includeProperties); + entity.identifier(), entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); - // Include properties only when explicitly requested to keep responses lean List properties = includeProperties ? entity.properties() : List.of(); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - properties, outboundRelations, inboundRelations); } + properties, outboundRelations, inboundRelations); + } /// Looks up a composite key from the map by identifier alone. /// Falls back to a synthetic key if no match is found (entity not in graph). @@ -125,19 +138,21 @@ private EntityCompositeKey findKeyByIdentifier(String identifier, Map buildRelationsAsTargetFromMap(String targetIdentifier, Map entityMap, int remainingDepth, - boolean includeProperties) { - Map> sourcesByRelationName = new java.util.HashMap<>(); + boolean includeProperties, + Set visitedNodeIds) { + Map> sourcesByRelationName = new HashMap<>(); for (Map.Entry entry : entityMap.entrySet()) { Entity sourceEntity = entry.getValue(); for (Relation relation : sourceEntity.relations()) { if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { sourcesByRelationName - .computeIfAbsent(relation.name(), k -> new java.util.ArrayList<>()) + .computeIfAbsent(relation.name(), k -> new ArrayList<>()) .add(entry.getKey()); } } @@ -147,7 +162,8 @@ private List buildRelationsAsTargetFromMap(String targetIde .map(e -> new EntityGraphRelation( e.getKey(), e.getValue().stream() - .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, includeProperties)) + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, + includeProperties, visitedNodeIds)) .toList() )) .toList(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index 19124e5..ddd780a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -9,9 +9,9 @@ public record CorsProperties( List allowedOrigins ) { + /// Compact constructor: normalises null to empty and defensively copies to prevent + /// external mutation of the configuration list (EI_EXPOSE_REP2 / EI_EXPOSE_REP). public CorsProperties { - if (allowedOrigins == null) { - allowedOrigins = List.of(); - } + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 23e72bd..19a06f4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -166,4 +166,5 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; + public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index ee8f58a..c71b88e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -6,10 +6,14 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_RELATIONS_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; +import java.util.List; +import java.util.Set; + import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -52,9 +56,10 @@ public class EntityGraphController { /// Cytoscape, and similar frontend graph visualization libraries. /// /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) - /// @param includeData when true, each node includes a data object with entity property values + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @param includeData when true, each node includes a data object with entity property values + /// @param relations when provided, only relations with matching names are included /// @return flat DTO containing nodes and edges arrays @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @@ -74,11 +79,16 @@ public EntityGraphFlatDtoOut getEntityGraph( @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) - @RequestParam(defaultValue = "false") boolean includeData) { + @RequestParam(defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_RELATIONS_DESCRIPTION) + @RequestParam(required = false) List relations) { + + // Convert the nullable list to a Set for O(1) lookup; empty set means no filter + Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); EntityGraphNode graphNode = entityGraphService.getEntityGraph( templateIdentifier, entityIdentifier, depth, includeData); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java index 0ae498b..c1fa208 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -40,4 +40,10 @@ public record EntityGraphNodeFlatDtoOut( @JsonInclude(Include.NON_EMPTY) @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) Map data -) {} +) { + /// Compact constructor: defensively copies the data map to prevent external mutation + /// of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public EntityGraphNodeFlatDtoOut { + data = data == null ? Map.of() : Map.copyOf(data); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index c7ca7a4..3bf9932 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -38,9 +38,14 @@ private EntityGraphFlatDtoOutMapper() { /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. /// - /// @param root the root [EntityGraphNode] returned by the domain service + /// @param root the root [EntityGraphNode] returned by the domain service + /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, + /// and nodes not referenced by any remaining edge are pruned from the + /// result (except the root, which is always included); + /// an empty set means no filter — all edge types and nodes are emitted /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if (root == null) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter) { + if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } @@ -54,9 +59,30 @@ public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if Set emittedEdgeSignatures = new HashSet<>(); var edgeCounter = new AtomicInteger(0); - traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + + // When a relation filter is active, prune nodes that are not connected to any + // remaining edge. The root is always kept. Without this step, nodes reachable via + // non-filtered edges (e.g. C via "depends-on" when filtering "monitors") would + // appear in the node list despite having no visible edges. + List finalNodes; + if (relationFilter.isEmpty()) { + finalNodes = List.copyOf(nodes); + } else { + // Collect all node IDs referenced by the filtered edges only. + // The root receives no special treatment: if it has no matching edges + // it is pruned just like any other disconnected node. + Set referencedNodeIds = new HashSet<>(); + for (var edge : edges) { + referencedNodeIds.add(edge.source()); + referencedNodeIds.add(edge.target()); + } + finalNodes = nodes.stream() + .filter(n -> referencedNodeIds.contains(n.id())) + .toList(); + } - return new EntityGraphFlatDtoOut(List.copyOf(nodes), List.copyOf(edges)); + return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(edges)); } private static void traverse( @@ -65,7 +91,8 @@ private static void traverse( List edges, Set visitedNodeIds, Set emittedEdgeSignatures, - AtomicInteger edgeCounter) { + AtomicInteger edgeCounter, + Set relationFilter) { var nodeId = nodeId(node.templateIdentifier(), node.identifier()); @@ -78,12 +105,16 @@ private static void traverse( nodeId, node.name(), node.templateIdentifier(), node.identifier(), toDataMap(node))); - // Traverse outbound relations: emit edge from currentNode → target + // Traverse outbound relations: emit edge from currentNode → target only when the + // relation type matches the filter (or no filter is active). Nodes are always + // traversed so that deeper nodes remain reachable regardless of edge visibility. for (EntityGraphRelation relation : node.relations()) { for (EntityGraphNode target : relation.targets()) { var targetId = nodeId(target.templateIdentifier(), target.identifier()); - addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); - traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); + } + traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); } } @@ -93,8 +124,10 @@ private static void traverse( for (EntityGraphRelation relation : node.relationsAsTarget()) { for (EntityGraphNode source : relation.targets()) { var sourceId = nodeId(source.templateIdentifier(), source.identifier()); - addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); - traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); + } + traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); } } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index deeb2b6..d48828c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -6,6 +6,7 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; @@ -34,12 +35,17 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { private final EntityPersistenceMapper mapper; @Override + @Transactional(readOnly = true) public Map findEntityGraph( String templateIdentifier, String entityIdentifier, int depth, boolean includeProperties) { - // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE + // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE. + // The CTE always traverses ALL relation types to discover all reachable nodes. + // Relation name filtering is applied at the service level when building edges, + // so nodes reachable via any path are included even if the filter only matches + // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( templateIdentifier, entityIdentifier, depth); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index fb94167..8be0fa7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -116,4 +116,56 @@ List findEntityGraphIdentifiers( @Param("templateIdentifier") String templateIdentifier, @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth); + + /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the given relation names. + /// When the list is empty, all relation names are followed (no filter). + /// The filter is applied inside both the outbound and inbound recursive CTE steps so that only + /// entities reachable through the specified relations are returned, keeping the result set lean. + @Query(value = """ + WITH RECURSIVE + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + AND r.name IN :relationNames + ), + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + AND r.name IN :relationNames + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiersFilteredByRelations( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, + @Param("depth") int depth, + @Param("relationNames") Collection relationNames); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 7dbaf9d..0f6b50f 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -2,8 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.List; @@ -24,6 +27,7 @@ import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; @@ -31,12 +35,6 @@ @DisplayName("EntityGraphService Tests") class EntityGraphServiceTest { - private static final String TEMPLATE = "web-service"; - private static final String DB_TEMPLATE = "database"; - private static final String CACHE_TEMPLATE = "cache"; - private static final String INFRA_TEMPLATE = "infrastructure"; - private static final int DEFAULT_DEPTH = 3; - @Mock private EntityRepositoryPort entityRepositoryPort; @@ -57,16 +55,26 @@ private Entity entityWithRelations(String templateIdentifier, String identifier, return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), relations); } - private Relation relation(String name, String targetTemplateIdentifier, List targetIdentifiers) { - return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, targetIdentifiers); + private Relation relation(String name, String targetTemplateIdentifier, String... targetIds) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); } private EntityCompositeKey key(String templateIdentifier, String identifier) { return new EntityCompositeKey(templateIdentifier, identifier); } + private static final String TEMPLATE = "web-service"; + + // --- Helper to stub both ports --- + + private void stubGraph(Map entityMap) { + when(entityGraphRepositoryPort.findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean())) + .thenReturn(entityMap); + } + + // ======================== @Nested - @DisplayName("getEntityGraph — root entity not found") + @DisplayName("Root Entity Not Found") class RootEntityNotFound { @Test @@ -75,29 +83,28 @@ void shouldThrowWhenRootEntityNotFound() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH, false)) + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false)) .isInstanceOf(EntityNotFoundException.class); - verify(entityRepositoryPort).findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing"); - verifyNoInteractions(entityGraphRepositoryPort); + verify(entityGraphRepositoryPort, never()) + .findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean()); } } + // ======================== @Nested - @DisplayName("getEntityGraph — single root, no relations") + @DisplayName("Single Root — No Relations") class SingleRootNoRelations { @Test - @DisplayName("Should return a leaf node when entity has no relations") + @DisplayName("Should return leaf node when entity has no relations") void shouldReturnLeafNodeWhenNoRelations() { - var root = entity(TEMPLATE, "api", "API Service"); - + Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.identifier()).isEqualTo("api"); assertThat(result.name()).isEqualTo("API Service"); @@ -106,94 +113,88 @@ void shouldReturnLeafNodeWhenNoRelations() { } } + // ======================== @Nested - @DisplayName("getEntityGraph — outbound relations") + @DisplayName("Outbound Relations") class OutboundRelations { @Test - @DisplayName("Should resolve outbound relations to graph nodes") + @DisplayName("Should resolve outbound relation targets at depth 1") void shouldResolveOutboundRelations() { - var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db - )); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.relations()).hasSize(1); - assertThat(result.relations().getFirst().name()).isEqualTo("uses"); - assertThat(result.relations().getFirst().targets()).hasSize(1); - assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("postgres"); + assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); + assertThat(result.relations().get(0).targets()).hasSize(1); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); } @Test - @DisplayName("Should create a fallback node when relation target is not in the graph map") + @DisplayName("Should return fallback node when target is not in the pre-loaded entity map") void shouldReturnFallbackNodeWhenTargetNotInMap() { - // Simulates a target entity outside the loaded depth — still produces a placeholder node - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("unknown-db")))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "missing-db"))); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.relations()).hasSize(1); - // Fallback node uses identifier as both id and name when entity is not in map - assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("unknown-db"); + EntityGraphNode fallback = result.relations().get(0).targets().get(0); + assertThat(fallback.identifier()).isEqualTo("missing-db"); } } + // ======================== @Nested - @DisplayName("getEntityGraph — inbound relations") + @DisplayName("Inbound Relations (relationsAsTarget)") class InboundRelations { @Test - @DisplayName("Should resolve inbound relations for entities that are targeted by others") + @DisplayName("Should resolve inbound relations when another entity points to root") void shouldResolveInboundRelations() { - var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(DB_TEMPLATE, "postgres")) - .thenReturn(Optional.of(db)); - when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db - )); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key(TEMPLATE, "consumer"), consumer)); - EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - // postgres is targeted by api via "uses" assertThat(result.relationsAsTarget()).hasSize(1); - assertThat(result.relationsAsTarget().getFirst().name()).isEqualTo("uses"); - assertThat(result.relationsAsTarget().getFirst().targets().getFirst().identifier()).isEqualTo("api"); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()).isEqualTo("consumer"); } } + // ======================== @Nested - @DisplayName("getEntityGraph — depth clamping") + @DisplayName("Depth Clamping") class DepthClamping { @Test @DisplayName("Should clamp depth below 1 to 1") void shouldClampDepthBelowOne() { - var root = entity(TEMPLATE, "api", "API Service"); - + Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); @@ -201,14 +202,12 @@ void shouldClampDepthBelowOne() { } @Test - @DisplayName("Should clamp depth above 10 to 10") + @DisplayName("Should clamp depth above MAX_DEPTH to MAX_DEPTH") void shouldClampDepthAboveTen() { - var root = entity(TEMPLATE, "api", "API Service"); - + Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); @@ -216,67 +215,164 @@ void shouldClampDepthAboveTen() { } } + // ======================== @Nested - @DisplayName("getEntityGraph — depth limit stops recursion") + @DisplayName("Depth Limit — Leaf Nodes at Boundary") class DepthLimit { @Test - @DisplayName("Should return a leaf node for targets at the depth boundary") + @DisplayName("Should return target as leaf node when depth limit is reached") void shouldReturnLeafNodeAtDepthBoundary() { - // api --uses--> postgres --runs-on--> server-1 - // At depth=1: postgres node is resolved but its own relations are NOT expanded - var server = entity(INFRA_TEMPLATE, "server-1", "Server 1"); - var db = entityWithRelations(DB_TEMPLATE, "postgres", "Postgres DB", - List.of(relation("runs-on", INFRA_TEMPLATE, List.of("server-1")))); - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entityWithRelations("database", "postgres", "Postgres DB", + List.of(relation("runs-on", "infra", "server-1"))); + Entity server = entity("infra", "server-1", "Server 1"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db, - key(INFRA_TEMPLATE, "server-1"), server - )); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres, + key("infra", "server-1"), server)); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - // postgres node is included but its child relations are empty (remaining depth = 0) - var dbNode = result.relations().getFirst().targets().getFirst(); - assertThat(dbNode.identifier()).isEqualTo("postgres"); - assertThat(dbNode.relations()).isEmpty(); + EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); + assertThat(postgresNode.identifier()).isEqualTo("postgres"); + // At depth=1, postgres is a leaf — no further relations resolved + assertThat(postgresNode.relations()).isEmpty(); + assertThat(postgresNode.relationsAsTarget()).isEmpty(); } } + // ======================== @Nested - @DisplayName("getEntityGraph — multiple outbound relations") + @DisplayName("Multiple Named Relations") class MultipleRelations { @Test - @DisplayName("Should resolve multiple named relation types correctly") + @DisplayName("Should resolve multiple distinct relation types") void shouldResolveMultipleNamedRelations() { - var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); - var cache = entity(CACHE_TEMPLATE, "redis", "Redis Cache"); - var api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( - relation("uses-db", DB_TEMPLATE, List.of("postgres")), - relation("uses-cache", CACHE_TEMPLATE, List.of("redis")) - )); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( + relation("uses-db", "database", "postgres"), + relation("depends-on", TEMPLATE, "auth"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity auth = entity(TEMPLATE, "auth", "Auth Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db, - key(CACHE_TEMPLATE, "redis"), cache - )); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres, + key(TEMPLATE, "auth"), auth)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.relations()).hasSize(2); - var relationNames = result.relations().stream().map(r -> r.name()).toList(); - assertThat(relationNames).containsExactlyInAnyOrder("uses-db", "uses-cache"); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("uses-db", "depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Full Graph Returned — Filtering Is a Mapper Concern") + class FullGraphReturned { + + @Test + @DisplayName("Should return all edges regardless of relation type (no filtering in service)") + void shouldReturnAllEdgesWithoutFiltering() { + // A --(depends-on)--> B --(owns)--> C + // The service must return both edges — the mapper will filter them. + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("depends-on", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", + List.of(relation("owns", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of( + key(TEMPLATE, "a"), a, + key(TEMPLATE, "b"), b, + key(TEMPLATE, "c"), c)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false); + + // Root A has one outbound "depends-on" edge → B + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); + + // B (at depth 1) has one outbound "owns" edge → C + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + assertThat(nodeB.relations()).hasSize(1); + assertThat(nodeB.relations().get(0).name()).isEqualTo("owns"); + assertThat(nodeB.relations().get(0).targets().get(0).identifier()).isEqualTo("c"); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "a", 2, false); + } + } + + // ======================== + @Nested + @DisplayName("Visited Node Guard — OOM Prevention") + class VisitedNodeGuard { + + @Test + @DisplayName("Should complete at depth=10 without exponential recursion for a small graph") + void shouldNotExplodeAtMaxDepthWithSmallGraph() { + // A --(uses)--> B --(uses)--> C; B also has inbound from A and C has inbound from B. + // Without the visited-node guard this produces O(2^depth) calls at depth=10. + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", + List.of(relation("uses", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of( + key(TEMPLATE, "a"), a, + key(TEMPLATE, "b"), b, + key(TEMPLATE, "c"), c)); + + // Must complete instantly — any OOM or StackOverflow here means the guard is missing. + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false); + + assertThat(result.identifier()).isEqualTo("a"); + assertThat(result.relations()).hasSize(1); + } + + @Test + @DisplayName("Should return stub leaf for already-visited node instead of re-expanding it") + void shouldReturnStubLeafForRevisitedNode() { + // A --(uses)--> B; B also points back to A (cycle: A→B→A) + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", + List.of(relation("uses", TEMPLATE, "a"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of( + key(TEMPLATE, "a"), a, + key(TEMPLATE, "b"), b)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false); + + // A → B is resolved + assertThat(result.relations()).hasSize(1); + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + + // B → A is a revisit: A was already marked visited, so it returns a stub leaf + // with no further outbound or inbound relations (no infinite loop). + EntityGraphNode stubA = nodeB.relations().get(0).targets().get(0); + assertThat(stubA.identifier()).isEqualTo("a"); + assertThat(stubA.relations()).isEmpty(); + assertThat(stubA.relationsAsTarget()).isEmpty(); } } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index ccee22d..53f5b15 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -48,8 +48,8 @@ void getEntities_paginated_200() throws Exception { .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(15)) .andExpect(jsonPath("$.page.number").value(0)) @@ -102,8 +102,8 @@ void getEntities_invalid_pagination_200() throws Exception { .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(20)) .andExpect(jsonPath("$.page.number").value(0)) diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java new file mode 100644 index 0000000..d2ef939 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -0,0 +1,155 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.decathlon.idp_core.AbstractIntegrationTest; + +/// Integration tests for the EntityGraphController REST API endpoint. +/// +/// Tests are based on the three-node chain seeded in R__2_Insert_entities_test_data.sql: +/// +/// graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +/// graph-svc-a --[monitors]--> graph-svc-b +/// +/// Key scenarios verified: +/// +/// - No filter: all nodes and edges are returned +/// - Filter "uses": full chain traversed (a→b→c), "monitors" edge excluded at every depth +/// - Filter "monitors": only a→b returned; c is unreachable via "monitors" edges +/// - 404 for unknown entity +/// - 401 without authentication +@DisplayName("GET /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph") +public class EntityGraphControllerTest extends AbstractIntegrationTest { + + private static final String GRAPH_PATH = "/api/v1/entities/{templateId}/{entityId}/graph"; + private static final String TEMPLATE = "web-service"; + private static final String ENTITY_A = "graph-svc-a"; + private static final String ENTITY_B = "graph-svc-b"; + private static final String ENTITY_C = "graph-svc-c"; + + @Autowired + private MockMvc mockMvc; + + @Nested + @DisplayName("Without relation filter") + class NoFilter { + + @Test + @WithMockUser + @DisplayName("Should return all nodes and edges when no filter is applied (depth=3)") + void shouldReturnAllNodesAndEdgesWithNoFilter() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes must be present + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Three edges: a-[uses]->b, a-[monitors]->b, b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(3))); + } + } + + @Nested + @DisplayName("With 'uses' relation filter") + class UsesFilter { + + @Test + @WithMockUser + @DisplayName("Should traverse full chain via 'uses' edges and exclude 'monitors' edge (depth=3)") + void shouldTraverseFullChainWithUsesFilter() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("relations", "uses") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are reachable via "uses" chain: a→b→c + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Only the two "uses" edges: a-[uses]->b and b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(2))) + .andExpect(jsonPath("$.edges[*].type", + containsInAnyOrder("uses", "uses"))); + } + + @Test + @WithMockUser + @DisplayName("Should still reach graph-svc-c at depth 2 when filtering by 'uses'") + void shouldReachNodeCAtDepthTwoWithUsesFilter() throws Exception { + // This specifically verifies that the filter applies recursively: + // at depth=2, a→b (level 1) and b→c (level 2) must both be traversed. + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "2") + .param("relations", "uses") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + .andExpect(jsonPath("$.edges", hasSize(2))); + } + } + + @Nested + @DisplayName("With 'monitors' relation filter") + class MonitorsFilter { + + @Test + @WithMockUser + @DisplayName("Should return only graph-svc-a and graph-svc-b when filtering by 'monitors' (depth=3)") + void shouldReturnOnlyRootAndDirectTargetWithMonitorsFilter() throws Exception { + // "monitors" only exists at level 1 (a→b). Since b has no "monitors" edges, + // graph-svc-c must NOT appear in the result. + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("relations", "monitors") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // Only a and b — c is unreachable via "monitors" + .andExpect(jsonPath("$.nodes", hasSize(2))) + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B))) + // One edge only: a-[monitors]->b + .andExpect(jsonPath("$.edges", hasSize(1))) + .andExpect(jsonPath("$.edges[0].type").value("monitors")); + } + } + + @Nested + @DisplayName("Error cases") + class ErrorCases { + + @Test + @WithMockUser + @DisplayName("Should return 404 when entity does not exist") + void shouldReturn404ForUnknownEntity() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, "non-existent-entity") + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("Should return 401 without authentication") + void shouldReturn401WithoutAuthentication() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 45e62ff..d80b4d5 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -1,5 +1,5 @@ -- Insert sample entities into idp_core.entity -INSERT INTO idp_core.entity (id, identifier, name, template_identifier) +INSERT INTO entity (id, identifier, name, template_identifier) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), @@ -16,3 +16,51 @@ VALUES ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); + +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +-- Entities (all use the 'web-service' template which exists in test data) +-- UUIDs use only valid hex characters (0-9, a-f) +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); + +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c + +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation From 545078b76aefe053a5b242805192c56ab208e271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 18:31:38 +0200 Subject: [PATCH 40/51] feat(entity-graph): add a entity graph service and endpoint --- docs/src/static/swagger.yaml | 682 ++++++++++++++++++----------------- 1 file changed, 361 insertions(+), 321 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index accb23c..590d771 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -6,140 +6,144 @@ info: servers: - url: http://localhost:8084 security: - - clientId: [] - - bearer: [] +- clientId: [] +- bearer: [] tags: - - name: Entities Management - description: Operations related to entity management - - name: Entities Templates Management - description: Operations related to entity template management +- name: Entity Graph + description: Entity relationship graph operations +- name: Entities Management + description: Operations related to entity management +- name: Entities Templates Management + description: Operations related to entity template management paths: - /api/v1/entity-templates/{identifier}: + "/api/v1/entity-templates/{identifier}": get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get template by identifier description: Retrieve a specific template using its string identifier operationId: getTemplateByIdentifier parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '200': description: Template found content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" put: tags: - - Entities Templates Management + - Entities Templates Management summary: Update an existing template by template identifier - description: Update the details of an existing template identified by its unique string identifier + description: Update the details of an existing template identified by its unique + string identifier operationId: updateTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' + "$ref": "#/components/schemas/EntityTemplateUpdateDtoIn" required: true responses: '200': description: Template update successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" delete: tags: - - Entities Templates Management + - Entities Templates Management summary: Delete template by identifier description: Remove a template from the system using its unique identifier operationId: deleteTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '204': description: Template deleted successfully '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entity-templates: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entity-templates": get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get paginated templates description: Retrieve a paginated list of templates with optional sorting operationId: getTemplatesPaginated parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - content: - '*/*': - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - content: - '*/*': - schema: - type: integer - default: '20' - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' - content: - '*/*': - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + content: + "*/*": + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + content: + "*/*": + schema: + type: integer + default: '20' + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults + to identifier,asc.' + content: + "*/*": + schema: + type: string + default: identifier,asc responses: '200': description: Paginated templates retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/TemplatePageResponse' + "$ref": "#/components/schemas/TemplatePageResponse" '400': description: Invalid pagination parameters content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" post: tags: - - Entities Templates Management + - Entities Templates Management summary: Create a new template description: Create a new template in the system with the provided information operationId: createTemplate @@ -147,193 +151,223 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateCreateDtoIn' + "$ref": "#/components/schemas/EntityTemplateCreateDtoIn" required: true responses: '201': description: Template created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '400': description: Invalid template data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entities/{templateIdentifier}: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}": get: tags: - - Entities Management + - Entities Management summary: Get entities by template identifier description: Retrieve a paginated list of entities with optional sorting operationId: getEntities parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - required: false - content: - '*/*': - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - required: false - content: - '*/*': - schema: - type: integer - default: '20' - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' - content: - '*/*': - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + "*/*": + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + "*/*": + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults + to identifier,asc.' + content: + "*/*": + schema: + type: string + default: identifier,asc responses: '200': description: Paginated entities retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityPageResponse' + "$ref": "#/components/schemas/EntityPageResponse" '400': description: Invalid pagination parameters content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" post: tags: - - Entities Management + - Entities Management summary: Create a new entity description: Create a new entity in the system with the provided information operationId: createEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - minLength: 1 + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 requestBody: content: application/json: schema: - $ref: '#/components/schemas/EntityDtoIn' + "$ref": "#/components/schemas/EntityDtoIn" required: true responses: '201': description: Entity created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" '400': description: Invalid entity data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" '401': description: Unauthorized - Missing or invalid token '403': description: Insufficient rights - '409': - description: Entity already exists in this template - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: Entity already exists in this template + content: + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" '500': description: Unexpected server-side failure content: - '*/*': + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph": + get: + tags: + - Entity Graph + summary: Get entity relationship graph as flat nodes and edges + description: Retrieves the entity relationship graph as a flat nodes-and-edges + structure, suitable for frontend visualization tools such as React Flow, Vis.js, + and Cytoscape. + operationId: getEntityGraph + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: depth + in: query + description: Maximum traversal depth for relationship resolution. Clamped + between 1 and 10. + required: false + schema: + type: integer + format: int32 + default: 1 + - name: includeData + in: query + description: When true, each graph node includes a data object containing + the entity's property values. Defaults to false. + required: false + schema: + type: boolean + default: false + - name: relations + in: query + description: When provided, only relations whose name matches one of the listed + values are traversed and included. Omit to include all relations. + required: false + schema: + type: array + items: + type: string + responses: + '200': + description: Flat entity graph successfully retrieved + content: + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + "$ref": "#/components/schemas/EntityGraphFlatDtoOut" + '404': + description: Entity not found with the provided identifier + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}": get: tags: - - Entities Management + - Entities Management summary: Get entity by entity template and identifier - description: Retrieve a specific entity using its string identifier and its template identifier + description: Retrieve a specific entity using its string identifier and its + template identifier operationId: getEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: entityIdentifier - in: path - required: true - schema: - type: string + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string responses: '200': description: Entity found content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" '404': description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" components: schemas: - EntityTemplateCreateDtoIn: - type: object - description: Input DTO for creating an entity template - properties: - identifier: - type: string - description: Unique Entity Template identifier - example: service - minLength: 1 - name: - type: string - description: Entity Template name - example: Service - maxLength: 255 - minLength: 1 - pattern: "^[a-zA-Z0-9 _-]+$" - description: - type: string - description: Entity Template description - example: A comprehensive service template - properties_definitions: - type: array - description: List of property definitions for this template - items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' - relations_definitions: - type: array - description: List of relation definitions for this template - items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' - required: - - identifier - - name EntityTemplateUpdateDtoIn: type: object description: Input DTO for updating an entity template @@ -353,14 +387,14 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' + "$ref": "#/components/schemas/PropertyDefinitionDtoIn" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' + "$ref": "#/components/schemas/RelationDefinitionDtoIn" required: - - name + - name PropertyDefinitionDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -379,9 +413,9 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean @@ -389,12 +423,12 @@ components: description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoIn' + "$ref": "#/components/schemas/PropertyRulesDtoIn" description: Property validation rules required: - - description - - name - - type + - description + - name + - type PropertyRulesDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -403,21 +437,21 @@ components: type: string description: Property format validation enum: - - URL - - EMAIL + - URL + - EMAIL example: EMAIL enum_values: type: array description: Enumeration values for enum properties example: - - ACTIVE - - INACTIVE + - ACTIVE + - INACTIVE items: type: string regex: type: string description: Regular expression pattern for validation - example: ^[a-zA-Z0-9]+$ + example: "^[a-zA-Z0-9]+$" max_length: type: integer format: int32 @@ -463,8 +497,8 @@ components: description: Whether this relation can have multiple targets example: true required: - - name - - target_template_identifier + - name + - target_template_identifier EntityTemplateDtoOut: type: object description: Output for entity template @@ -485,12 +519,12 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoOut' + "$ref": "#/components/schemas/PropertyDefinitionDtoOut" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoOut' + "$ref": "#/components/schemas/RelationDefinitionDtoOut" PropertyDefinitionDtoOut: type: object description: Output DTO for property definition @@ -507,46 +541,41 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoOut' + "$ref": "#/components/schemas/PropertyRulesDtoOut" description: Property validation rules example: Property validation rules PropertyRulesDtoOut: type: object description: Output DTO for property validation rules properties: - id: - type: string - format: uuid - description: Unique identifier of the property rules - example: 123e4567-e89b-12d3-a456-426614174000 format: type: string description: Format of the property enum: - - URL - - EMAIL + - URL + - EMAIL example: STRING enum_values: type: array description: Allowed enum values for the property example: - - VALUE1 - - VALUE2 + - VALUE1 + - VALUE2 items: type: string regex: type: string description: Regular expression for property validation - example: ^[A-Za-z0-9]+$ + example: "^[A-Za-z0-9]+$" max_length: type: integer format: int32 @@ -592,78 +621,41 @@ components: properties: error: type: string - enum: - - 100 CONTINUE - - 101 SWITCHING_PROTOCOLS - - 102 PROCESSING - - 103 EARLY_HINTS - - 103 CHECKPOINT - - 200 OK - - 201 CREATED - - 202 ACCEPTED - - 203 NON_AUTHORITATIVE_INFORMATION - - 204 NO_CONTENT - - 205 RESET_CONTENT - - 206 PARTIAL_CONTENT - - 207 MULTI_STATUS - - 208 ALREADY_REPORTED - - 226 IM_USED - - 300 MULTIPLE_CHOICES - - 301 MOVED_PERMANENTLY - - 302 FOUND - - 302 MOVED_TEMPORARILY - - 303 SEE_OTHER - - 304 NOT_MODIFIED - - 305 USE_PROXY - - 307 TEMPORARY_REDIRECT - - 308 PERMANENT_REDIRECT - - 400 BAD_REQUEST - - 401 UNAUTHORIZED - - 402 PAYMENT_REQUIRED - - 403 FORBIDDEN - - 404 NOT_FOUND - - 405 METHOD_NOT_ALLOWED - - 406 NOT_ACCEPTABLE - - 407 PROXY_AUTHENTICATION_REQUIRED - - 408 REQUEST_TIMEOUT - - 409 CONFLICT - - 410 GONE - - 411 LENGTH_REQUIRED - - 412 PRECONDITION_FAILED - - 413 PAYLOAD_TOO_LARGE - - 413 REQUEST_ENTITY_TOO_LARGE - - 414 URI_TOO_LONG - - 414 REQUEST_URI_TOO_LONG - - 415 UNSUPPORTED_MEDIA_TYPE - - 416 REQUESTED_RANGE_NOT_SATISFIABLE - - 417 EXPECTATION_FAILED - - 418 I_AM_A_TEAPOT - - 419 INSUFFICIENT_SPACE_ON_RESOURCE - - 420 METHOD_FAILURE - - 421 DESTINATION_LOCKED - - 422 UNPROCESSABLE_ENTITY - - 423 LOCKED - - 424 FAILED_DEPENDENCY - - 425 TOO_EARLY - - 426 UPGRADE_REQUIRED - - 428 PRECONDITION_REQUIRED - - 429 TOO_MANY_REQUESTS - - 431 REQUEST_HEADER_FIELDS_TOO_LARGE - - 451 UNAVAILABLE_FOR_LEGAL_REASONS - - 500 INTERNAL_SERVER_ERROR - - 501 NOT_IMPLEMENTED - - 502 BAD_GATEWAY - - 503 SERVICE_UNAVAILABLE - - 504 GATEWAY_TIMEOUT - - 505 HTTP_VERSION_NOT_SUPPORTED - - 506 VARIANT_ALSO_NEGOTIATES - - 507 INSUFFICIENT_STORAGE - - 508 LOOP_DETECTED - - 509 BANDWIDTH_LIMIT_EXCEEDED - - 510 NOT_EXTENDED - - 511 NETWORK_AUTHENTICATION_REQUIRED errorDescription: type: string + EntityTemplateCreateDtoIn: + type: object + description: Input DTO for creating an entity template + properties: + identifier: + type: string + description: Unique Entity Template identifier + example: service + minLength: 1 + name: + type: string + description: Unique Entity Template name + example: Service + maxLength: 255 + minLength: 0 + pattern: "^[a-zA-Z0-9 _-]+$" + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + "$ref": "#/components/schemas/PropertyDefinitionDtoIn" + relations_definitions: + type: array + description: List of relation definitions for this template + items: + "$ref": "#/components/schemas/RelationDefinitionDtoIn" + required: + - identifier + - name EntityDtoIn: type: object description: Input DTO for creating or updating an entity @@ -689,10 +681,10 @@ components: type: array description: List of relations for this entity items: - $ref: '#/components/schemas/RelationDtoIn' + "$ref": "#/components/schemas/RelationDtoIn" required: - - identifier - - name + - identifier + - name RelationDtoIn: type: object description: Input DTO for an entity relation instance @@ -706,13 +698,13 @@ components: type: array description: List of target entity identifiers for this relation example: - - web-api-1 - - web-api-2 + - web-api-1 + - web-api-2 items: type: string required: - - name - - target_entity_identifiers + - name + - target_entity_identifiers EntityDtoOut: type: object properties: @@ -730,13 +722,13 @@ components: additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + "$ref": "#/components/schemas/EntitySummaryDto" relations_as_target: type: object additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + "$ref": "#/components/schemas/EntitySummaryDto" EntitySummaryDto: type: object properties: @@ -747,13 +739,6 @@ components: PageableObject: type: object properties: - offset: - type: integer - format: int64 - sort: - $ref: '#/components/schemas/SortObject' - unpaged: - type: boolean paged: type: boolean pageNumber: @@ -762,15 +747,22 @@ components: pageSize: type: integer format: int32 + sort: + "$ref": "#/components/schemas/SortObject" + unpaged: + type: boolean + offset: + type: integer + format: int64 SortObject: type: object properties: - empty: - type: boolean sorted: type: boolean unsorted: type: boolean + empty: + type: boolean TemplatePageResponse: type: object description: Paginated response containing Template objects @@ -778,9 +770,9 @@ components: content: type: array items: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + "$ref": "#/components/schemas/PageableObject" totalElements: type: integer format: int64 @@ -789,17 +781,17 @@ components: format: int32 last: type: boolean - size: - type: integer - format: int32 - number: + sort: + "$ref": "#/components/schemas/SortObject" + numberOfElements: type: integer format: int32 - sort: - $ref: '#/components/schemas/SortObject' first: type: boolean - numberOfElements: + size: + type: integer + format: int32 + number: type: integer format: int32 empty: @@ -811,9 +803,9 @@ components: content: type: array items: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + "$ref": "#/components/schemas/PageableObject" totalElements: type: integer format: int64 @@ -822,21 +814,69 @@ components: format: int32 last: type: boolean - size: - type: integer - format: int32 - number: + sort: + "$ref": "#/components/schemas/SortObject" + numberOfElements: type: integer format: int32 - sort: - $ref: '#/components/schemas/SortObject' first: type: boolean - numberOfElements: + size: + type: integer + format: int32 + number: type: integer format: int32 empty: type: boolean + EntityGraphEdgeDtoOut: + type: object + properties: + id: + type: string + description: Unique edge identifier + source: + type: string + description: Node id of the source entity + target: + type: string + description: Node id of the target entity + type: + type: string + description: Relation name as defined in the entity template + EntityGraphFlatDtoOut: + type: object + properties: + nodes: + type: array + description: All entity nodes in the graph + items: + "$ref": "#/components/schemas/EntityGraphNodeFlatDtoOut" + edges: + type: array + description: All directed relation edges in the graph + items: + "$ref": "#/components/schemas/EntityGraphEdgeDtoOut" + EntityGraphNodeFlatDtoOut: + type: object + properties: + id: + type: string + description: Unique node identifier composed of templateIdentifier:identifier + label: + type: string + description: Human-readable entity name + template_identifier: + type: string + description: Template identifier this entity belongs to + identifier: + type: string + description: Business identifier of the entity within its template + data: + type: object + additionalProperties: {} + description: Entity property values keyed by property name; present only + when include_data=true is requested securitySchemes: clientId: type: oauth2 From 90ab0927d4cc6d08fc548d2af51330898ddc8891 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Wed, 20 May 2026 18:37:01 +0200 Subject: [PATCH 41/51] feat: remove pg_trgm extension --- .../EntitySearchSpecification.java | 53 +++++++++---------- .../V3_4__add_search_performance_indexes.sql | 50 ++++------------- 2 files changed, 35 insertions(+), 68 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java index 8175c1d..eb13dd5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -40,9 +40,10 @@ /// significantly cheaper. /// /// **Security:** LIKE-based operators ([SearchOperator#CONTAINS], [SearchOperator#NOT_CONTAINS], -/// [SearchOperator#STARTS_WITH], [SearchOperator#ENDS_WITH]) use PostgreSQL `ILIKE` for -/// case-insensitive matching. SQL wildcards (`%` and `_`) in user-supplied values are escaped -/// to prevent unintended pattern matching. EQ and NEQ use `LOWER()` with functional btree indexes. +/// [SearchOperator#STARTS_WITH], [SearchOperator#ENDS_WITH]) use `LOWER() LIKE lower_value` +/// for case-insensitive matching. SQL wildcards (`%` and `_`) in user-supplied values are +/// escaped to prevent unintended pattern matching. EQ and NEQ also use `LOWER()` with +/// functional btree indexes. STARTS_WITH benefits from btree index prefix scans. @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class EntitySearchSpecification { @@ -70,23 +71,22 @@ public static Specification of(SearchFilterNode filter) { /// /// The four conditions are combined with OR so that a match on any field is sufficient. /// The "any property" branch uses a correlated EXISTS subquery to avoid row multiplication. - /// All comparisons use `ILIKE` so that `pg_trgm` GIN indexes can be leveraged. + /// All comparisons use `LOWER() LIKE lower_pattern` for case-insensitive substring matching. /// /// @param query the search string; must be non-null and non-blank /// @return a [Specification] implementing the global text search public static Specification globalTextSearch(String query) { - // No toLowerCase() needed — ILIKE is inherently case-insensitive. - String escaped = escapeLikeWildcards(query); + String escaped = escapeLikeWildcards(query.toLowerCase()); String pattern = "%" + escaped + "%"; Specification byIdentifier = - (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(IDENTIFIER), pattern, LIKE_ESCAPE_CHAR); + (root, q, cb) -> cb.like(cb.lower(root.get(IDENTIFIER)), pattern, LIKE_ESCAPE_CHAR); Specification byName = - (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(NAME), pattern, LIKE_ESCAPE_CHAR); + (root, q, cb) -> cb.like(cb.lower(root.get(NAME)), pattern, LIKE_ESCAPE_CHAR); Specification byTemplate = - (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(TEMPLATE_IDENTIFIER), pattern, LIKE_ESCAPE_CHAR); + (root, q, cb) -> cb.like(cb.lower(root.get(TEMPLATE_IDENTIFIER)), pattern, LIKE_ESCAPE_CHAR); Specification byAnyProperty = (root, queryCtx, cb) -> { // Correlated EXISTS: does this entity have at least one property whose value matches? @@ -96,7 +96,7 @@ public static Specification globalTextSearch(String query) { sub.select(cb.literal(1)) .where( cb.equal(subRoot.get("id"), root.get("id")), - ((HibernateCriteriaBuilder) cb).ilike(propJoin.get("value").as(String.class), pattern, LIKE_ESCAPE_CHAR) + cb.like(cb.lower(propJoin.get("value").as(String.class)), pattern, LIKE_ESCAPE_CHAR) ); return cb.exists(sub); }; @@ -285,29 +285,29 @@ private static Predicate buildPredicate( if (isNumericOperator(operator)) { return buildNumericPredicate(cb, field, operator, new BigDecimal(value)); } - HibernateCriteriaBuilder hcb = (HibernateCriteriaBuilder) cb; - Expression stringField = field.as(String.class); + Expression lowerField = cb.lower(field.as(String.class)); return switch (operator) { - // EQ / NEQ use lower() + functional btree index (V3_4) for optimal equality matching. - case EQ -> cb.equal(cb.lower(stringField), value.toLowerCase()); - case NEQ -> cb.notEqual(cb.lower(stringField), value.toLowerCase()); - // LIKE operators use ILIKE so that pg_trgm GIN indexes (V3_5) can be leveraged. - // No pre-lowercasing of the value — ILIKE is inherently case-insensitive. + // EQ / NEQ use lower() + functional btree index for optimal equality matching. + case EQ -> cb.equal(lowerField, value.toLowerCase()); + case NEQ -> cb.notEqual(lowerField, value.toLowerCase()); + // LIKE operators apply LOWER() on the column and lowercase the value. + // STARTS_WITH can leverage the functional btree lower() index (prefix scan). + // CONTAINS and ENDS_WITH use sequential scans as no extension-free index supports leading wildcards. case CONTAINS -> { - String escaped = escapeLikeWildcards(value); - yield hcb.ilike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.like(lowerField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); } case NOT_CONTAINS -> { - String escaped = escapeLikeWildcards(value); - yield hcb.notIlike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.notLike(lowerField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); } case STARTS_WITH -> { - String escaped = escapeLikeWildcards(value); - yield hcb.ilike(stringField, escaped + "%", LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.like(lowerField, escaped + "%", LIKE_ESCAPE_CHAR); } case ENDS_WITH -> { - String escaped = escapeLikeWildcards(value); - yield hcb.ilike(stringField, "%" + escaped, LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.like(lowerField, "%" + escaped, LIKE_ESCAPE_CHAR); } default -> throw new IllegalStateException("Unhandled operator: " + operator); }; @@ -342,8 +342,7 @@ private static Predicate buildNumericPredicate( /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are /// treated as literal characters rather than pattern metacharacters. - /// Used by both `ILIKE`-based operators and `LOWER() LIKE`-based comparisons. - /// The value does **not** need to be pre-lowercased for `ILIKE` operators. + /// The value should be pre-lowercased before passing to LIKE-based operators. static String escapeLikeWildcards(String value) { return value .replace(String.valueOf(LIKE_ESCAPE_CHAR), LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) diff --git a/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql b/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql index 2006ec4..4bc4e4f 100644 --- a/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql +++ b/src/main/resources/db/migration/V3_4__add_search_performance_indexes.sql @@ -1,16 +1,12 @@ -- Flyway migration script: add search performance indexes --- Purpose: Support the EXISTS-based search specifications and ILIKE-based operators +-- Purpose: Support the EXISTS-based search specifications and LOWER()-based operators -- with index scans instead of full table scans. -- -- Strategy: --- - Btree functional lower(col) indexes → EQ / NEQ comparisons using LOWER() --- - Btree indexes on relation columns → exact equality lookups in EXISTS subqueries --- - pg_trgm GIN indexes → ILIKE CONTAINS / ENDS_WITH / STARTS_WITH +-- - Btree functional lower(col) indexes -> EQ / NEQ / STARTS_WITH comparisons using LOWER() +-- - Btree indexes on relation columns -> exact equality lookups in EXISTS subqueries --- Enable pg_trgm for GIN trigram indexes used by ILIKE operators -CREATE EXTENSION IF NOT EXISTS pg_trgm; - --- ── Relation indexes ──────────────────────────────────────────────────────── +-- Relation indexes -- Exact equality on relation name (used in all relation EXISTS subqueries) CREATE INDEX idx_relation_name @@ -18,21 +14,15 @@ CREATE INDEX idx_relation_name COMMENT ON INDEX idx_relation_name IS 'Supports exact relation name equality in EXISTS subqueries'; --- GIN trigram index for ILIKE-based relation name searches (CONTAINS, ENDS_WITH, STARTS_WITH) -CREATE INDEX idx_relation_name_trgm - ON relation USING GIN (name gin_trgm_ops); - -COMMENT ON INDEX idx_relation_name_trgm IS 'GIN trigram index for ILIKE pattern matching on relation name'; - -- Reverse-relation lookup: target entity identifier in relationsAsTargetSpec CREATE INDEX idx_relation_target_entities_identifier ON relation_target_entities (target_entity_identifier); COMMENT ON INDEX idx_relation_target_entities_identifier IS 'Supports reverse relation lookups by target entity identifier'; --- ── Entity indexes ────────────────────────────────────────────────────────── +-- Entity indexes --- Functional btree indexes for EQ / NEQ which use LOWER(col) = lower_value +-- Functional btree indexes for EQ / NEQ / STARTS_WITH which use LOWER(col) CREATE INDEX idx_entity_name_lower ON entity (lower(name)); @@ -42,28 +32,6 @@ CREATE INDEX idx_entity_identifier_lower CREATE INDEX idx_entity_template_identifier_lower ON entity (lower(template_identifier)); -COMMENT ON INDEX idx_entity_name_lower IS 'Supports LOWER(name) = value equality comparisons (EQ operator)'; -COMMENT ON INDEX idx_entity_identifier_lower IS 'Supports LOWER(identifier) = value equality comparisons (EQ operator)'; -COMMENT ON INDEX idx_entity_template_identifier_lower IS 'Supports LOWER(template_identifier) = value equality comparisons (EQ operator)'; - --- GIN trigram indexes for ILIKE-based searches (CONTAINS, ENDS_WITH, STARTS_WITH) -CREATE INDEX idx_entity_name_trgm - ON entity USING GIN (name gin_trgm_ops); - -CREATE INDEX idx_entity_identifier_trgm - ON entity USING GIN (identifier gin_trgm_ops); - -CREATE INDEX idx_entity_template_identifier_trgm - ON entity USING GIN (template_identifier gin_trgm_ops); - -COMMENT ON INDEX idx_entity_name_trgm IS 'GIN trigram index for ILIKE pattern matching on entity name'; -COMMENT ON INDEX idx_entity_identifier_trgm IS 'GIN trigram index for ILIKE pattern matching on entity identifier'; -COMMENT ON INDEX idx_entity_template_identifier_trgm IS 'GIN trigram index for ILIKE pattern matching on entity template identifier'; - --- ── Property indexes ──────────────────────────────────────────────────────── - --- GIN trigram index for ILIKE-based property value searches -CREATE INDEX idx_property_value_trgm - ON property USING GIN (value gin_trgm_ops); - -COMMENT ON INDEX idx_property_value_trgm IS 'GIN trigram index for ILIKE pattern matching on property value'; +COMMENT ON INDEX idx_entity_name_lower IS 'Supports LOWER(name) comparisons for EQ, NEQ, and STARTS_WITH operators'; +COMMENT ON INDEX idx_entity_identifier_lower IS 'Supports LOWER(identifier) comparisons for EQ, NEQ, and STARTS_WITH operators'; +COMMENT ON INDEX idx_entity_template_identifier_lower IS 'Supports LOWER(template_identifier) comparisons for EQ, NEQ, and STARTS_WITH operators'; From 13cb37da49d3501261ff4258ff25abc2ddedcffe Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 21 May 2026 08:09:57 +0200 Subject: [PATCH 42/51] feat(core): fix andres review --- docs/src/concepts/entities.md | 4 +- docs/src/static/swagger.yaml | 2 +- .../domain/constant/ValidationMessages.java | 3 + .../entity/EntityValidationService.java | 67 ++++++++++++++++- .../api/controller/EntityController.java | 2 +- .../entity/EntityValidationServiceTest.java | 73 +++++++++++++++++++ .../api/controller/EntityControllerTest.java | 6 +- 7 files changed, 147 insertions(+), 10 deletions(-) diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 92598c0..150fb3d 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -133,6 +133,8 @@ After syntactic checks pass, the domain service validates the entity against its - **Property value types** - Values must conform to the property definition type (STRING, NUMBER, BOOLEAN). - **Property rules** - Values must satisfy the template's property rules (min/max length, format, regex, enum). - **Required properties** - All properties marked as required in the template must be present. +- **Relation names** - Each provided relation must exist in the template relation definitions. +- **Relation constraints** - Required relations must be present, and non-`to_many` relations can target only one entity. - **Duplicate check** - An entity with the same identifier must not already exist for the template. Returns `409 Conflict` if it does. @@ -302,7 +304,7 @@ GET /api/v1/entities/{templateIdentifier}?page=0&size=20&sort=identifier,asc Retrieve a specific entity using its template and entity identifiers: ```text -GET /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier} +GET /api/v1/entities/{templateIdentifier}/{entityIdentifier} ``` --- diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index fe7bb92..83e19e3 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -271,7 +271,7 @@ paths: schema: "$ref": "#/components/schemas/ErrorResponse" description: Unexpected server-side failure - /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + /api/v1/entities/{templateIdentifier}/{entityIdentifier}: get: tags: - Entities Management 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 0d011a5..eefac27 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 @@ -50,6 +50,9 @@ public class ValidationMessages { public static final String RELATION_NAME_ALREADY_EXISTS = "Relation name '%s' already exists within the template. Relation names must be unique."; 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"; + public static final String RELATION_NOT_DEFINED_IN_TEMPLATE = "Relation '%s' is not defined in template '%s'"; + public static final String RELATION_REQUIRED_MISSING = "Relation '%s' is required by template '%s'"; + public static final String RELATION_TOO_MANY_TARGETS = "Relation '%s' allows only one target in template '%s'"; public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 8143e6c..80da26a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -1,6 +1,9 @@ package com.decathlon.idp_core.domain.service.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; import java.util.List; import java.util.Map; @@ -13,8 +16,10 @@ import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; 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.RelationDefinition; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; @@ -46,17 +51,18 @@ public class EntityValidationService { /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template void validateForCreation(Entity entity, EntityTemplate template) { validateUniqueness(entity); - validateAgainstTemplate(template, entity.properties()); + validateAgainstTemplate(template, entity); } /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation - /// @param properties the list of properties from the entity to validate + /// @param entity the entity being validated, containing the actual property values to check + /// @throws EntityValidationException if any property validation rules are violated, including missing required properties private void validateAgainstTemplate(EntityTemplate template, - List properties) { + Entity entity) { Violations violations = new Violations(); List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); - Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() + Map propertiesByName = Optional.ofNullable(entity.properties()).orElse(List.of()).stream() .filter(p -> p.name() != null) .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); @@ -77,11 +83,64 @@ private void validateAgainstTemplate(EntityTemplate template, .validatePropertyValue(definition, property.value()) .forEach(violations::add); } + + validateRelationsAgainstTemplate(template, entity.relations(), violations); + if (!violations.isEmpty()) { throw new EntityValidationException(violations.asList()); } } + /// Validates entity relations against the template's relation definitions, enforcing required relations and cardinality constraints. + /// @param template the entity template whose relation definitions are used for validation + /// @param providedRelations the actual relations provided in the entity to validate + /// @param violations the accumulator for any validation violations found during the process + private void validateRelationsAgainstTemplate(EntityTemplate template, + List providedRelations, + Violations violations) { + + List definitions = template.relationsDefinitions() != null ? template.relationsDefinitions() : List.of(); + List relations = providedRelations != null ? providedRelations : List.of(); + + Map definitionsByName = definitions.stream() + .filter(def -> def.name() != null) + .collect(Collectors.toMap(RelationDefinition::name, def -> def, + (existing, replacement) -> existing)); + + Map relationsByName = relations.stream() + .filter(rel -> rel.name() != null) + .collect(Collectors.toMap(Relation::name, rel -> rel, + (existing, replacement) -> existing)); + + for (Relation relation : relations) { + if (relation.name() != null && !definitionsByName.containsKey(relation.name())) { + violations.add(RELATION_NOT_DEFINED_IN_TEMPLATE, relation.name(), template.identifier()); + } + } + + for (RelationDefinition definition : definitions) { + Relation relation = relationsByName.get(definition.name()); + List validTargets = extractValidTargetIdentifiers(relation); + + if (definition.required() && validTargets.isEmpty()) { + violations.add(RELATION_REQUIRED_MISSING, definition.name(), template.identifier()); + } + + if (relation != null && !definition.toMany() && validTargets.size() > 1) { + violations.add(RELATION_TOO_MANY_TARGETS, definition.name(), template.identifier()); + } + } + } + + private List extractValidTargetIdentifiers(Relation relation) { + if (relation == null || relation.targetEntityIdentifiers() == null) { + return List.of(); + } + return relation.targetEntityIdentifiers().stream() + .filter(id -> id != null && !id.isBlank()) + .toList(); + } + /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists 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 f9f8d90..2b10c03 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 @@ -122,7 +122,7 @@ public Page getEntities( @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") + @GetMapping("/{templateIdentifier}/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut getEntity( @PathVariable String templateIdentifier, diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 6411bd6..3e5c01d 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -1,6 +1,9 @@ package com.decathlon.idp_core.domain.service.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -29,6 +32,7 @@ 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; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; @@ -220,6 +224,75 @@ void shouldValidateStringPropertyWithNumericStringValue() { verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); } + @Test + @DisplayName("Should fail when required relation is missing") + void shouldFailWhenRequiredRelationIsMissing() { + var dependsOn = new RelationDefinition(UUID.randomUUID(), "depends-on", "service", true, true); + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(), + List.of(dependsOn)); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + var exception = assertThrows(EntityValidationException.class, + () -> entityValidationService.validateForCreation(entity, template)); + + assertEquals(List.of(RELATION_REQUIRED_MISSING.formatted("depends-on", "web-service")), exception.getViolations()); + } + + @Test + @DisplayName("Should fail when relation is not defined by template") + void shouldFailWhenRelationIsNotDefinedByTemplate() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(), + List.of(new Relation(UUID.randomUUID(), "unsupported", "service", List.of("target-1")))); + + var exception = assertThrows(EntityValidationException.class, + () -> entityValidationService.validateForCreation(entity, template)); + + assertEquals(List.of(RELATION_NOT_DEFINED_IN_TEMPLATE.formatted("unsupported", "web-service")), exception.getViolations()); + } + + @Test + @DisplayName("Should fail when non-toMany relation has multiple targets") + void shouldFailWhenNonToManyRelationHasMultipleTargets() { + var ownedBy = new RelationDefinition(UUID.randomUUID(), "owned-by", "team", false, false); + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(), + List.of(ownedBy)); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(), + List.of(new Relation(UUID.randomUUID(), "owned-by", "team", List.of("team-a", "team-b")))); + + var exception = assertThrows(EntityValidationException.class, + () -> entityValidationService.validateForCreation(entity, template)); + + assertEquals(List.of(RELATION_TOO_MANY_TARGETS.formatted("owned-by", "web-service")), exception.getViolations()); + } + private Entity entity( String templateIdentifier, String identifier, diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index ccee22d..5380154 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -25,7 +25,7 @@ public class EntityControllerTest extends AbstractIntegrationTest { private static final String TEMPLATE_IDENTIFIER = "web-service"; private static final String ENTITY_IDENTIFIER = "web-api-2"; - private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; + private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/{identifier}"; private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; @Autowired @@ -111,10 +111,10 @@ void getEntities_invalid_pagination_200() throws Exception { } } - /// Tests for GET /api/v1/entities/{template-identifier}/identifier/{identifier} + /// Tests for GET /api/v1/entities/{template-identifier}/{identifier} /// endpoint (lookup by template and identifier). @Nested - @DisplayName("GET /api/v1/entities/{template-identifier}/identifier/{identifier} - Get Entities by template identifier and entity identifier") + @DisplayName("GET /api/v1/entities/{template-identifier}/{identifier} - Get Entities by template identifier and entity identifier") class GetEntitiesByTemplateAndEntityIdentifierTests { @Test From 5c6c7925193b177e0727841c4de4cb42ea6a9162 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 21 May 2026 10:02:18 +0200 Subject: [PATCH 43/51] feat(core): refacto test and validation --- .../entity/EntityValidationService.java | 80 +------ .../domain/service/entity/Violations.java | 6 +- .../property/PropertyValidationService.java | 32 +++ .../relation/RelationValidationService.java | 68 ++++++ .../entity/EntityValidationServiceTest.java | 222 ++++-------------- .../PropertyValidationServiceTest.java | 120 +++++++++- .../RelationValidationServiceTest.java | 200 ++++++++++++++++ 7 files changed, 468 insertions(+), 260 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 80da26a..0c03919 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -1,10 +1,5 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; - import java.util.List; import java.util.Map; import java.util.Optional; @@ -16,12 +11,11 @@ import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; -import com.decathlon.idp_core.domain.model.entity.Relation; 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.RelationDefinition; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; +import com.decathlon.idp_core.domain.service.relation.RelationValidationService; import lombok.AllArgsConstructor; @@ -37,6 +31,7 @@ public class EntityValidationService { private final EntityRepositoryPort entityRepository; private final PropertyValidationService propertyValidationService; + private final RelationValidationService relationValidationService; /// Validates intrinsic entity data integrity and template-driven rules. /// @@ -61,85 +56,22 @@ void validateForCreation(Entity entity, EntityTemplate template) { private void validateAgainstTemplate(EntityTemplate template, Entity entity) { Violations violations = new Violations(); + List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); + Map propertiesByName = Optional.ofNullable(entity.properties()).orElse(List.of()).stream() .filter(p -> p.name() != null) .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); - for (PropertyDefinition definition : definitions) { - Property property = propertiesByName.get(definition.name()); - boolean missing = property == null - || property.value() == null - || (property.value().isBlank()); - - if (missing) { - if (definition.required()) { - violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); - } - continue; - } - - propertyValidationService - .validatePropertyValue(definition, property.value()) - .forEach(violations::add); - } + propertyValidationService.validatePropertiesAgainstTemplate(template, definitions, propertiesByName, violations); - validateRelationsAgainstTemplate(template, entity.relations(), violations); + relationValidationService.validateRelationsAgainstTemplate(template, entity.relations(), violations); if (!violations.isEmpty()) { throw new EntityValidationException(violations.asList()); } } - /// Validates entity relations against the template's relation definitions, enforcing required relations and cardinality constraints. - /// @param template the entity template whose relation definitions are used for validation - /// @param providedRelations the actual relations provided in the entity to validate - /// @param violations the accumulator for any validation violations found during the process - private void validateRelationsAgainstTemplate(EntityTemplate template, - List providedRelations, - Violations violations) { - - List definitions = template.relationsDefinitions() != null ? template.relationsDefinitions() : List.of(); - List relations = providedRelations != null ? providedRelations : List.of(); - - Map definitionsByName = definitions.stream() - .filter(def -> def.name() != null) - .collect(Collectors.toMap(RelationDefinition::name, def -> def, - (existing, replacement) -> existing)); - - Map relationsByName = relations.stream() - .filter(rel -> rel.name() != null) - .collect(Collectors.toMap(Relation::name, rel -> rel, - (existing, replacement) -> existing)); - - for (Relation relation : relations) { - if (relation.name() != null && !definitionsByName.containsKey(relation.name())) { - violations.add(RELATION_NOT_DEFINED_IN_TEMPLATE, relation.name(), template.identifier()); - } - } - - for (RelationDefinition definition : definitions) { - Relation relation = relationsByName.get(definition.name()); - List validTargets = extractValidTargetIdentifiers(relation); - - if (definition.required() && validTargets.isEmpty()) { - violations.add(RELATION_REQUIRED_MISSING, definition.name(), template.identifier()); - } - - if (relation != null && !definition.toMany() && validTargets.size() > 1) { - violations.add(RELATION_TOO_MANY_TARGETS, definition.name(), template.identifier()); - } - } - } - - private List extractValidTargetIdentifiers(Relation relation) { - if (relation == null || relation.targetEntityIdentifiers() == null) { - return List.of(); - } - return relation.targetEntityIdentifiers().stream() - .filter(id -> id != null && !id.isBlank()) - .toList(); - } /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java index 92a3dd6..3aeb9b4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -7,12 +7,12 @@ /// Centralises message formatting and indexed-prefix handling so domain /// validators stay focused on the rule they enforce rather than on string /// concatenation. Not thread-safe; intended for short-lived per-request use. -final class Violations { +public final class Violations { private final List messages = new ArrayList<>(); void add(String message) { messages.add(message); } - void add(String template, Object... args) { + public void add(String template, Object... args) { messages.add(template.formatted(args)); } void addIfBlank(String value, String message) { @@ -23,7 +23,7 @@ void addIfBlank(String value, String message) { /// Adds a violation prefixed with the indexed collection name, e.g. /// `Property[2]: Property name is mandatory`. - void addIndexed(String collection, int index, String message) { + public void addIndexed(String collection, int index, String message) { messages.add("%s[%d]: %s".formatted(collection, index, message)); } boolean isEmpty() { diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index 604891c..25446ec 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -7,6 +7,7 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REGEX_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_MISMATCH; import java.math.BigDecimal; @@ -18,10 +19,14 @@ import org.springframework.stereotype.Service; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Property; +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; import com.decathlon.idp_core.domain.model.enums.PropertyFormat; import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.service.entity.Violations; /** * Domain service validating entity property values against template definitions. @@ -55,6 +60,33 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, }; } + /// Validates that all required properties defined in the template are present and conform to their definitions. + /// For each property definition, checks if the corresponding property is provided and non-blank. If a required property is missing, adds a violation. If the property is present, validates its value against the definition's rules and accumulates any violations found. + /// @param template the entity template whose property definitions are used for validation + /// @param definitions the list of property definitions from the template + /// @param propertiesByName a map of provided properties keyed by their name for quick lookup + /// @param violations the accumulator for any validation violations found during the process + /// @throws EntityValidationException if any required property is missing or if any property value violates its definition rules + /// @implNote This method focuses on validating the presence and correctness of properties as defined by the template. It iterates through each property definition, checks for the corresponding provided property, and applies the appropriate validation logic based on the property's type and rules. + public void validatePropertiesAgainstTemplate(final EntityTemplate template, final List definitions, final Map propertiesByName, final Violations violations) { + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null + || property.value() == null + || (property.value().isBlank()); + + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); + } + continue; + } + + validatePropertyValue(definition, property.value()) + .forEach(violations::add); + } + } + private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { if (!(rawValue instanceof String stringValue)) { diff --git a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java new file mode 100644 index 0000000..4cd92e8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java @@ -0,0 +1,68 @@ +package com.decathlon.idp_core.domain.service.relation; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; +import com.decathlon.idp_core.domain.service.entity.Violations; + +/// Domain service validating entity relations against template relation definitions. +@Service +public class RelationValidationService { + + /// Validates entity relations against the template's relation definitions, enforcing required relations and cardinality constraints. + /// @param template the entity template whose relation definitions are used for validation + /// @param providedRelations the actual relations provided in the entity to validate + /// @param violations the accumulator for any validation violations found during the process + public void validateRelationsAgainstTemplate(EntityTemplate template, + List providedRelations, + Violations violations) { + + List definitions = template.relationsDefinitions() != null ? template.relationsDefinitions() : List.of(); + List relations = providedRelations != null ? providedRelations : List.of(); + + Map definitionsByName = definitions.stream() + .filter(def -> def.name() != null) + .collect(Collectors.toMap(RelationDefinition::name, def -> def, + (existing, replacement) -> existing)); + + Map relationsByName = relations.stream() + .filter(rel -> rel.name() != null) + .collect(Collectors.toMap(Relation::name, rel -> rel, + (existing, replacement) -> existing)); + + for (Relation relation : relations) { + if (relation.name() != null && !definitionsByName.containsKey(relation.name())) { + violations.add(RELATION_NOT_DEFINED_IN_TEMPLATE, relation.name(), template.identifier()); + } + } + + for (RelationDefinition definition : definitions) { + Relation relation = relationsByName.get(definition.name()); + List validTargets = extractValidTargetIdentifiers(relation); + + if (definition.required() && validTargets.isEmpty()) { + violations.add(RELATION_REQUIRED_MISSING, definition.name(), template.identifier()); + } + + if (relation != null && !definition.toMany() && validTargets.size() > 1) { + violations.add(RELATION_TOO_MANY_TARGETS, definition.name(), template.identifier()); + } + } + } + + private List extractValidTargetIdentifiers(Relation relation) { + if (relation == null || relation.targetEntityIdentifiers() == null) { + return List.of(); + } + return relation.targetEntityIdentifiers().stream() + .filter(id -> id != null && !id.isBlank()) + .toList(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 3e5c01d..7a4711d 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -1,19 +1,18 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -22,6 +21,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedConstruction; import org.mockito.junit.jupiter.MockitoExtension; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; @@ -30,12 +30,9 @@ import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; 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; -import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; -import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; +import com.decathlon.idp_core.domain.service.relation.RelationValidationService; @ExtendWith(MockitoExtension.class) @DisplayName("EntityValidationService Tests") @@ -44,6 +41,8 @@ class EntityValidationServiceTest { @Mock private EntityRepositoryPort entityRepository; + @Mock + private RelationValidationService relationValidationService; @Mock private PropertyValidationService propertyValidationService; @@ -83,170 +82,48 @@ void shouldNotQueryRepositoryWhenIdentifierIsNull() { assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); - } - - - @Test - @DisplayName("Should aggregate property requirements and rule violations") - void shouldAggregateAllViolationsDuringValidateForCreation() { - var portDefinition = new PropertyDefinition( - UUID.randomUUID(), - "port", - "Port", - PropertyType.NUMBER, - true, - new PropertyRules(null, null, null, null, null, null, 65535, 1024)); - - var requiredDefinition = new PropertyDefinition( - UUID.randomUUID(), - "ownerEmail", - "Owner email", - PropertyType.STRING, - true, - null); - - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(requiredDefinition, portDefinition), - List.of()); - - var entity = entity( - "web-service", - " ", // Blank identifier (handled by Jakarta, not this service) - " ", // Blank name (handled by Jakarta, not this service) - List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), - List.of()); // No relations - - when(propertyValidationService.validatePropertyValue(portDefinition, "80")) - .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateForCreation(entity, template)); - - // Expecting exactly 2 errors: the missing required property, and the invalid port value. - assertEquals(2, exception.getViolations().size()); - assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(0)); - assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(1)); - - verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(any(), any()); } @Test - @DisplayName("Should validate entity successfully when no violations") + @DisplayName("Should validate entity successfully by delegating to property and relation validation services") void shouldValidateForCreationSuccessfullyWhenNoViolations() { - var versionDefinition = new PropertyDefinition( - UUID.randomUUID(), - "version", - "Version", - PropertyType.STRING, - false, - null); - var template = new EntityTemplate( UUID.randomUUID(), "web-service", "Web Service", "desc", - List.of(versionDefinition), + List.of(), List.of()); + var property = new Property(UUID.randomUUID(), "version", "1.0.0"); + var relation = new Relation(UUID.randomUUID(), "owned-by", "team", List.of("team-a")); var entity = entity( "web-service", "catalog-api", "Catalog API", - List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), - null); - - - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + List.of(property), + List.of(relation)); assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); - } - - @Test - @DisplayName("Should skip property rule validation for missing optional property") - void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { - var optionalDefinition = new PropertyDefinition( - UUID.randomUUID(), - "version", - "Version", - PropertyType.STRING, - false, - null); - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(optionalDefinition), - List.of()); - - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verifyNoInteractions(propertyValidationService); - } - - @Test - @DisplayName("Should validate property of type STRING with a numeric string value '1234'") - void shouldValidateStringPropertyWithNumericStringValue() { - var stringDefinition = new PropertyDefinition( - UUID.randomUUID(), - "versionCode", - "Version Code as String", - PropertyType.STRING, - false, - null + verify(propertyValidationService).validatePropertiesAgainstTemplate( + eq(template), + eq(template.propertiesDefinitions()), + eq(Map.of("version", property)), + any(Violations.class) ); - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(stringDefinition), - List.of()); - - var entity = entity( - "web-service", - "catalog-api", - "Catalog API", - List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), - null); - when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); - } - - @Test - @DisplayName("Should fail when required relation is missing") - void shouldFailWhenRequiredRelationIsMissing() { - var dependsOn = new RelationDefinition(UUID.randomUUID(), "depends-on", "service", true, true); - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(), - List.of(dependsOn)); - - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - - var exception = assertThrows(EntityValidationException.class, - () -> entityValidationService.validateForCreation(entity, template)); - - assertEquals(List.of(RELATION_REQUIRED_MISSING.formatted("depends-on", "web-service")), exception.getViolations()); + verify(relationValidationService).validateRelationsAgainstTemplate( + eq(template), + eq(entity.relations()), + any(Violations.class) + ); } @Test - @DisplayName("Should fail when relation is not defined by template") - void shouldFailWhenRelationIsNotDefinedByTemplate() { + @DisplayName("Should throw EntityValidationException when delegated validations populate the Violations aggregate") + void shouldThrowEntityValidationExceptionWhenViolationsExist() { var template = new EntityTemplate( UUID.randomUUID(), "web-service", @@ -255,42 +132,23 @@ void shouldFailWhenRelationIsNotDefinedByTemplate() { List.of(), List.of()); - var entity = entity( - "web-service", - "catalog-api", - "Catalog API", - List.of(), - List.of(new Relation(UUID.randomUUID(), "unsupported", "service", List.of("target-1")))); - - var exception = assertThrows(EntityValidationException.class, - () -> entityValidationService.validateForCreation(entity, template)); + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - assertEquals(List.of(RELATION_NOT_DEFINED_IN_TEMPLATE.formatted("unsupported", "web-service")), exception.getViolations()); - } + try (MockedConstruction mockedViolations = mockConstruction(Violations.class, + (mock, context) -> { + when(mock.isEmpty()).thenReturn(false); + when(mock.asList()).thenReturn(List.of("Delegated property error", "Delegated relation error")); + })) { - @Test - @DisplayName("Should fail when non-toMany relation has multiple targets") - void shouldFailWhenNonToManyRelationHasMultipleTargets() { - var ownedBy = new RelationDefinition(UUID.randomUUID(), "owned-by", "team", false, false); - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(), - List.of(ownedBy)); - - var entity = entity( - "web-service", - "catalog-api", - "Catalog API", - List.of(), - List.of(new Relation(UUID.randomUUID(), "owned-by", "team", List.of("team-a", "team-b")))); + var exception = assertThrows(EntityValidationException.class, + () -> entityValidationService.validateForCreation(entity, template)); - var exception = assertThrows(EntityValidationException.class, - () -> entityValidationService.validateForCreation(entity, template)); + assertEquals(2, exception.getViolations().size()); + assertEquals("Delegated property error", exception.getViolations().get(0)); - assertEquals(List.of(RELATION_TOO_MANY_TARGETS.formatted("owned-by", "web-service")), exception.getViolations()); + verify(propertyValidationService).validatePropertiesAgainstTemplate(eq(template), any(), any(), any()); + verify(relationValidationService).validateRelationsAgainstTemplate(eq(template), any(), any()); + } } private Entity entity( diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 14ed3f6..a73c90e 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -1,8 +1,13 @@ package com.decathlon.idp_core.domain.service.property; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import java.util.List; +import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -11,16 +16,87 @@ import org.junit.jupiter.params.provider.ValueSource; import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.model.entity.Property; +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; import com.decathlon.idp_core.domain.model.enums.PropertyFormat; import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.service.entity.Violations; @DisplayName("PropertyValidationService Tests") class PropertyValidationServiceTest { private final PropertyValidationService service = new PropertyValidationService(); + @Nested + @DisplayName("validatePropertiesAgainstTemplate Orchestration Tests") + class AgainstTemplateValidationTests { + + @Test + @DisplayName("Should report violation when required property is completely missing") + void shouldReportViolationWhenRequiredPropertyIsMissing() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", PropertyType.STRING, true, null); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), violations); + + verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", "system-template"); + } + + @Test + @DisplayName("Should report violation when required property is present but blank") + void shouldReportViolationWhenRequiredPropertyIsBlank() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", PropertyType.STRING, true, null); + var property = new Property(UUID.randomUUID(), "owner", " "); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("owner", property), violations); + + verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", "system-template"); + } + + @Test + @DisplayName("Should not report violation when optional property is missing") + void shouldNotReportViolationWhenOptionalPropertyIsMissing() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "description", "Desc", PropertyType.STRING, false, null); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), violations); + + verifyNoInteractions(violations); + } + + @Test + @DisplayName("Should delegate to validatePropertyValue and accumulate rule violations") + void shouldDelegateAndAccumulateRuleViolations() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", PropertyType.NUMBER, true, null); + var property = new Property(UUID.randomUUID(), "port", "not-a-number"); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("port", property), violations); + + verify(violations).add(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("port", PropertyType.NUMBER)); + } + + @Test + @DisplayName("Should add no violations when required property is present and valid") + void shouldAddNoViolationsWhenValid() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", PropertyType.NUMBER, true, null); + var property = new Property(UUID.randomUUID(), "port", "8080"); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("port", property), violations); + + verifyNoInteractions(violations); + } + } + @Nested @DisplayName("STRING validation") class StringValidationTests { @@ -207,6 +283,15 @@ void shouldUseCachedPatternForRepeatedRegex() { @DisplayName("NUMBER validation") class NumberValidationTests { + @Test + @DisplayName("Should report type mismatch when NUMBER value is null") + void shouldReportTypeMismatchWhenNumberValueIsNull() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + var violations = service.validatePropertyValue(definition, null); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + @Test @DisplayName("Should report type mismatch for non-numeric NUMBER value") void shouldReportTypeMismatchWhenNumberValueIsInvalid() { @@ -217,6 +302,17 @@ void shouldReportTypeMismatchWhenNumberValueIsInvalid() { assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); } + @Test + @DisplayName("Should accept primitive/boxed Number objects") + void shouldAcceptBoxedNumberObjects() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + var violationsInt = service.validatePropertyValue(definition, 42); + var violationsDouble = service.validatePropertyValue(definition, 42.5); + + assertEquals(List.of(), violationsInt); + assertEquals(List.of(), violationsDouble); + } + @Test @DisplayName("Should return no violations when NUMBER has no rules") void shouldReturnNoViolationsWhenNumberHasNoRules() { @@ -299,7 +395,29 @@ void shouldReportTypeMismatchWhenBooleanSentForNumber() { @DisplayName("BOOLEAN validation") class BooleanValidationTests { - @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") + @Test + @DisplayName("Should report type mismatch when BOOLEAN value is null") + void shouldReportTypeMismatchWhenBooleanValueIsNull() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, null); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); + } + + @Test + @DisplayName("Should accept raw Boolean objects") + void shouldAcceptRawBooleanObjects() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violationsTrue = service.validatePropertyValue(definition, true); + var violationsFalse = service.validatePropertyValue(definition, Boolean.FALSE); + + assertEquals(List.of(), violationsTrue); + assertEquals(List.of(), violationsFalse); + } + + @ParameterizedTest(name = "Should accept valid boolean string value: ''{0}''") @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) void shouldAcceptValidBooleanValues(String value) { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java new file mode 100644 index 0000000..447cd9e --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java @@ -0,0 +1,200 @@ +package com.decathlon.idp_core.domain.service.relation; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; +import com.decathlon.idp_core.domain.service.entity.Violations; + +@DisplayName("RelationValidationService Tests") +class RelationValidationServiceTest { + + private final RelationValidationService service = new RelationValidationService(); + + @Test + @DisplayName("Should pass all checks cleanly when relations map exactly to definitions") + void shouldPassCleanlyOnValidEntity() { + var definition1 = definition("owned-by", true, false); + var definition2 = definition("depends-on", false, true); + var template = template("system-template", List.of(definition1, definition2)); + + var relation1 = relation("owned-by", List.of("team-a")); + var relation2 = relation("depends-on", List.of("service-x", "service-y")); + + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation1, relation2), violations); + + verifyNoInteractions(violations); + } + + private EntityTemplate template(String identifier, List relationDefinitions) { + return new EntityTemplate( + UUID.randomUUID(), + identifier, + "Template Name", + "Description", + List.of(), + relationDefinitions + ); + } + + private RelationDefinition definition(String name, boolean required, boolean toMany) { + return new RelationDefinition( + UUID.randomUUID(), + name, + "targetType", + required, + toMany + ); + } + + private Relation relation(String name, List targets) { + return new Relation( + UUID.randomUUID(), + name, + "targetType", + targets + ); + } + + @Nested + @DisplayName("Relation Existence Checks") + class ExistenceTests { + + @Test + @DisplayName("Should report violation when relation is not defined in the template") + void shouldReportViolationWhenRelationNotDefined() { + var template = template("system-template", List.of()); + var relation = relation("unknown-relation", List.of("target-1")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_NOT_DEFINED_IN_TEMPLATE, "unknown-relation", "system-template"); + } + + @Test + @DisplayName("Should handle missing definition lists and relation lists gracefully") + void shouldHandleNullListsGracefully() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), null); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, null, violations); + + verifyNoInteractions(violations); + } + } + + @Nested + @DisplayName("Relation Requirement Checks") + class RequirementTests { + + @Test + @DisplayName("Should report violation when required relation is missing completely") + void shouldReportViolationWhenRequiredRelationMissing() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(), violations); + + verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); + } + + @Test + @DisplayName("Should report violation when required relation is provided but target list is empty") + void shouldReportViolationWhenRequiredRelationHasEmptyTargets() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of()); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); + } + + @Test + @DisplayName("Should report violation when required relation only has blank targets") + void shouldReportViolationWhenRequiredRelationHasOnlyBlankTargets() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("", " ")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); + } + + @Test + @DisplayName("Should not report violation when an optional relation is omitted") + void shouldNotReportViolationWhenOptionalRelationOmitted() { + var definition = definition("depends-on", false, true); + var template = template("system-template", List.of(definition)); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(), violations); + + verifyNoInteractions(violations); + } + } + + @Nested + @DisplayName("Relation Cardinality Checks") + class CardinalityTests { + + @Test + @DisplayName("Should report violation when a non-toMany relation has multiple valid targets") + void shouldReportViolationForMultipleTargetsOnSingleRelation() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("team-a", "team-b")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_TOO_MANY_TARGETS, "owned-by", "system-template"); + } + + @Test + @DisplayName("Should not report violation for multiple targets if toMany is true") + void shouldNotReportViolationForMultipleTargetsWhenToManyIsTrue() { + var definition = definition("depends-on", false, true); + var template = template("system-template", List.of(definition)); + var relation = relation("depends-on", List.of("service-a", "service-b", "service-c")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verifyNoInteractions(violations); + } + + @Test + @DisplayName("Should ignore blank targets when checking cardinality constraints") + void shouldIgnoreBlankTargetsForCardinality() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("team-a", " ", "")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verifyNoInteractions(violations); + } + } +} From 7fdf3c6cbec2eeced9642a99b7f1607c91118b39 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 21 May 2026 10:36:04 +0200 Subject: [PATCH 44/51] feat(core): update entity endpoint --- docs/src/concepts/entities.md | 103 ++++++++++++++ docs/src/static/swagger.yaml | 109 +++++++++++++++ .../domain/constant/ValidationMessages.java | 1 + .../domain/service/entity/EntityService.java | 37 +++++ .../entity/EntityValidationService.java | 13 ++ .../api/configuration/SwaggerDescription.java | 3 + .../api/controller/EntityController.java | 24 ++++ .../controller/EntityTemplateController.java | 16 +++ .../service/entity/EntityServiceTest.java | 58 ++++++++ .../api/controller/EntityControllerTest.java | 127 ++++++++++++++++++ .../EntityTemplateControllerTest.java | 13 ++ 11 files changed, 504 insertions(+) diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 92598c0..6ec9ad9 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -307,6 +307,109 @@ GET /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier} --- +## Updating an Entity + +You update an existing entity by sending a `PUT` request on the entity resource path. + +### Update Endpoint + +```text +PUT /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier} +``` + +### Update Request Body + +The request body follows the same shape and validation rules as `POST /api/v1/entities/{templateIdentifier}`. + +```json +{ + "name": "my-web-service-updated", + "identifier": "my-web-service", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1" + ] + } + ] +} +``` + +### Update Example Request + +```bash +curl -X PUT http://localhost:8084/api/v1/entities/web-service/identifier/my-web-service \ + -H "Content-Type: application/json" \ + -d '{ + "name": "my-web-service-updated", + "identifier": "my-web-service", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + }' +``` + +### Update Example Response + +```json +{ + "identifier": "my-web-service", + "name": "my-web-service-updated", + "template_identifier": "web-service", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + }, + "relations": {}, + "relations_as_target": {} +} +``` + +### Additional Rule for Update + +- `identifier` in the request body must match `{entityIdentifier}` in the path. +- If they differ, the API returns `400 Bad Request`. + +### Update Response Codes + +| Code | Description | +|-------|--------------------------------------------------------| +| `200` | Entity updated successfully | +| `400` | Invalid request body or validation failure | +| `401` | Missing or invalid authentication token | +| `403` | Insufficient permissions | +| `404` | Template or entity not found for the given identifier | +| `500` | Unexpected server error | + +--- + ## Dynamic Schema Because templates are configured at runtime, the entity structure is **dynamic**: diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index fe7bb92..345c8c8 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -66,12 +66,34 @@ paths: '*/*': schema: $ref: '#/components/schemas/EntityTemplateDtoOut' + '400': + description: Invalid template data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights '404': description: Template not found with the provided identifier content: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + '409': + description: Template with this identifier already exists + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' delete: tags: - Entities Templates Management @@ -303,6 +325,93 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - Entities Management + summary: Update an existing entity + description: Update an existing entity in the system with the provided information + operationId: updateEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + minLength: 1 + type: string + - name: entityIdentifier + in: path + required: true + schema: + minLength: 1 + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntityDtoIn' + example: + name: my-web-service-updated + identifier: my-web-service + properties: + applicationName: catalog-api + ownerEmail: owner@example.com + port: '8080' + environment: DEV + version: 1.2.3 + teamName: platform-team + baseUrl: https://catalog.example.com + protocol: HTTP + programmingLanguage: JAVA + relations: + - name: depends-on + target_entity_identifiers: + - web-api-1 + responses: + '200': + description: Entity updated successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + example: + identifier: my-web-service + name: my-web-service-updated + template_identifier: web-service + properties: + applicationName: catalog-api + ownerEmail: owner@example.com + port: '8080' + environment: DEV + version: 1.2.3 + teamName: platform-team + baseUrl: https://catalog.example.com + protocol: HTTP + programmingLanguage: JAVA + relations: {} + relations_as_target: {} + '400': + description: Invalid entity data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' components: schemas: EntityTemplateUpdateDtoIn: 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 0d011a5..436d047 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 @@ -56,6 +56,7 @@ public class ValidationMessages { // Entity input validation messages public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MUST_MATCH_PATH = "Entity identifier in body must match path identifier"; // Entity creation validation messages public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 72e40ad..3f4da23 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.domain.service.entity; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MUST_MATCH_PATH; + import java.util.List; import org.springframework.data.domain.Page; @@ -106,5 +108,40 @@ public Entity createEntity(@Valid Entity entity) { return entityRepository.save(entity); } + /// Updates an existing entity identified by template and entity identifiers. + /// + /// **Contract:** validates that the path identifier and payload identifier are + /// aligned, then applies the same template-based semantic checks as creation + /// before persisting the updated aggregate. + /// + /// @param templateIdentifier template identifier from the request path + /// @param entityIdentifier entity identifier from the request path + /// @param entity validated entity payload + /// @return persisted updated entity + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when target entity doesn't exist + /// @throws EntityValidationException when payload violates template constraints + @Transactional + public Entity updateEntity(String templateIdentifier, String entityIdentifier, @Valid Entity entity) { + EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(templateIdentifier); + Entity existingEntity = entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + + if (!entityIdentifier.equals(entity.identifier())) { + throw new EntityValidationException(List.of(ENTITY_IDENTIFIER_MUST_MATCH_PATH)); + } + + Entity entityToSave = new Entity( + existingEntity.id(), + templateIdentifier, + entity.name(), + entity.identifier(), + entity.properties(), + entity.relations()); + + entityValidationService.validateForUpdate(entityToSave, template); + return entityRepository.save(entityToSave); + } + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 8143e6c..c77066f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -49,6 +49,19 @@ void validateForCreation(Entity entity, EntityTemplate template) { validateAgainstTemplate(template, entity.properties()); } + /// Validates entity data for update operations. + /// + /// **Contract:** update keeps the existing aggregate identity and applies the + /// same template conformance rules as creation. Uniqueness check is not needed + /// when updating an already identified entity. + /// + /// @param entity the entity payload to validate + /// @param template the already-resolved template the entity must conform to + /// @throws EntityValidationException when one or more validation rules are violated + void validateForUpdate(Entity entity, EntityTemplate template) { + validateAgainstTemplate(template, entity.properties()); + } + /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation /// @param properties the list of properties from the entity to validate diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index d645725..3bc74ab 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -66,6 +66,8 @@ public class SwaggerDescription { public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; + public static final String ENDPOINT_PUT_ENTITY_SUMMARY = "Update an existing entity"; + public static final String ENDPOINT_PUT_ENTITY_DESCRIPTION = "Update an existing entity in the system with the provided information"; /// API response description constants @@ -86,6 +88,7 @@ public class SwaggerDescription { public static final String RESPONSE_ENTITY_FOUND = "Entity found"; public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; + public static final String RESPONSE_ENTITY_UPDATED = "Entity updated successfully"; public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; 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 f9f8d90..cd79067 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 @@ -9,6 +9,8 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; @@ -21,6 +23,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CREATED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_FOUND; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_UPDATED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INSUFFICIENT_RIGHTS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_ENTITY_DATA; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_PAGINATION; @@ -43,6 +46,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PutMapping; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.service.entity.EntityService; @@ -158,4 +162,24 @@ public EntityDtoOut createEntity( Entity savedEntity = entityService.createEntity(entity); return entityDtoOutMapper.fromEntity(savedEntity); } + + /// Updates an existing entity for the specified template. + @Operation(summary = ENDPOINT_PUT_ENTITY_SUMMARY, description = ENDPOINT_PUT_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PutMapping("/{templateIdentifier}/identifier/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut updateEntity( + @NotBlank @PathVariable String templateIdentifier, + @NotBlank @PathVariable String entityIdentifier, + @Valid @RequestBody EntityDtoIn entityDtoIn) { + + Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); + Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); + return entityDtoOutMapper.fromEntity(updatedEntity); + } } 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 fa0f947..5779713 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 @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.BAD_REQUEST_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CONFLICT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CREATED_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_DELETE_TEMPLATE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_DELETE_TEMPLATE_SUMMARY; @@ -12,6 +13,8 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_TEMPLATE_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_TEMPLATE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_TEMPLATE_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NO_CONTENT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; @@ -20,12 +23,17 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SORT_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_PAGINATION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_TEMPLATE_DATA; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INSUFFICIENT_RIGHTS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_CONFLICT; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATES_PAGINATED_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_CREATED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_DELETED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_FOUND; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_UPDATED; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNAUTHORIZED; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.http.HttpStatus.OK; @@ -150,8 +158,16 @@ public EntityTemplateDtoOut createTemplate(@Valid @RequestBody EntityTemplateCre @Operation(summary = ENDPOINT_PUT_TEMPLATE_SUMMARY, description = ENDPOINT_PUT_TEMPLATE_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_UPDATED, content = { @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class)) }) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_TEMPLATE_DATA, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) @ApiResponse(responseCode = "404", description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_TEMPLATE_CONFLICT, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) @PutMapping("/{identifier}") public EntityTemplateDtoOut updateTemplate( @PathVariable(name = "identifier") String identifier, diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 22b4cb9..7a9ca33 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -26,6 +26,7 @@ import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; 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; @@ -158,6 +159,63 @@ void shouldStopWhenTemplateDoesNotExistOnCreate() { verifyNoMoreInteractions(entityRepository); } + @Test + @DisplayName("Should update entity when validations pass") + void shouldUpdateEntityWhenValidationsPass() { + var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), List.of()); + var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); + var expectedSaved = new Entity(existing.id(), "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")).thenReturn(Optional.of(existing)); + when(entityRepository.save(expectedSaved)).thenReturn(expectedSaved); + + var result = entityService.updateEntity("web-service", "web-api-2", payload); + + assertSame(expectedSaved, result); + InOrder inOrder = inOrder(entityTemplateService, entityRepository, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); + inOrder.verify(entityValidationService).validateForUpdate(expectedSaved, template); + inOrder.verify(entityRepository).save(expectedSaved); + } + + @Test + @DisplayName("Should throw when updating non-existing entity") + void shouldThrowWhenUpdatingNonExistingEntity() { + var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")).thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> entityService.updateEntity("web-service", "web-api-2", payload)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should reject update when path identifier and body identifier differ") + void shouldRejectUpdateWhenPathAndBodyIdentifierDiffer() { + var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), List.of()); + var payload = new Entity(null, "web-service", "Web API 2 Updated", "different-id", List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")).thenReturn(Optional.of(existing)); + + assertThrows(EntityValidationException.class, + () -> entityService.updateEntity("web-service", "web-api-2", payload)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); + verifyNoInteractions(entityValidationService); + } + private Entity entity(String templateIdentifier, String identifier, String name) { return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index ccee22d..7f0b0a3 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -3,6 +3,7 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -261,4 +262,130 @@ void postEntity_400_when_property_rules_not_respected() throws Exception { } + @Nested + @DisplayName("PUT /api/v1/entities/{template-identifier}/identifier/{identifier} - Update entity") + class PutEntitiesTests { + + @Test + @WithMockUser + @DisplayName("Should update entity and return 200") + void putEntity_200() throws Exception { + var payload = """ + { + "name": "Web API 2 Updated", + "identifier": "web-api-2", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) + .andExpect(jsonPath("$.template_identifier").value(TEMPLATE_IDENTIFIER)) + .andExpect(jsonPath("$.name").value("Web API 2 Updated")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when updating non-existent entity") + void putEntity_404_non_existent_entity() throws Exception { + var payload = """ + { + "name": "Unknown", + "identifier": "unknown-entity" + } + """; + + mockMvc.perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "unknown-entity") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when path identifier and body identifier do not match") + void putEntity_400_identifier_mismatch() throws Exception { + var payload = """ + { + "name": "Web API 2 Updated", + "identifier": "different-id", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Entity identifier in body must match path identifier"))); + } + + @Test + @DisplayName("Should return 401 when updating without authentication") + void putEntity_401_without_user_token() throws Exception { + var payload = """ + { + "name": "Web API 2 Updated", + "identifier": "web-api-2" + } + """; + + mockMvc.perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 403 when updating without CSRF token") + void putEntity_403_without_csrf() throws Exception { + var payload = """ + { + "name": "Web API 2 Updated", + "identifier": "web-api-2" + } + """; + + mockMvc.perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .content(payload)) + .andExpect(status().isForbidden()); + } + } + } 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 c6b2a23..f8397e7 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 @@ -566,6 +566,19 @@ void putTemplate_without_user_token_401() throws Exception { .andExpect(status().isUnauthorized()); } + @Test + @WithMockUser + @DisplayName("Should return 403 when updating template without CSRF token") + void putTemplate_without_csrf_403() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser @DisplayName("Should update existing property rules using PUT") From b963fed9f030bad0d1a64878f47eafa64070a0a3 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 21 May 2026 10:59:26 +0200 Subject: [PATCH 45/51] feat(core): update entity endpoint --- specs/static-swagger-declared.yaml | 892 ++++++++++++++++++ specs/swagger-extracted.yaml | 1 + .../entity/EntityValidationService.java | 2 +- .../api/controller/EntityController.java | 2 +- 4 files changed, 895 insertions(+), 2 deletions(-) create mode 100644 specs/static-swagger-declared.yaml create mode 100644 specs/swagger-extracted.yaml diff --git a/specs/static-swagger-declared.yaml b/specs/static-swagger-declared.yaml new file mode 100644 index 0000000..345c8c8 --- /dev/null +++ b/specs/static-swagger-declared.yaml @@ -0,0 +1,892 @@ +openapi: 3.1.0 +info: + title: Idp core API + description: API dedicated to idp core functionalities + version: v1 +servers: + - url: http://localhost:8084 +security: + - clientId: [] + - bearer: [] +tags: + - name: Entities Management + description: Operations related to entity management + - name: Entities Templates Management + description: Operations related to entity template management +paths: + /api/v1/entity-templates/{identifier}: + get: + tags: + - Entities Templates Management + summary: Get template by identifier + description: Retrieve a specific template using its string identifier + operationId: getTemplateByIdentifier + parameters: + - name: identifier + in: path + required: true + schema: + type: string + responses: + '200': + description: Template found + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityTemplateDtoOut' + '404': + description: Template not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - Entities Templates Management + summary: Update an existing template by template identifier + description: Update the details of an existing template identified by its unique + string identifier + operationId: updateTemplate + parameters: + - name: identifier + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' + responses: + '200': + description: Template update successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityTemplateDtoOut' + '400': + description: Invalid template data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '404': + description: Template not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Template with this identifier already exists + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Entities Templates Management + summary: Delete template by identifier + description: Remove a template from the system using its unique identifier + operationId: deleteTemplate + parameters: + - name: identifier + in: path + required: true + schema: + type: string + responses: + '204': + description: Template deleted successfully + '404': + description: Template not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entity-templates: + get: + tags: + - Entities Templates Management + summary: Get paginated templates + description: Retrieve a paginated list of templates with optional sorting + operationId: getTemplatesPaginated + parameters: + - name: page + in: query + description: Page number for pagination. Defaults to 0. + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + content: + '*/*': + schema: + type: integer + default: '20' + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc.' + content: + '*/*': + schema: + type: string + default: identifier,asc + responses: + '200': + description: Paginated templates retrieved successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/TemplatePageResponse' + '400': + description: Invalid pagination parameters + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Entities Templates Management + summary: Create a new template + description: Create a new template in the system with the provided information + operationId: createTemplate + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntityTemplateCreateDtoIn' + responses: + '201': + description: Template created successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityTemplateDtoOut' + '400': + description: Invalid template data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/{templateIdentifier}: + get: + tags: + - Entities Management + summary: Get entities by template identifier + description: Retrieve a paginated list of entities with optional sorting + operationId: getEntities + parameters: + - name: page + in: query + required: false + description: Page number for pagination. Defaults to 0. + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + required: false + description: Number of items per page. Defaults to 20. + content: + '*/*': + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc.' + content: + '*/*': + schema: + type: string + default: identifier,asc + responses: + '200': + description: Paginated entities retrieved successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityPageResponse' + '400': + description: Invalid pagination parameters + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Entities Management + summary: Create a new entity + description: Create a new entity in the system with the provided information + operationId: createEntity + parameters: + - in: path + name: templateIdentifier + required: true + schema: + minLength: 1 + type: string + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/EntityDtoIn" + required: true + responses: + '201': + content: + "*/*": + schema: + "$ref": "#/components/schemas/EntityDtoOut" + description: Entity created successfully + '400': + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + description: Invalid entity data provided + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '404': + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + description: Template not found with the provided identifier + '409': + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + description: Entity already exists in this template + '500': + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + description: Unexpected server-side failure + /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + get: + tags: + - Entities Management + summary: Get entity by entity template and identifier + description: Retrieve a specific entity using its string identifier and its + template identifier + operationId: getEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string + responses: + '200': + description: Entity found + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - Entities Management + summary: Update an existing entity + description: Update an existing entity in the system with the provided information + operationId: updateEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + minLength: 1 + type: string + - name: entityIdentifier + in: path + required: true + schema: + minLength: 1 + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntityDtoIn' + example: + name: my-web-service-updated + identifier: my-web-service + properties: + applicationName: catalog-api + ownerEmail: owner@example.com + port: '8080' + environment: DEV + version: 1.2.3 + teamName: platform-team + baseUrl: https://catalog.example.com + protocol: HTTP + programmingLanguage: JAVA + relations: + - name: depends-on + target_entity_identifiers: + - web-api-1 + responses: + '200': + description: Entity updated successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + example: + identifier: my-web-service + name: my-web-service-updated + template_identifier: web-service + properties: + applicationName: catalog-api + ownerEmail: owner@example.com + port: '8080' + environment: DEV + version: 1.2.3 + teamName: platform-team + baseUrl: https://catalog.example.com + protocol: HTTP + programmingLanguage: JAVA + relations: {} + relations_as_target: {} + '400': + description: Invalid entity data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + schemas: + EntityTemplateUpdateDtoIn: + type: object + description: Input DTO for updating an entity template + properties: + name: + type: string + description: Unique Entity Template name + example: Service + maxLength: 255 + minLength: 1 + pattern: ^[a-zA-Z0-9 _-]+$ + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + $ref: '#/components/schemas/PropertyDefinitionDtoIn' + relations_definitions: + type: array + description: List of relation definitions for this template + items: + $ref: '#/components/schemas/RelationDefinitionDtoIn' + required: + - name + PropertyDefinitionDtoIn: + type: object + description: Input DTO for creating or updating a property definition + properties: + name: + type: string + description: Property name + example: applicationName + minLength: 1 + description: + type: string + description: Property description + example: Name of the application + minLength: 1 + type: + type: string + description: Property data type + enum: + - STRING + - NUMBER + - BOOLEAN + example: STRING + required: + type: boolean + description: Whether this property is required + example: true + default: false + rules: + $ref: '#/components/schemas/PropertyRulesDtoIn' + description: Property validation rules + required: + - description + - name + - type + PropertyRulesDtoIn: + type: object + description: Input DTO for creating or updating a property definition + properties: + format: + type: string + description: Property format validation + enum: + - URL + - EMAIL + example: EMAIL + enum_values: + type: array + description: Enumeration values for enum properties + example: + - ACTIVE + - INACTIVE + items: + type: string + regex: + type: string + description: Regular expression pattern for validation + 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 + example: 1 + 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 + example: 0 + RelationDefinitionDtoIn: + type: object + description: Input DTO for creating or updating a relation definition + properties: + name: + type: string + description: Name of the relation + example: dependencies + minLength: 1 + target_template_identifier: + type: string + description: Identifier of the target template + example: service + minLength: 1 + required: + type: boolean + description: Whether this relation is required + example: false + default: false + to_many: + type: boolean + description: Whether this relation can have multiple targets + example: true + default: false + required: + - name + - target_template_identifier + EntityTemplateDtoOut: + type: object + description: Output for entity template + properties: + identifier: + type: string + description: Unique Entity Template identifier + example: service + name: + type: string + description: Unique Entity Template name + example: Service + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + $ref: '#/components/schemas/PropertyDefinitionDtoOut' + relations_definitions: + type: array + description: List of relation definitions for this template + items: + $ref: '#/components/schemas/RelationDefinitionDtoOut' + PropertyDefinitionDtoOut: + type: object + description: Output DTO for property definition + properties: + name: + type: string + description: Property name + example: applicationName + description: + type: string + description: Property description + example: Name of the application + type: + type: string + description: Property data type + enum: + - STRING + - NUMBER + - BOOLEAN + example: STRING + required: + type: boolean + description: Whether this property is required + example: true + rules: + $ref: '#/components/schemas/PropertyRulesDtoOut' + description: Property validation rules + example: Property validation rules + PropertyRulesDtoOut: + type: object + description: Output DTO for property validation rules + properties: + format: + type: string + description: Format of the property + enum: + - URL + - EMAIL + example: STRING + enum_values: + type: array + description: Allowed enum values for the property + example: + - VALUE1 + - VALUE2 + items: + type: string + regex: + type: string + description: Regular expression for property validation + example: ^[A-Za-z0-9]+$ + max_length: + type: integer + format: int32 + description: Maximum length of the property + example: 255 + min_length: + type: integer + format: int32 + description: Minimum length of the property + example: 1 + max_value: + type: integer + format: int32 + description: Maximum value for the property + example: 100 + min_value: + type: integer + format: int32 + description: Minimum value for the property + example: 0 + RelationDefinitionDtoOut: + type: object + description: Output DTO for relation definition + properties: + name: + type: string + description: Name of the relation + example: dependencies + target_template_identifier: + type: string + description: Identifier of the target template + example: component-template + required: + type: boolean + description: Whether this relation is required + example: false + to_many: + type: boolean + description: Whether this relation can have multiple targets + example: true + ErrorResponse: + type: object + properties: + error: + type: string + errorDescription: + type: string + EntityTemplateCreateDtoIn: + type: object + description: Input DTO for creating an entity template + properties: + identifier: + type: string + description: Unique Entity Template identifier + example: service + minLength: 1 + name: + type: string + description: Unique Entity Template name + example: Service + maxLength: 255 + minLength: 0 + pattern: ^[a-zA-Z0-9 _-]+$ + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + $ref: '#/components/schemas/PropertyDefinitionDtoIn' + relations_definitions: + type: array + description: List of relation definitions for this template + items: + $ref: '#/components/schemas/RelationDefinitionDtoIn' + required: + - identifier + - name + EntityDtoIn: + type: object + description: Input DTO for creating or updating an entity + properties: + name: + type: string + description: Name of the entity + example: my-web-service + minLength: 1 + identifier: + type: string + description: Unique identifier of the entity within the template scope + example: my-web-service + minLength: 1 + properties: + type: object + additionalProperties: + type: string + description: Map of property name to value for this entity + example: + port: '8080' + environment: dev + relations: + type: array + description: List of relations for this entity + items: + $ref: '#/components/schemas/RelationDtoIn' + required: + - identifier + - name + RelationDtoIn: + type: object + description: Input DTO for an entity relation instance + properties: + name: + type: string + description: Name of the relation (must match a template relation definition) + example: depends-on + minLength: 1 + target_entity_identifiers: + type: array + description: List of target entity identifiers for this relation + example: + - web-api-1 + - web-api-2 + items: + type: string + required: + - name + - target_entity_identifiers + EntityDtoOut: + type: object + properties: + template_identifier: + type: string + name: + type: string + identifier: + type: string + properties: + type: object + additionalProperties: {} + relations: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + relations_as_target: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + EntitySummaryDto: + type: object + properties: + identifier: + type: string + name: + type: string + PageableObject: + type: object + properties: + offset: + type: integer + format: int64 + paged: + type: boolean + pageNumber: + type: integer + format: int32 + pageSize: + type: integer + format: int32 + sort: + $ref: '#/components/schemas/SortObject' + unpaged: + type: boolean + SortObject: + type: object + properties: + empty: + type: boolean + sorted: + type: boolean + unsorted: + type: boolean + TemplatePageResponse: + type: object + description: Paginated response containing Template objects + properties: + content: + type: array + items: + $ref: '#/components/schemas/EntityTemplateDtoOut' + pageable: + $ref: '#/components/schemas/PageableObject' + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + last: + type: boolean + size: + type: integer + format: int32 + number: + type: integer + format: int32 + sort: + $ref: '#/components/schemas/SortObject' + first: + type: boolean + numberOfElements: + type: integer + format: int32 + empty: + type: boolean + EntityPageResponse: + type: object + description: Paginated response containing Entity objects + properties: + content: + type: array + items: + $ref: '#/components/schemas/EntityDtoOut' + pageable: + $ref: '#/components/schemas/PageableObject' + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + last: + type: boolean + size: + type: integer + format: int32 + number: + type: integer + format: int32 + sort: + $ref: '#/components/schemas/SortObject' + first: + type: boolean + numberOfElements: + type: integer + format: int32 + empty: + type: boolean + securitySchemes: + clientId: + type: oauth2 + description: clientId authentication + name: clientId + flows: + clientCredentials: + tokenUrl: http://localhost:8080/auth/token + bearer: + type: http + description: bearer authentication + name: bearer + scheme: bearer + bearerFormat: JWT diff --git a/specs/swagger-extracted.yaml b/specs/swagger-extracted.yaml new file mode 100644 index 0000000..5b724e5 --- /dev/null +++ b/specs/swagger-extracted.yaml @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Idp core API","description":"API dedicated to idp core functionalities","version":"v1"},"servers":[{"url":"http://localhost:8084"}],"security":[{"clientId":[]},{"bearer":[]}],"tags":[{"name":"Entities Management","description":"Operations related to entity management"},{"name":"Entities Templates Management","description":"Operations related to entity template management"}],"paths":{"/api/v1/entity-templates/{identifier}":{"get":{"tags":["Entities Templates Management"],"summary":"Get template by identifier","description":"Retrieve a specific template using its string identifier","operationId":"getTemplateByIdentifier","parameters":[{"name":"identifier","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Template found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityTemplateDtoOut"}}}},"404":{"description":"Template not found with the provided identifier","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"put":{"tags":["Entities Templates Management"],"summary":"Update an existing template by template identifier","description":"Update the details of an existing template identified by its unique string identifier","operationId":"updateTemplate","parameters":[{"name":"identifier","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityTemplateUpdateDtoIn"}}},"required":true},"responses":{"200":{"description":"Template update successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityTemplateDtoOut"}}}},"400":{"description":"Invalid template data provided","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized - Missing or invalid token"},"403":{"description":"Insufficient rights"},"404":{"description":"Template not found with the provided identifier","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Template with this identifier already exists","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Unexpected server-side failure","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["Entities Templates Management"],"summary":"Delete template by identifier","description":"Remove a template from the system using its unique identifier","operationId":"deleteTemplate","parameters":[{"name":"identifier","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Template deleted successfully"},"404":{"description":"Template not found with the provided identifier","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}":{"get":{"tags":["Entities Management"],"summary":"Get entity by entity template and identifier","description":"Retrieve a specific entity using its string identifier and its template identifier","operationId":"getEntity","parameters":[{"name":"templateIdentifier","in":"path","required":true,"schema":{"type":"string"}},{"name":"entityIdentifier","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Entity found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityDtoOut"}}}},"404":{"description":"Entity not found with the provided identifier","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"put":{"tags":["Entities Management"],"summary":"Update an existing entity","description":"Update an existing entity in the system with the provided information","operationId":"updateEntity","parameters":[{"name":"templateIdentifier","in":"path","required":true,"schema":{"type":"string","minLength":1}},{"name":"entityIdentifier","in":"path","required":true,"schema":{"type":"string","minLength":1}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityDtoIn"}}},"required":true},"responses":{"200":{"description":"Entity updated successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityDtoOut"}}}},"400":{"description":"Invalid entity data provided","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized - Missing or invalid token"},"403":{"description":"Insufficient rights"},"404":{"description":"Entity not found with the provided identifier","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Unexpected server-side failure","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/entity-templates":{"get":{"tags":["Entities Templates Management"],"summary":"Get paginated templates","description":"Retrieve a paginated list of templates with optional sorting","operationId":"getTemplatesPaginated","parameters":[{"name":"page","in":"query","description":"Page number for pagination. Defaults to 0.","content":{"*/*":{"schema":{"type":"integer","default":"0"}}}},{"name":"size","in":"query","description":"Number of items per page. Defaults to 20.","content":{"*/*":{"schema":{"type":"integer","default":"20"}}}},{"name":"sort","in":"query","description":"Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.","content":{"*/*":{"schema":{"type":"string","default":"identifier,asc"}}}}],"responses":{"200":{"description":"Paginated templates retrieved successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TemplatePageResponse"}}}},"400":{"description":"Invalid pagination parameters","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["Entities Templates Management"],"summary":"Create a new template","description":"Create a new template in the system with the provided information","operationId":"createTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityTemplateCreateDtoIn"}}},"required":true},"responses":{"201":{"description":"Template created successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityTemplateDtoOut"}}}},"400":{"description":"Invalid template data provided","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/entities/{templateIdentifier}":{"get":{"tags":["Entities Management"],"summary":"Get entities by template identifier","description":"Retrieve a paginated list of entities with optional sorting","operationId":"getEntities","parameters":[{"name":"page","in":"query","description":"Page number for pagination. Defaults to 0.","required":false,"content":{"*/*":{"schema":{"type":"integer","default":"0"}}}},{"name":"size","in":"query","description":"Number of items per page. Defaults to 20.","required":false,"content":{"*/*":{"schema":{"type":"integer","default":"20"}}}},{"name":"templateIdentifier","in":"path","required":true,"schema":{"type":"string"}},{"name":"sort","in":"query","description":"Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.","content":{"*/*":{"schema":{"type":"string","default":"identifier,asc"}}}}],"responses":{"200":{"description":"Paginated entities retrieved successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityPageResponse"}}}},"400":{"description":"Invalid pagination parameters","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["Entities Management"],"summary":"Create a new entity","description":"Create a new entity in the system with the provided information","operationId":"createEntity","parameters":[{"name":"templateIdentifier","in":"path","required":true,"schema":{"type":"string","minLength":1}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityDtoIn"}}},"required":true},"responses":{"201":{"description":"Entity created successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/EntityDtoOut"}}}},"400":{"description":"Invalid entity data provided","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized - Missing or invalid token"},"403":{"description":"Insufficient rights"},"409":{"description":"Entity already exists in this template","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Template not found with the provided identifier","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Unexpected server-side failure","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}},"components":{"schemas":{"EntityTemplateUpdateDtoIn":{"type":"object","description":"Input DTO for updating an entity template","properties":{"name":{"type":"string","description":"Unique Entity Template name","example":"Service","maxLength":255,"minLength":0,"pattern":"^[a-zA-Z0-9 _-]+$"},"description":{"type":"string","description":"Entity Template description","example":"A comprehensive service template"},"properties_definitions":{"type":"array","description":"List of property definitions for this template","items":{"$ref":"#/components/schemas/PropertyDefinitionDtoIn"}},"relations_definitions":{"type":"array","description":"List of relation definitions for this template","items":{"$ref":"#/components/schemas/RelationDefinitionDtoIn"}}},"required":["name"]},"PropertyDefinitionDtoIn":{"type":"object","description":"Input DTO for creating or updating a property definition","properties":{"name":{"type":"string","description":"Property name","example":"applicationName","minLength":1},"description":{"type":"string","description":"Property description","example":"Name of the application","minLength":1},"type":{"type":"string","description":"Property data type","enum":["STRING","NUMBER","BOOLEAN"],"example":"STRING"},"required":{"type":"boolean","default":false,"description":"Whether this property is required","example":true},"rules":{"$ref":"#/components/schemas/PropertyRulesDtoIn","description":"Property validation rules"}},"required":["description","name","type"]},"PropertyRulesDtoIn":{"type":"object","description":"Input DTO for creating or updating a property definition","properties":{"format":{"type":"string","description":"Property format validation","enum":["URL","EMAIL"],"example":"EMAIL"},"enum_values":{"type":"array","description":"Enumeration values for enum properties","example":["ACTIVE","INACTIVE"],"items":{"type":"string"}},"regex":{"type":"string","description":"Regular expression pattern for validation","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","example":1},"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","example":0}}},"RelationDefinitionDtoIn":{"type":"object","description":"Input DTO for creating or updating a relation definition","properties":{"name":{"type":"string","description":"Name of the relation","example":"dependencies","minLength":1},"target_template_identifier":{"type":"string","description":"Identifier of the target template","example":"service","minLength":1},"required":{"type":"boolean","default":false,"description":"Whether this relation is required","example":false},"to_many":{"type":"boolean","default":false,"description":"Whether this relation can have multiple targets","example":true}},"required":["name","target_template_identifier"]},"EntityTemplateDtoOut":{"type":"object","description":"Output for entity template","properties":{"identifier":{"type":"string","description":"Unique Entity Template identifier","example":"service"},"name":{"type":"string","description":"Unique Entity Template name","example":"Service"},"description":{"type":"string","description":"Entity Template description","example":"A comprehensive service template"},"properties_definitions":{"type":"array","description":"List of property definitions for this template","items":{"$ref":"#/components/schemas/PropertyDefinitionDtoOut"}},"relations_definitions":{"type":"array","description":"List of relation definitions for this template","items":{"$ref":"#/components/schemas/RelationDefinitionDtoOut"}}}},"PropertyDefinitionDtoOut":{"type":"object","description":"Output DTO for property definition","properties":{"name":{"type":"string","description":"Property name","example":"applicationName"},"description":{"type":"string","description":"Property description","example":"Name of the application"},"type":{"type":"string","description":"Property data type","enum":["STRING","NUMBER","BOOLEAN"],"example":"STRING"},"required":{"type":"boolean","description":"Whether this property is required","example":true},"rules":{"$ref":"#/components/schemas/PropertyRulesDtoOut","description":"Property validation rules","example":"Property validation rules"}}},"PropertyRulesDtoOut":{"type":"object","description":"Output DTO for property validation rules","properties":{"format":{"type":"string","description":"Format of the property","enum":["URL","EMAIL"],"example":"STRING"},"enum_values":{"type":"array","description":"Allowed enum values for the property","example":["VALUE1","VALUE2"],"items":{"type":"string"}},"regex":{"type":"string","description":"Regular expression for property validation","example":"^[A-Za-z0-9]+$"},"max_length":{"type":"integer","format":"int32","description":"Maximum length of the property","example":255},"min_length":{"type":"integer","format":"int32","description":"Minimum length of the property","example":1},"max_value":{"type":"integer","format":"int32","description":"Maximum value for the property","example":100},"min_value":{"type":"integer","format":"int32","description":"Minimum value for the property","example":0}}},"RelationDefinitionDtoOut":{"type":"object","description":"Output DTO for relation definition","properties":{"name":{"type":"string","description":"Name of the relation","example":"dependencies"},"target_template_identifier":{"type":"string","description":"Identifier of the target template","example":"component-template"},"required":{"type":"boolean","description":"Whether this relation is required","example":false},"to_many":{"type":"boolean","description":"Whether this relation can have multiple targets","example":true}}},"ErrorResponse":{"type":"object","properties":{"error":{"type":"string"},"errorDescription":{"type":"string"}}},"EntityDtoIn":{"type":"object","description":"Input DTO for creating or updating an entity","properties":{"name":{"type":"string","description":"Name of the entity","example":"my-web-service","minLength":1},"identifier":{"type":"string","description":"Unique identifier of the entity within the template scope","example":"my-web-service","minLength":1},"properties":{"type":"object","additionalProperties":{"type":"string"},"description":"Map of property name to value for this entity","example":{"port":"8080","environment":"dev"}},"relations":{"type":"array","description":"List of relations for this entity","items":{"$ref":"#/components/schemas/RelationDtoIn"}}},"required":["identifier","name"]},"RelationDtoIn":{"type":"object","description":"Input DTO for an entity relation instance","properties":{"name":{"type":"string","description":"Name of the relation (must match a template relation definition)","example":"depends-on","minLength":1},"target_entity_identifiers":{"type":"array","description":"List of target entity identifiers for this relation","example":["web-api-1","web-api-2"],"items":{"type":"string"}}},"required":["name","target_entity_identifiers"]},"EntityDtoOut":{"type":"object","properties":{"template_identifier":{"type":"string"},"name":{"type":"string"},"identifier":{"type":"string"},"properties":{"type":"object","additionalProperties":{}},"relations":{"type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/EntitySummaryDto"}}},"relations_as_target":{"type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/EntitySummaryDto"}}}}},"EntitySummaryDto":{"type":"object","properties":{"identifier":{"type":"string"},"name":{"type":"string"}}},"EntityTemplateCreateDtoIn":{"type":"object","description":"Input DTO for creating an entity template","properties":{"identifier":{"type":"string","description":"Unique Entity Template identifier","example":"service","minLength":1},"name":{"type":"string","description":"Unique Entity Template name","example":"Service","maxLength":255,"minLength":0,"pattern":"^[a-zA-Z0-9 _-]+$"},"description":{"type":"string","description":"Entity Template description","example":"A comprehensive service template"},"properties_definitions":{"type":"array","description":"List of property definitions for this template","items":{"$ref":"#/components/schemas/PropertyDefinitionDtoIn"}},"relations_definitions":{"type":"array","description":"List of relation definitions for this template","items":{"$ref":"#/components/schemas/RelationDefinitionDtoIn"}}},"required":["identifier","name"]},"PageableObject":{"type":"object","properties":{"offset":{"type":"integer","format":"int64"},"paged":{"type":"boolean"},"pageNumber":{"type":"integer","format":"int32"},"pageSize":{"type":"integer","format":"int32"},"sort":{"$ref":"#/components/schemas/SortObject"},"unpaged":{"type":"boolean"}}},"SortObject":{"type":"object","properties":{"empty":{"type":"boolean"},"sorted":{"type":"boolean"},"unsorted":{"type":"boolean"}}},"TemplatePageResponse":{"type":"object","description":"Paginated response containing Template objects","properties":{"content":{"type":"array","items":{"$ref":"#/components/schemas/EntityTemplateDtoOut"}},"pageable":{"$ref":"#/components/schemas/PageableObject"},"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"last":{"type":"boolean"},"size":{"type":"integer","format":"int32"},"number":{"type":"integer","format":"int32"},"sort":{"$ref":"#/components/schemas/SortObject"},"first":{"type":"boolean"},"numberOfElements":{"type":"integer","format":"int32"},"empty":{"type":"boolean"}}},"EntityPageResponse":{"type":"object","description":"Paginated response containing Entity objects","properties":{"content":{"type":"array","items":{"$ref":"#/components/schemas/EntityDtoOut"}},"pageable":{"$ref":"#/components/schemas/PageableObject"},"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"last":{"type":"boolean"},"size":{"type":"integer","format":"int32"},"number":{"type":"integer","format":"int32"},"sort":{"$ref":"#/components/schemas/SortObject"},"first":{"type":"boolean"},"numberOfElements":{"type":"integer","format":"int32"},"empty":{"type":"boolean"}}}},"securitySchemes":{"clientId":{"type":"oauth2","description":"clientId authentication","name":"clientId","flows":{"clientCredentials":{"tokenUrl":"http://localhost:8080/auth/token"}}},"bearer":{"type":"http","description":"bearer authentication","name":"bearer","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index a4805d2..a37a6ee 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -59,7 +59,7 @@ void validateForCreation(Entity entity, EntityTemplate template) { /// @param template the already-resolved template the entity must conform to /// @throws EntityValidationException when one or more validation rules are violated void validateForUpdate(Entity entity, EntityTemplate template) { - validateAgainstTemplate(template, entity.properties()); + validateAgainstTemplate(template, entity); } /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. 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 479a99a..f5f9956 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 @@ -171,7 +171,7 @@ public EntityDtoOut createEntity( @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PutMapping("/{templateIdentifier}/identifier/{entityIdentifier}") + @PutMapping("/{templateIdentifier}/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut updateEntity( @NotBlank @PathVariable String templateIdentifier, From f945249a6645a44d2634d3d8a1449c56c4920f4c Mon Sep 17 00:00:00 2001 From: evebrnd Date: Thu, 21 May 2026 13:47:40 +0200 Subject: [PATCH 46/51] feat: use pg_trgm extension --- docker-compose.yml | 2 + scripts/init-extensions.sql | 3 + .../EntitySearchSpecification.java | 54 ++++++++--------- .../V3_5__add_search_performance_indexes.sql | 58 +++++++++++++++---- .../idp_core/AbstractIntegrationTest.java | 3 +- .../resources/db/init/init-extensions.sql | 3 + 6 files changed, 86 insertions(+), 37 deletions(-) create mode 100644 scripts/init-extensions.sql create mode 100644 src/test/resources/db/init/init-extensions.sql diff --git a/docker-compose.yml b/docker-compose.yml index 139089d..13f7784 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-idpcore_password} ports: - "5437:5432" + volumes: + - ./scripts/init-extensions.sql:/docker-entrypoint-initdb.d/01-init-extensions.sql:ro networks: - postgres restart: unless-stopped diff --git a/scripts/init-extensions.sql b/scripts/init-extensions.sql new file mode 100644 index 0000000..131be46 --- /dev/null +++ b/scripts/init-extensions.sql @@ -0,0 +1,3 @@ +-- Initialize PostgreSQL extensions required by the application. +-- This script runs once on first container startup (docker-entrypoint-initdb.d). +CREATE EXTENSION IF NOT EXISTS pg_trgm; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java index eb13dd5..189cd32 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -40,10 +40,10 @@ /// significantly cheaper. /// /// **Security:** LIKE-based operators ([SearchOperator#CONTAINS], [SearchOperator#NOT_CONTAINS], -/// [SearchOperator#STARTS_WITH], [SearchOperator#ENDS_WITH]) use `LOWER() LIKE lower_value` -/// for case-insensitive matching. SQL wildcards (`%` and `_`) in user-supplied values are -/// escaped to prevent unintended pattern matching. EQ and NEQ also use `LOWER()` with -/// functional btree indexes. STARTS_WITH benefits from btree index prefix scans. +/// [SearchOperator#STARTS_WITH], [SearchOperator#ENDS_WITH]) use PostgreSQL `ILIKE` for +/// case-insensitive matching, allowing GIN trigram indexes (V3_5) to be leveraged. +/// SQL wildcards (`%` and `_`) in user-supplied values are escaped to prevent unintended +/// pattern matching. EQ and NEQ use `LOWER()` with functional btree indexes (V3_4). @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class EntitySearchSpecification { @@ -71,22 +71,23 @@ public static Specification of(SearchFilterNode filter) { /// /// The four conditions are combined with OR so that a match on any field is sufficient. /// The "any property" branch uses a correlated EXISTS subquery to avoid row multiplication. - /// All comparisons use `LOWER() LIKE lower_pattern` for case-insensitive substring matching. + /// All comparisons use `ILIKE` so that GIN trigram indexes (V3_5) can be leveraged. /// /// @param query the search string; must be non-null and non-blank /// @return a [Specification] implementing the global text search public static Specification globalTextSearch(String query) { - String escaped = escapeLikeWildcards(query.toLowerCase()); + // No toLowerCase() needed — ILIKE is inherently case-insensitive. + String escaped = escapeLikeWildcards(query); String pattern = "%" + escaped + "%"; Specification byIdentifier = - (root, q, cb) -> cb.like(cb.lower(root.get(IDENTIFIER)), pattern, LIKE_ESCAPE_CHAR); + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(IDENTIFIER), pattern, LIKE_ESCAPE_CHAR); Specification byName = - (root, q, cb) -> cb.like(cb.lower(root.get(NAME)), pattern, LIKE_ESCAPE_CHAR); + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(NAME), pattern, LIKE_ESCAPE_CHAR); Specification byTemplate = - (root, q, cb) -> cb.like(cb.lower(root.get(TEMPLATE_IDENTIFIER)), pattern, LIKE_ESCAPE_CHAR); + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(TEMPLATE_IDENTIFIER), pattern, LIKE_ESCAPE_CHAR); Specification byAnyProperty = (root, queryCtx, cb) -> { // Correlated EXISTS: does this entity have at least one property whose value matches? @@ -96,7 +97,7 @@ public static Specification globalTextSearch(String query) { sub.select(cb.literal(1)) .where( cb.equal(subRoot.get("id"), root.get("id")), - cb.like(cb.lower(propJoin.get("value").as(String.class)), pattern, LIKE_ESCAPE_CHAR) + ((HibernateCriteriaBuilder) cb).ilike(propJoin.get("value").as(String.class), pattern, LIKE_ESCAPE_CHAR) ); return cb.exists(sub); }; @@ -285,29 +286,29 @@ private static Predicate buildPredicate( if (isNumericOperator(operator)) { return buildNumericPredicate(cb, field, operator, new BigDecimal(value)); } - Expression lowerField = cb.lower(field.as(String.class)); + HibernateCriteriaBuilder hcb = (HibernateCriteriaBuilder) cb; + Expression stringField = field.as(String.class); return switch (operator) { - // EQ / NEQ use lower() + functional btree index for optimal equality matching. - case EQ -> cb.equal(lowerField, value.toLowerCase()); - case NEQ -> cb.notEqual(lowerField, value.toLowerCase()); - // LIKE operators apply LOWER() on the column and lowercase the value. - // STARTS_WITH can leverage the functional btree lower() index (prefix scan). - // CONTAINS and ENDS_WITH use sequential scans as no extension-free index supports leading wildcards. + // EQ / NEQ use lower() + functional btree index (V3_4) for optimal equality matching. + case EQ -> cb.equal(cb.lower(stringField), value.toLowerCase()); + case NEQ -> cb.notEqual(cb.lower(stringField), value.toLowerCase()); + // LIKE operators use ILIKE so that GIN trigram indexes (V3_5) can be leveraged. + // No pre-lowercasing of the value — ILIKE is inherently case-insensitive. case CONTAINS -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.like(lowerField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); } case NOT_CONTAINS -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.notLike(lowerField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value); + yield hcb.notIlike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); } case STARTS_WITH -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.like(lowerField, escaped + "%", LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, escaped + "%", LIKE_ESCAPE_CHAR); } case ENDS_WITH -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.like(lowerField, "%" + escaped, LIKE_ESCAPE_CHAR); + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, "%" + escaped, LIKE_ESCAPE_CHAR); } default -> throw new IllegalStateException("Unhandled operator: " + operator); }; @@ -342,7 +343,8 @@ private static Predicate buildNumericPredicate( /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are /// treated as literal characters rather than pattern metacharacters. - /// The value should be pre-lowercased before passing to LIKE-based operators. + /// Used by ILIKE-based operators. The value does not need to be pre-lowercased + /// as ILIKE handles case-insensitivity natively. static String escapeLikeWildcards(String value) { return value .replace(String.valueOf(LIKE_ESCAPE_CHAR), LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) diff --git a/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql b/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql index 4bc4e4f..21aa28e 100644 --- a/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql +++ b/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql @@ -1,12 +1,18 @@ -- Flyway migration script: add search performance indexes --- Purpose: Support the EXISTS-based search specifications and LOWER()-based operators --- with index scans instead of full table scans. +-- Purpose: Accelerate the search endpoint with GIN trigram indexes (ILIKE pattern matching) +-- and functional btree indexes (EQ/NEQ equality matching). -- -- Strategy: --- - Btree functional lower(col) indexes -> EQ / NEQ / STARTS_WITH comparisons using LOWER() --- - Btree indexes on relation columns -> exact equality lookups in EXISTS subqueries +-- - GIN trigram indexes (public.gin_trgm_ops) on raw columns → ILIKE with CONTAINS, ENDS_WITH, +-- STARTS_WITH, NOT_CONTAINS operators and globalTextSearch. Operator class is schema-qualified +-- because the application connection uses search_path = idp_core which does not include public. +-- - Functional btree lower(col) indexes → EQ / NEQ comparisons using LOWER(col) +-- - Btree indexes on relation columns → exact equality lookups in EXISTS subqueries +-- - The pg_trgm extension is managed by infrastructure — no CREATE EXTENSION here. --- Relation indexes +-- ========================================================================= +-- Relation Indexes +-- ========================================================================= -- Exact equality on relation name (used in all relation EXISTS subqueries) CREATE INDEX idx_relation_name @@ -20,9 +26,17 @@ CREATE INDEX idx_relation_target_entities_identifier COMMENT ON INDEX idx_relation_target_entities_identifier IS 'Supports reverse relation lookups by target entity identifier'; --- Entity indexes +-- GIN trigram index for ILIKE-based relation name searches (CONTAINS, STARTS_WITH, ENDS_WITH) +CREATE INDEX idx_relation_name_trgm + ON relation USING GIN (name public.gin_trgm_ops); --- Functional btree indexes for EQ / NEQ / STARTS_WITH which use LOWER(col) +COMMENT ON INDEX idx_relation_name_trgm IS 'GIN trigram index for ILIKE pattern matching on relation name'; + +-- ========================================================================= +-- Entity Indexes +-- ========================================================================= + +-- Functional btree indexes for EQ / NEQ which use LOWER(col) CREATE INDEX idx_entity_name_lower ON entity (lower(name)); @@ -32,6 +46,30 @@ CREATE INDEX idx_entity_identifier_lower CREATE INDEX idx_entity_template_identifier_lower ON entity (lower(template_identifier)); -COMMENT ON INDEX idx_entity_name_lower IS 'Supports LOWER(name) comparisons for EQ, NEQ, and STARTS_WITH operators'; -COMMENT ON INDEX idx_entity_identifier_lower IS 'Supports LOWER(identifier) comparisons for EQ, NEQ, and STARTS_WITH operators'; -COMMENT ON INDEX idx_entity_template_identifier_lower IS 'Supports LOWER(template_identifier) comparisons for EQ, NEQ, and STARTS_WITH operators'; +COMMENT ON INDEX idx_entity_name_lower IS 'Supports LOWER(name) comparisons for EQ and NEQ operators'; +COMMENT ON INDEX idx_entity_identifier_lower IS 'Supports LOWER(identifier) comparisons for EQ and NEQ operators'; +COMMENT ON INDEX idx_entity_template_identifier_lower IS 'Supports LOWER(template_identifier) comparisons for EQ and NEQ operators'; + +-- GIN trigram indexes for ILIKE-based entity field searches +CREATE INDEX idx_entity_name_trgm + ON entity USING GIN (name public.gin_trgm_ops); + +CREATE INDEX idx_entity_identifier_trgm + ON entity USING GIN (identifier public.gin_trgm_ops); + +CREATE INDEX idx_entity_template_identifier_trgm + ON entity USING GIN (template_identifier public.gin_trgm_ops); + +COMMENT ON INDEX idx_entity_name_trgm IS 'GIN trigram index for ILIKE pattern matching on entity name'; +COMMENT ON INDEX idx_entity_identifier_trgm IS 'GIN trigram index for ILIKE pattern matching on entity identifier'; +COMMENT ON INDEX idx_entity_template_identifier_trgm IS 'GIN trigram index for ILIKE pattern matching on entity template identifier'; + +-- ========================================================================= +-- Property Indexes +-- ========================================================================= + +-- GIN trigram index for ILIKE-based property value searches +CREATE INDEX idx_property_value_trgm + ON property USING GIN (value public.gin_trgm_ops); + +COMMENT ON INDEX idx_property_value_trgm IS 'GIN trigram index for ILIKE pattern matching on property value'; diff --git a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java index a0a35cf..8142739 100644 --- a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java +++ b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java @@ -91,7 +91,8 @@ protected AbstractIntegrationTest() { @Container @SuppressWarnings("rawtypes") private static final JdbcDatabaseContainer postgres = new PostgreSQLContainer("postgres:18-alpine") - .withDatabaseName("idp-core").withUsername("idp-core").withPassword("idp-core"); + .withDatabaseName("idp-core").withUsername("idp-core").withPassword("idp-core") + .withInitScript("db/init/init-extensions.sql"); @DynamicPropertySource static void postgresProperties(DynamicPropertyRegistry registry) { diff --git a/src/test/resources/db/init/init-extensions.sql b/src/test/resources/db/init/init-extensions.sql new file mode 100644 index 0000000..144c6ba --- /dev/null +++ b/src/test/resources/db/init/init-extensions.sql @@ -0,0 +1,3 @@ +-- Initialize PostgreSQL extensions required by the application. +-- This script runs once when the Testcontainers PostgreSQL container starts. +CREATE EXTENSION IF NOT EXISTS pg_trgm; From b5ca22d6575c9fa51217403d7b49eee8a11d13d1 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Thu, 21 May 2026 17:00:25 +0200 Subject: [PATCH 47/51] fix: empty files --- .../decathlon/idp_core/domain/model/entity/EntityGraphNode.java | 0 .../idp_core/domain/model/entity/EntityGraphRelation.java | 0 .../adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java | 0 .../adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java | 0 .../adapters/api/mapper/entity/EntityGraphDtoOutMapper.java | 0 5 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java delete mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java deleted file mode 100644 index e69de29..0000000 From a57ffc8a827013bcc7e90cb31edbcd69a348d9fe Mon Sep 17 00:00:00 2001 From: evebrnd Date: Fri, 22 May 2026 10:49:19 +0200 Subject: [PATCH 48/51] feat: add filtering on relations_as_target presence --- .../domain/constant/ValidationMessages.java | 2 +- .../domain/model/entity/SearchFilterNode.java | 1 + .../entity/EntitySearchDomainMapper.java | 2 +- .../EntitySearchSpecification.java | 30 +++++++++++++++++ .../api/controller/EntityControllerTest.java | 33 +++++++++++++++++++ .../mapper/EntitySearchDomainMapperTest.java | 6 ++++ .../EntitySearchSpecificationTest.java | 6 ++++ ...h_request_relations_as_target_absence.json | 11 +++++++ ..._request_relations_as_target_presence.json | 11 +++++++ 9 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target_absence.json create mode 100644 src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target_presence.json 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 cc622fd..59789d8 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 @@ -89,7 +89,7 @@ public static String minMaxConstraintViolated(String constraint) { // Search filter validation messages public static final String SEARCH_INVALID_CONNECTOR = "Invalid connector '%s'. Supported values: AND, OR"; public static final String SEARCH_INVALID_OPERATOR = "Invalid operation '%s'. Supported values: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE"; - public static final String SEARCH_INVALID_FIELD = "Unknown field '%s'. Supported fields: template, identifier, name, relation, property.{name}, relation.{name}, relation.{name}.identifier, relation.{name}.name, relations_as_target.{name}.identifier, relations_as_target.{name}.name"; + public static final String SEARCH_INVALID_FIELD = "Unknown field '%s'. Supported fields: template, identifier, name, relation, property.{name}, relation.{name}, relation.{name}.identifier, relation.{name}.name, relations_as_target, relations_as_target.{name}.identifier, relations_as_target.{name}.name"; public static final String SEARCH_TOO_MANY_CRITERIA = "Search filter exceeds maximum of %d total criteria"; public static final String SEARCH_NESTING_TOO_DEEP = "Search filter exceeds maximum nesting depth of %d"; public static final String SEARCH_CRITERION_MISSING_FIELD = "A criterion node must have a non-blank 'field'"; diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java index 7c129df..2aee81b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java @@ -24,6 +24,7 @@ /// - `relation.{name}` — filters by target entity identifier of a named relation /// - `relation.{name}.identifier` — explicit form of the above /// - `relation.{name}.name` — filters by target entity name of a named relation +/// - `relations_as_target` — filters by the presence or absence of any reverse relation by name /// - `relations_as_target.{name}.identifier` — filters by source entity identifier in a reverse relation /// - `relations_as_target.{name}.name` — filters by source entity name in a reverse relation public sealed interface SearchFilterNode { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntitySearchDomainMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntitySearchDomainMapper.java index 6adb29a..6c58f90 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntitySearchDomainMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntitySearchDomainMapper.java @@ -34,7 +34,7 @@ public class EntitySearchDomainMapper { private static final String PROPERTY_PREFIX = "property."; private static final String RELATION_PREFIX = "relation."; private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; - private static final Set SIMPLE_FIELDS = Set.of("template", "identifier", "name", "relation"); + private static final Set SIMPLE_FIELDS = Set.of("template", "identifier", "name", "relation", "relations_as_target"); private static final Set NUMERIC_OPERATORS = Set.of(SearchOperator.GT, SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java index 189cd32..5504530 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -53,6 +53,7 @@ public final class EntitySearchSpecification { private static final String NAME = "name"; private static final String RELATION = "relation"; private static final String RELATIONS = "relations"; + private static final String RELATIONS_AS_TARGET = "relations_as_target"; private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; private static final String PROPERTY_PREFIX = "property."; private static final String RELATION_PREFIX = "relation."; @@ -145,6 +146,9 @@ private static Specification buildCriterion(SearchFilterNode.Cr if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { return relationsAsTargetSpec(c, field.substring(RELATIONS_AS_TARGET_PREFIX.length())); } + if (RELATIONS_AS_TARGET.equals(field)) { + return relationsAsTargetNameSpec(c); + } if (RELATION.equals(field)) { return relationNameSpec(c); } @@ -249,6 +253,32 @@ private static Specification relationPropertySpec( // --- Relations-as-target specs --- + private static Specification relationsAsTargetNameSpec(SearchFilterNode.Criterion c) { + return (root, query, cb) -> { + // Subquery: collect all target entity identifiers from relations whose name matches. + // For NOT_CONTAINS / NEQ (negative operators): use NOT IN with the positive equivalent + // predicate so that the result means "not targeted by any matching reverse relation", + // which is the natural set-membership interpretation of "does not contain". + SearchOperator effectiveOp = switch (c.operation()) { + case NOT_CONTAINS -> SearchOperator.CONTAINS; + case NEQ -> SearchOperator.EQ; + default -> c.operation(); + }; + + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where(buildPredicate(cb, relJoin.get(NAME), effectiveOp, c.value())); + + boolean isNegated = c.operation() == SearchOperator.NOT_CONTAINS + || c.operation() == SearchOperator.NEQ; + var membership = cb.in(root.get(IDENTIFIER)).value(subquery); + return isNegated ? cb.not(membership) : membership; + }; + } + private static Specification relationsAsTargetSpec( SearchFilterNode.Criterion c, String relPart) { int dotIndex = relPart.indexOf('.'); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 356141c..97eb0ca 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -478,6 +480,37 @@ void search_200_byRelationsAsTarget() throws Exception { JSONCompareMode.STRICT); } + @Test + @DisplayName("Should search entities by bare relations_as_target presence (EQ)") + @WithMockUser + void search_200_byRelationsAsTargetPresence() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_relations_as_target_presence.json"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("microservice-1")); + } + + @Test + @DisplayName("Should search entities by bare relations_as_target absence (NOT_CONTAINS)") + @WithMockUser + void search_200_byRelationsAsTargetAbsence() throws Exception { + // graph-svc-b and graph-svc-c are targeted by 'uses' relations; they must be excluded. + // graph-svc-a has a 'uses' outgoing relation but is itself not targeted; it must be included. + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(SEARCH_JSON_PATH + "search_request_relations_as_target_absence.json"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[*].identifier", hasItem("graph-svc-a"))) + .andExpect(jsonPath("$.content[*].identifier", not(hasItem("graph-svc-b")))) + .andExpect(jsonPath("$.content[*].identifier", not(hasItem("graph-svc-c")))); + } + @Test @DisplayName("Should search entities using STARTS_WITH operator") @WithMockUser diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntitySearchDomainMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntitySearchDomainMapperTest.java index 12a1355..2c8f76f 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntitySearchDomainMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntitySearchDomainMapperTest.java @@ -214,6 +214,12 @@ void relationIdentifierField_accepted() { assertThat(criterionFor("relation.api-link.identifier")).isNotNull(); } + @Test + @DisplayName("'relations_as_target' bare field is accepted") + void relationsAsTargetBareField_accepted() { + assertThat(criterionFor("relations_as_target")).isNotNull(); + } + @Test @DisplayName("'relations_as_target.{name}.identifier' field is accepted") void relationsAsTargetIdentifierField_accepted() { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java index 7dc6185..45c05bd 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java @@ -187,6 +187,12 @@ void relationsAsTargetNameField_returnsSpec() { assertThat(specFor("relations_as_target.api-link.name", SearchOperator.CONTAINS, "Web")).isNotNull(); } + @Test + @DisplayName("bare 'relations_as_target' field (filter on reverse relation name) returns non-null spec") + void bareRelationsAsTargetField_returnsSpec() { + assertThat(specFor("relations_as_target", SearchOperator.NOT_CONTAINS, "used_by")).isNotNull(); + } + @Test @DisplayName("bare 'relation' field (filter on relation name) returns non-null spec") void bareRelationField_returnsSpec() { diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target_absence.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target_absence.json new file mode 100644 index 0000000..d4c56ed --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target_absence.json @@ -0,0 +1,11 @@ +{ + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "relations_as_target", "operation": "NOT_CONTAINS", "value": "uses" } + ] + }, + "page": 0, + "size": 20 +} diff --git a/src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target_presence.json b/src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target_presence.json new file mode 100644 index 0000000..0199b84 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/search/search_request_relations_as_target_presence.json @@ -0,0 +1,11 @@ +{ + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "relations_as_target", "operation": "EQ", "value": "api-link" } + ] + }, + "page": 0, + "size": 20 +} From b89ec6d43ecc3e2c72e25ea26443b038bc0046f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 22 May 2026 16:44:01 +0200 Subject: [PATCH 49/51] feat(core): add a entity graph service and endpoint --- .../api/configuration/CorsProperties.java | 13 +-- .../api/configuration/SwaggerDescription.java | 1 + .../api/controller/EntityGraphController.java | 13 ++- .../entity/EntityGraphFlatDtoOutMapper.java | 106 +++++++++-------- .../controller/EntityGraphControllerTest.java | 68 +++++++++++ .../db/test/R__1_Insert_test_data.sql | 109 +++++++++++++++++- .../test/R__2_Insert_entities_test_data.sql | 66 ----------- 7 files changed, 251 insertions(+), 125 deletions(-) delete mode 100644 src/test/resources/db/test/R__2_Insert_entities_test_data.sql diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index 841b7a9..d71c328 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -10,14 +10,11 @@ public record CorsProperties( List allowedOrigins, List allowedOriginPatterns ) { - /// Compact constructor: normalises null to empty and defensively copies to prevent - /// external mutation of the configuration list (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + /// Compact constructor: normalises null to empty and defensively copies every list + /// to prevent external mutation of the internal state (EI_EXPOSE_REP / EI_EXPOSE_REP2). + /// List.copyOf() also rejects null elements, enforcing a clean configuration contract. public CorsProperties { - if (allowedOriginPatterns == null) { - allowedOriginPatterns = List.of(); - } - if (allowedOrigins == null) { - allowedOrigins = List.of(); - } + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); + allowedOriginPatterns = allowedOriginPatterns == null ? List.of() : List.copyOf(allowedOriginPatterns); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 19a06f4..feb6d60 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -167,4 +167,5 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; + public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index c71b88e..7ea3de4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -6,6 +6,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PROPERTIES_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_RELATIONS_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; @@ -60,6 +61,7 @@ public class EntityGraphController { /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) /// @param includeData when true, each node includes a data object with entity property values /// @param relations when provided, only relations with matching names are included + /// @param properties when provided, each node's data object is restricted to the listed property names /// @return flat DTO containing nodes and edges arrays @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @@ -79,16 +81,19 @@ public EntityGraphFlatDtoOut getEntityGraph( @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) - @RequestParam(defaultValue = "false") boolean includeData, + @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, @Parameter(description = PARAM_RELATIONS_DESCRIPTION) - @RequestParam(required = false) List relations) { + @RequestParam(required = false) List relations, + @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) + @RequestParam(required = false) List properties) { - // Convert the nullable list to a Set for O(1) lookup; empty set means no filter + // Convert the nullable lists to Sets for O(1) lookup; empty set means no filter Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); + Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); EntityGraphNode graphNode = entityGraphService.getEntityGraph( templateIdentifier, entityIdentifier, depth, includeData); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter); + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter, propertyFilter); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index 3bf9932..8b90f8b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -36,74 +36,80 @@ private EntityGraphFlatDtoOutMapper() { // Utility class — not instantiable } + /// Groups mutable traversal accumulators to stay within the method-parameter limit + /// and keep the traversal signature readable. + private record TraversalState( + SequencedSet nodes, + List edges, + Set visitedNodeIds, + Set emittedEdgeSignatures, + AtomicInteger edgeCounter) { + } + /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. /// - /// @param root the root [EntityGraphNode] returned by the domain service - /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, - /// and nodes not referenced by any remaining edge are pruned from the - /// result (except the root, which is always included); - /// an empty set means no filter — all edge types and nodes are emitted + /// @param root the root [EntityGraphNode] returned by the domain service + /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, + /// and nodes not referenced by any remaining edge are pruned; + /// an empty set means no filter — all edge types and nodes are emitted + /// @param propertyFilter when non-empty, only properties whose name is in this set appear + /// in each node's `data` field; + /// an empty set means no filter — all properties are included /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter, + Set propertyFilter) { if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } - // Use a SequencedSet to deduplicate nodes while preserving insertion order - SequencedSet nodes = new LinkedHashSet<>(); - List edges = new ArrayList<>(); - // Tracks visited node IDs to prevent infinite loops in cyclic graphs - Set visitedNodeIds = new HashSet<>(); - // Tracks emitted edge signatures (source|target|label) to avoid duplicate edges - // when the same relation is encountered from both sides during traversal - Set emittedEdgeSignatures = new HashSet<>(); - var edgeCounter = new AtomicInteger(0); + var state = new TraversalState( + new LinkedHashSet<>(), // nodes — insertion-ordered, deduplicated + new ArrayList<>(), // edges + new HashSet<>(), // visitedNodeIds — prevents infinite loops in cyclic graphs + new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges + new AtomicInteger(0)); // edgeCounter - traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + traverse(root, state, relationFilter, propertyFilter); // When a relation filter is active, prune nodes that are not connected to any - // remaining edge. The root is always kept. Without this step, nodes reachable via - // non-filtered edges (e.g. C via "depends-on" when filtering "monitors") would + // remaining edge. Without this step, nodes reachable via non-filtered edges would // appear in the node list despite having no visible edges. List finalNodes; if (relationFilter.isEmpty()) { - finalNodes = List.copyOf(nodes); + finalNodes = List.copyOf(state.nodes()); } else { // Collect all node IDs referenced by the filtered edges only. // The root receives no special treatment: if it has no matching edges // it is pruned just like any other disconnected node. Set referencedNodeIds = new HashSet<>(); - for (var edge : edges) { + for (var edge : state.edges()) { referencedNodeIds.add(edge.source()); referencedNodeIds.add(edge.target()); } - finalNodes = nodes.stream() + finalNodes = state.nodes().stream() .filter(n -> referencedNodeIds.contains(n.id())) .toList(); } - return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(edges)); + return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); } private static void traverse( EntityGraphNode node, - SequencedSet nodes, - List edges, - Set visitedNodeIds, - Set emittedEdgeSignatures, - AtomicInteger edgeCounter, - Set relationFilter) { + TraversalState state, + Set relationFilter, + Set propertyFilter) { var nodeId = nodeId(node.templateIdentifier(), node.identifier()); // Skip this node if already visited to prevent infinite loops in cyclic graphs - if (!visitedNodeIds.add(nodeId)) { + if (!state.visitedNodeIds().add(nodeId)) { return; } - nodes.add(new EntityGraphNodeFlatDtoOut( + state.nodes().add(new EntityGraphNodeFlatDtoOut( nodeId, node.name(), node.templateIdentifier(), node.identifier(), - toDataMap(node))); + toDataMap(node, propertyFilter))); // Traverse outbound relations: emit edge from currentNode → target only when the // relation type matches the filter (or no filter is active). Nodes are always @@ -112,9 +118,9 @@ private static void traverse( for (EntityGraphNode target : relation.targets()) { var targetId = nodeId(target.templateIdentifier(), target.identifier()); if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); + addEdge(state, nodeId, targetId, relation.name()); } - traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + traverse(target, state, relationFilter, propertyFilter); } } @@ -125,9 +131,9 @@ private static void traverse( for (EntityGraphNode source : relation.targets()) { var sourceId = nodeId(source.templateIdentifier(), source.identifier()); if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); + addEdge(state, sourceId, nodeId, relation.name()); } - traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + traverse(source, state, relationFilter, propertyFilter); } } } @@ -136,17 +142,15 @@ private static void traverse( /// that arise when the same relation is encountered from both the source and the target /// during depth-first traversal. private static void addEdge( - List edges, - Set emittedEdgeSignatures, - AtomicInteger edgeCounter, + TraversalState state, String sourceId, String targetId, String label) { var signature = sourceId + "|" + targetId + "|" + label; - if (emittedEdgeSignatures.add(signature)) { - edges.add(new EntityGraphEdgeDtoOut( - "e" + edgeCounter.incrementAndGet(), sourceId, targetId, label)); + if (state.emittedEdgeSignatures().add(signature)) { + state.edges().add(new EntityGraphEdgeDtoOut( + "e" + state.edgeCounter().incrementAndGet(), sourceId, targetId, label)); } } @@ -157,10 +161,20 @@ private static String nodeId(String templateIdentifier, String identifier) { } /// Converts a node's property list to a name→value map for the `data` field. - /// Returns an empty map when there are no properties; the DTO's @JsonInclude(NON_EMPTY) - /// annotation ensures an empty map is omitted from the JSON output. - private static Map toDataMap(EntityGraphNode node) { - return node.properties().stream() - .collect(Collectors.toMap(p -> p.name(), p -> p.value())); + /// + /// When [propertyFilter] is non-empty, only entries whose name is contained in the + /// filter are included. Returns an empty map when there are no matching properties; + /// the DTO's @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted from + /// the JSON output. + /// + /// @param node the graph node whose properties are converted + /// @param propertyFilter when non-empty, restricts which properties appear in the map; + /// an empty set means all properties are included + private static Map toDataMap(EntityGraphNode node, Set propertyFilter) { + var stream = node.properties().stream(); + if (!propertyFilter.isEmpty()) { + stream = stream.filter(p -> propertyFilter.contains(p.name())); + } + return stream.collect(Collectors.toMap(p -> p.name(), p -> p.value())); } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java index d2ef939..ca30cf2 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -152,4 +152,72 @@ void shouldReturn401WithoutAuthentication() throws Exception { .andExpect(status().isUnauthorized()); } } + + @Nested + @DisplayName("With 'properties' filter (include_data=true)") + class PropertyFilter { + + @Test + @WithMockUser + @DisplayName("Should include only requested property in each node's data when one property is requested") + void shouldIncludeOnlyRequestedProperty() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .param("properties", "tier") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are still returned + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Each node's data must contain "tier" … + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + // … but must NOT contain "version" + .andExpect(jsonPath("$.nodes[0].data.version").doesNotExist()); + } + + @Test + @WithMockUser + @DisplayName("Should include multiple requested properties in each node's data") + void shouldIncludeMultipleRequestedProperties() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .param("properties", "tier") + .param("properties", "version") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return empty data when requested property does not exist on entity") + void shouldReturnEmptyDataForUnknownProperty() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .param("properties", "non-existent-prop") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + // data field is omitted from JSON when empty (@JsonInclude NON_EMPTY) + .andExpect(jsonPath("$.nodes[0].data").doesNotExist()); + } + + @Test + @WithMockUser + @DisplayName("Should include all properties when no property filter is supplied") + void shouldIncludeAllPropertiesWithoutFilter() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); + } + } } diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index bab7cb5..3225589 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -1,6 +1,13 @@ -- Sample data for IDP Core domain models - Enhanced with 10 templates --- Clear existing data (for repeatable migrations) +-- Clear existing data (for repeatable migrations). +-- Deletion order respects FK constraints: child tables first, then parents. +DELETE FROM entity_properties; +DELETE FROM entity_relations; +DELETE FROM relation_target_entities; +DELETE FROM relation; +DELETE FROM entity; +DELETE FROM property; DELETE FROM entity_template_relations_definitions; DELETE FROM entity_template_properties_definitions; DELETE FROM entity_template; @@ -278,3 +285,103 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440053'), -- database ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440064'); -- external_apis + +-- ----------------------------------------------------------------------- +-- Sample entity instances +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), + ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), + ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), + ('550e8400-e29b-41d4-a716-446655440103', 'batch-job-1', 'Batch Job 1', 'batch-job'), + ('550e8400-e29b-41d4-a716-446655440104', 'frontend-app-1', 'Frontend App 1', 'frontend-app'), + ('550e8400-e29b-41d4-a716-446655440105', 'worker-service-1', 'Worker Service 1', 'worker-service'), + ('550e8400-e29b-41d4-a716-446655440106', 'api-gateway-1', 'API Gateway 1', 'api-gateway'), + ('550e8400-e29b-41d4-a716-446655440107', 'database-service-1', 'Database Service 1', 'database-service'), + ('550e8400-e29b-41d4-a716-446655440108', 'cache-service-1', 'Cache Service 1', 'cache-service'), + ('550e8400-e29b-41d4-a716-446655440109', 'monitoring-service-1', 'Monitoring Service 1', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440110', 'monitoring-service-2', 'Monitoring Service 2', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); + +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); + +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c + +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + +-- ----------------------------------------------------------------------- +-- Property data for graph test entities (used by the property-filter tests). +-- +-- Each graph entity gets two properties: "tier" and "version". +-- This lets us verify: +-- 1. No filter → both properties appear in node data +-- 2. Filter "tier" → only tier present, version absent +-- 3. Filter "tier"+"version" → both present +-- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) +-- ----------------------------------------------------------------------- + +INSERT INTO property (id, name, value) +VALUES + -- graph-svc-a + ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), + ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), + -- graph-svc-b + ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), + ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), + -- graph-svc-c + ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); + +INSERT INTO entity_properties (entity_id, property_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql deleted file mode 100644 index d80b4d5..0000000 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ /dev/null @@ -1,66 +0,0 @@ --- Insert sample entities into idp_core.entity -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), - ('550e8400-e29b-41d4-a716-446655440103', 'batch-job-1', 'Batch Job 1', 'batch-job'), - ('550e8400-e29b-41d4-a716-446655440104', 'frontend-app-1', 'Frontend App 1', 'frontend-app'), - ('550e8400-e29b-41d4-a716-446655440105', 'worker-service-1', 'Worker Service 1', 'worker-service'), - ('550e8400-e29b-41d4-a716-446655440106', 'api-gateway-1', 'API Gateway 1', 'api-gateway'), - ('550e8400-e29b-41d4-a716-446655440107', 'database-service-1', 'Database Service 1', 'database-service'), - ('550e8400-e29b-41d4-a716-446655440108', 'cache-service-1', 'Cache Service 1', 'cache-service'), - ('550e8400-e29b-41d4-a716-446655440109', 'monitoring-service-1', 'Monitoring Service 1', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440110', 'monitoring-service-2', 'Monitoring Service 2', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); - --- ----------------------------------------------------------------------- --- Graph test data: 3-level chain of entities connected via two relation --- types ("uses" and "monitors") for integration testing of the graph API. --- --- Graph topology (depth-3 chain): --- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c --- graph-svc-a --[monitors]--> graph-svc-b --- --- This setup allows us to verify: --- 1. Graph traversal works at all depths (not just root level) --- 2. Relation name filtering excludes the correct edges/nodes at every depth --- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) --- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) --- ----------------------------------------------------------------------- - --- Entities (all use the 'web-service' template which exists in test data) --- UUIDs use only valid hex characters (0-9, a-f) -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), - ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), - ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); - --- Relations owned by graph-svc-a: "uses" → b, "monitors" → b -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), - ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); - --- Relation owned by graph-svc-b: "uses" → c -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); - --- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c - --- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation - ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation From 09f09e3d2a837ec92c4037061f2a49c5304907f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 26 May 2026 11:15:11 +0200 Subject: [PATCH 50/51] feat(core): add a entity graph service and endpoint --- .../adapters/api/dto/in/FilterNodeDtoIn.java | 13 +- .../db/test/R__1_Insert_test_data.sql | 100 ------------- .../test/R__2_Insert_entities_test_data.sql | 136 +++++++++++------- 3 files changed, 95 insertions(+), 154 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java index 047bf2d..cec644b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java @@ -1,5 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import java.util.Collections; import java.util.List; import io.swagger.v3.oas.annotations.media.Schema; @@ -28,6 +29,14 @@ public record FilterNodeDtoIn( String operation, @Schema(description = "Value to compare against for a criterion node. Required for leaf nodes.", example = "microservice") - String value -) { + String value) { + + public FilterNodeDtoIn { + criteria = criteria == null ? null : Collections.unmodifiableList(List.copyOf(criteria)); + } + + @Override + public List criteria() { + return criteria == null ? null : Collections.unmodifiableList(criteria); + } } diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index 3225589..2bc5459 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -285,103 +285,3 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440053'), -- database ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440064'); -- external_apis - --- ----------------------------------------------------------------------- --- Sample entity instances --- ----------------------------------------------------------------------- - -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), - ('550e8400-e29b-41d4-a716-446655440103', 'batch-job-1', 'Batch Job 1', 'batch-job'), - ('550e8400-e29b-41d4-a716-446655440104', 'frontend-app-1', 'Frontend App 1', 'frontend-app'), - ('550e8400-e29b-41d4-a716-446655440105', 'worker-service-1', 'Worker Service 1', 'worker-service'), - ('550e8400-e29b-41d4-a716-446655440106', 'api-gateway-1', 'API Gateway 1', 'api-gateway'), - ('550e8400-e29b-41d4-a716-446655440107', 'database-service-1', 'Database Service 1', 'database-service'), - ('550e8400-e29b-41d4-a716-446655440108', 'cache-service-1', 'Cache Service 1', 'cache-service'), - ('550e8400-e29b-41d4-a716-446655440109', 'monitoring-service-1', 'Monitoring Service 1', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440110', 'monitoring-service-2', 'Monitoring Service 2', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); - --- ----------------------------------------------------------------------- --- Graph test data: 3-level chain of entities connected via two relation --- types ("uses" and "monitors") for integration testing of the graph API. --- --- Graph topology (depth-3 chain): --- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c --- graph-svc-a --[monitors]--> graph-svc-b --- --- This setup allows us to verify: --- 1. Graph traversal works at all depths (not just root level) --- 2. Relation name filtering excludes the correct edges/nodes at every depth --- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) --- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) --- ----------------------------------------------------------------------- - -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), - ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), - ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); - --- Relations owned by graph-svc-a: "uses" → b, "monitors" → b -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), - ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); - --- Relation owned by graph-svc-b: "uses" → c -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); - --- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c - --- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation - ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation - --- ----------------------------------------------------------------------- --- Property data for graph test entities (used by the property-filter tests). --- --- Each graph entity gets two properties: "tier" and "version". --- This lets us verify: --- 1. No filter → both properties appear in node data --- 2. Filter "tier" → only tier present, version absent --- 3. Filter "tier"+"version" → both present --- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) --- ----------------------------------------------------------------------- - -INSERT INTO property (id, name, value) -VALUES - -- graph-svc-a - ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), - ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), - -- graph-svc-b - ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), - ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), - -- graph-svc-c - ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), - ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); - -INSERT INTO entity_properties (entity_id, property_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier - ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version - ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier - ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index d92f2af..46d3a8e 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -1,4 +1,7 @@ --- Insert sample entities into idp_core.entity +-- ----------------------------------------------------------------------- +-- Sample entity instances +-- ----------------------------------------------------------------------- + INSERT INTO entity (id, identifier, name, template_identifier) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), @@ -17,7 +20,85 @@ VALUES ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); --- Properties for web-api-1 (language=JAVA, environment=PROD) +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); + +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c + +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + +-- ----------------------------------------------------------------------- +-- Property data for graph test entities (used by the property-filter tests). +-- +-- Each graph entity gets two properties: "tier" and "version". +-- This lets us verify: +-- 1. No filter → both properties appear in node data +-- 2. Filter "tier" → only tier present, version absent +-- 3. Filter "tier"+"version" → both present +-- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) +-- ----------------------------------------------------------------------- + +INSERT INTO property (id, name, value) +VALUES + -- graph-svc-a + ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), + ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), + -- graph-svc-b + ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), + ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), + -- graph-svc-c + ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); + +INSERT INTO entity_properties (entity_id, property_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version + + -- Properties for web-api-1 (language=JAVA, environment=PROD) INSERT INTO idp_core.property (id, name, value) VALUES ('aa000000-0000-0000-0000-000000000001', 'programmingLanguage', 'JAVA'), @@ -78,53 +159,4 @@ VALUES ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); INSERT INTO idp_core.entity_relations (entity_id, relation_id) VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); - - --- ----------------------------------------------------------------------- --- Graph test data: 3-level chain of entities connected via two relation --- types ("uses" and "monitors") for integration testing of the graph API. --- --- Graph topology (depth-3 chain): --- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c --- graph-svc-a --[monitors]--> graph-svc-b --- --- This setup allows us to verify: --- 1. Graph traversal works at all depths (not just root level) --- 2. Relation name filtering excludes the correct edges/nodes at every depth --- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) --- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) --- ----------------------------------------------------------------------- - --- Entities (all use the 'web-service' template which exists in test data) --- UUIDs use only valid hex characters (0-9, a-f) -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), - ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), - ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); - --- Relations owned by graph-svc-a: "uses" → b, "monitors" → b -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), - ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); - --- Relation owned by graph-svc-b: "uses" → c -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); - --- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c - --- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation - ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); \ No newline at end of file From 2ab2e283c196ce806007cddf78d48adc61a1beb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 26 May 2026 12:42:06 +0200 Subject: [PATCH 51/51] feat(core): add a entity graph service and endpoint --- docs/src/static/swagger.yaml | 828 +++++++++++------- .../entity_graph/EntityGraphService.java | 9 +- .../test/R__2_Insert_entities_test_data.sql | 2 +- 3 files changed, 522 insertions(+), 317 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 3f1002a..5e12c84 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -9,141 +9,252 @@ security: - clientId: [] - bearer: [] tags: -- name: Entity Graph - description: Entity relationship graph operations -- name: Entities Management - description: Operations related to entity management -- name: Entities Templates Management - description: Operations related to entity template management + - name: Entity Graph + description: Entity relationship graph operations + - name: Entities Management + description: Operations related to entity management + - name: Entities Templates Management + description: Operations related to entity template management paths: - "/api/v1/entity-templates/{identifier}": + '/api/v1/entity-templates/{identifier}': get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get template by identifier description: Retrieve a specific template using its string identifier operationId: getTemplateByIdentifier parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '200': description: Template found content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityTemplateDtoOut" + $ref: '#/components/schemas/EntityTemplateDtoOut' '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' put: tags: - - Entities Templates Management + - Entities Templates Management summary: Update an existing template by template identifier - description: Update the details of an existing template identified by its unique + description: >- + Update the details of an existing template identified by its unique string identifier operationId: updateTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string requestBody: - required: true content: application/json: schema: - "$ref": "#/components/schemas/EntityTemplateUpdateDtoIn" + $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' + required: true responses: '200': description: Template update successfully content: - "*/*": + '*/*': + schema: + $ref: '#/components/schemas/EntityTemplateDtoOut' + '400': + description: Invalid template data provided + content: + '*/*': schema: - "$ref": "#/components/schemas/EntityTemplateDtoOut" + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Template with this identifier already exists + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' delete: tags: - - Entities Templates Management + - Entities Templates Management summary: Delete template by identifier description: Remove a template from the system using its unique identifier operationId: deleteTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '204': description: Template deleted successfully '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entities/{templateIdentifier}/{entityIdentifier}': + get: + tags: + - Entities Management + summary: Get entity by entity template and identifier + description: >- + Retrieve a specific entity using its string identifier and its template + identifier + operationId: getEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string + responses: + '200': + description: Entity found + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - Entities Management + summary: Update an existing entity + description: Update an existing entity in the system with the provided information + operationId: updateEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EntityDtoIn' + required: true + responses: + '200': + description: Entity updated successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '400': + description: Invalid entity data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - "/api/v1/entity-templates": + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entity-templates: get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get paginated templates description: Retrieve a paginated list of templates with optional sorting operationId: getTemplatesPaginated parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - content: - "*/*": - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - content: - "*/*": - schema: - type: integer - default: '20' - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults - to identifier,asc.' - content: - "*/*": - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + content: + '*/*': + schema: + type: integer + default: '20' + - name: sort + in: query + description: >- + Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc. + content: + '*/*': + schema: + type: string + default: 'identifier,asc' responses: '200': description: Paginated templates retrieved successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/TemplatePageResponse" + $ref: '#/components/schemas/TemplatePageResponse' '400': description: Invalid pagination parameters content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' post: tags: - - Entities Templates Management + - Entities Templates Management summary: Create a new template description: Create a new template in the system with the provided information operationId: createTemplate @@ -151,105 +262,107 @@ paths: content: application/json: schema: - "$ref": "#/components/schemas/EntityTemplateCreateDtoIn" + $ref: '#/components/schemas/EntityTemplateCreateDtoIn' + required: true responses: '201': description: Template created successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityTemplateDtoOut" + $ref: '#/components/schemas/EntityTemplateDtoOut' '400': description: Invalid template data provided content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - "/api/v1/entities/{templateIdentifier}": + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entities/{templateIdentifier}': get: tags: - - Entities Management + - Entities Management summary: Get entities by template identifier description: Retrieve a paginated list of entities with optional sorting operationId: getEntities parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - required: false - content: - "*/*": - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - required: false - content: - "*/*": - schema: - type: integer - default: '20' - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults - to identifier,asc.' - content: - "*/*": - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + '*/*': + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: >- + Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc. + content: + '*/*': + schema: + type: string + default: 'identifier,asc' responses: '200': description: Paginated entities retrieved successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityPageResponse" + $ref: '#/components/schemas/EntityPageResponse' '400': description: Invalid pagination parameters content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' post: tags: - - Entities Management + - Entities Management summary: Create a new entity description: Create a new entity in the system with the provided information operationId: createEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - minLength: 1 + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 requestBody: content: application/json: schema: - "$ref": "#/components/schemas/EntityDtoIn" + $ref: '#/components/schemas/EntityDtoIn' required: true responses: '201': description: Entity created successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityDtoOut" + $ref: '#/components/schemas/EntityDtoOut' '400': description: Invalid entity data provided content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '401': description: Unauthorized - Missing or invalid token '403': @@ -257,114 +370,127 @@ paths: '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '409': description: Entity already exists in this template content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '500': description: Unexpected server-side failure content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - "/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph": - get: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/search: + post: tags: - - Entity Graph - summary: Get entity relationship graph as flat nodes and edges - description: Retrieves the entity relationship graph as a flat nodes-and-edges - structure, suitable for frontend visualization tools such as React Flow, Vis.js, - and Cytoscape. - operationId: getEntityGraph - parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - minLength: 1 - - name: entityIdentifier - in: path + - Entities Management + summary: Search entities + description: >- + Search for entities across all templates using a nested filter query. + Supports complex logical compositions (AND / OR / IN) of filter criteria + on template, identifier, name, properties, relations, and reverse + relations. + operationId: searchEntities + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EntitySearchRequestDtoIn' required: true - schema: - type: string - minLength: 1 - - name: depth - in: query - description: Maximum traversal depth for relationship resolution. Clamped - between 1 and 10. - required: false - schema: - type: integer - format: int32 - default: 1 - - name: includeData - in: query - description: When true, each graph node includes a data object containing - the entity's property values. Defaults to false. - required: false - schema: - type: boolean - default: false - - name: relations - in: query - description: When provided, only relations whose name matches one of the listed - values are traversed and included. Omit to include all relations. - required: false - schema: - type: array - items: - type: string responses: '200': - description: Flat entity graph successfully retrieved + description: Entities retrieved successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityGraphFlatDtoOut" - '404': - description: Entity not found with the provided identifier + $ref: '#/components/schemas/EntityPageResponse' + '400': + description: Invalid search filter content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - "/api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}": + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph': get: tags: - - Entities Management - summary: Get entity by entity template and identifier - description: Retrieve a specific entity using its string identifier and its - template identifier - operationId: getEntity + - Entity Graph + summary: Get entity relationship graph as flat nodes and edges + description: >- + Retrieves the entity relationship graph as a flat nodes-and-edges + structure, suitable for frontend visualization tools such as React Flow, + Vis.js, and Cytoscape. + operationId: getEntityGraph parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: entityIdentifier - in: path - required: true - schema: - type: string + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: depth + in: query + description: >- + Maximum traversal depth for relationship resolution. Clamped between + 1 and 10. + required: false + schema: + type: integer + format: int32 + default: 1 + - name: include_data + in: query + description: >- + When true, each graph node includes a data object containing the + entity's property values. Defaults to false. + required: false + schema: + type: boolean + default: false + - name: relations + in: query + description: >- + When provided, only relations whose name matches one of the listed + values are traversed and included. Omit to include all relations. + required: false + schema: + type: array + items: + type: string + - name: properties + in: query + description: >- + When provided, each node's data object is restricted to the listed + property names. Requires include_data=true to have any effect. Omit + to include all properties. + required: false + schema: + type: array + items: + type: string responses: '200': - description: Entity found + description: Flat entity graph successfully retrieved content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityDtoOut" + $ref: '#/components/schemas/EntityGraphFlatDtoOut' '404': description: Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' components: schemas: EntityTemplateUpdateDtoIn: @@ -377,7 +503,7 @@ components: example: Service maxLength: 255 minLength: 0 - pattern: "^[a-zA-Z0-9 _-]+$" + pattern: '^[a-zA-Z0-9 _-]+$' description: type: string description: Entity Template description @@ -386,14 +512,14 @@ components: type: array description: List of property definitions for this template items: - "$ref": "#/components/schemas/PropertyDefinitionDtoIn" + $ref: '#/components/schemas/PropertyDefinitionDtoIn' relations_definitions: type: array description: List of relation definitions for this template items: - "$ref": "#/components/schemas/RelationDefinitionDtoIn" + $ref: '#/components/schemas/RelationDefinitionDtoIn' required: - - name + - name PropertyDefinitionDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -412,22 +538,22 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean + default: false description: Whether this property is required example: true - default: false rules: - "$ref": "#/components/schemas/PropertyRulesDtoIn" + $ref: '#/components/schemas/PropertyRulesDtoIn' description: Property validation rules required: - - description - - name - - type + - description + - name + - type PropertyRulesDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -436,21 +562,21 @@ components: type: string description: Property format validation enum: - - URL - - EMAIL + - URL + - EMAIL example: EMAIL enum_values: type: array description: Enumeration values for enum properties example: - - ACTIVE - - INACTIVE + - ACTIVE + - INACTIVE items: type: string regex: type: string description: Regular expression pattern for validation - example: "^[a-zA-Z0-9]+$" + example: '^[a-zA-Z0-9]+$' max_length: type: integer format: int32 @@ -487,17 +613,17 @@ components: minLength: 1 required: type: boolean + default: false description: Whether this relation is required example: false - default: false to_many: type: boolean + default: false description: Whether this relation can have multiple targets example: true - default: false required: - - name - - target_template_identifier + - name + - target_template_identifier EntityTemplateDtoOut: type: object description: Output for entity template @@ -518,12 +644,12 @@ components: type: array description: List of property definitions for this template items: - "$ref": "#/components/schemas/PropertyDefinitionDtoOut" + $ref: '#/components/schemas/PropertyDefinitionDtoOut' relations_definitions: type: array description: List of relation definitions for this template items: - "$ref": "#/components/schemas/RelationDefinitionDtoOut" + $ref: '#/components/schemas/RelationDefinitionDtoOut' PropertyDefinitionDtoOut: type: object description: Output DTO for property definition @@ -540,16 +666,16 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean description: Whether this property is required example: true rules: - "$ref": "#/components/schemas/PropertyRulesDtoOut" + $ref: '#/components/schemas/PropertyRulesDtoOut' description: Property validation rules example: Property validation rules PropertyRulesDtoOut: @@ -560,21 +686,21 @@ components: type: string description: Format of the property enum: - - URL - - EMAIL + - URL + - EMAIL example: STRING enum_values: type: array description: Allowed enum values for the property example: - - VALUE1 - - VALUE2 + - VALUE1 + - VALUE2 items: type: string regex: type: string description: Regular expression for property validation - example: "^[A-Za-z0-9]+$" + example: '^[A-Za-z0-9]+$' max_length: type: integer format: int32 @@ -622,39 +748,6 @@ components: type: string errorDescription: type: string - EntityTemplateCreateDtoIn: - type: object - description: Input DTO for creating an entity template - properties: - identifier: - type: string - description: Unique Entity Template identifier - example: service - minLength: 1 - name: - type: string - description: Unique Entity Template name - example: Service - maxLength: 255 - minLength: 0 - pattern: "^[a-zA-Z0-9 _-]+$" - description: - type: string - description: Entity Template description - example: A comprehensive service template - properties_definitions: - type: array - description: List of property definitions for this template - items: - "$ref": "#/components/schemas/PropertyDefinitionDtoIn" - relations_definitions: - type: array - description: List of relation definitions for this template - items: - "$ref": "#/components/schemas/RelationDefinitionDtoIn" - required: - - identifier - - name EntityDtoIn: type: object description: Input DTO for creating or updating an entity @@ -671,7 +764,8 @@ components: minLength: 1 properties: type: object - additionalProperties: {} + additionalProperties: + type: string description: Map of property name to value for this entity example: port: '8080' @@ -680,10 +774,10 @@ components: type: array description: List of relations for this entity items: - "$ref": "#/components/schemas/RelationDtoIn" + $ref: '#/components/schemas/RelationDtoIn' required: - - identifier - - name + - identifier + - name RelationDtoIn: type: object description: Input DTO for an entity relation instance @@ -697,13 +791,13 @@ components: type: array description: List of target entity identifiers for this relation example: - - web-api-1 - - web-api-2 + - web-api-1 + - web-api-2 items: type: string required: - - name - - target_entity_identifiers + - name + - target_entity_identifiers EntityDtoOut: type: object properties: @@ -721,13 +815,13 @@ components: additionalProperties: type: array items: - "$ref": "#/components/schemas/EntitySummaryDto" + $ref: '#/components/schemas/EntitySummaryDto' relations_as_target: type: object additionalProperties: type: array items: - "$ref": "#/components/schemas/EntitySummaryDto" + $ref: '#/components/schemas/EntitySummaryDto' EntitySummaryDto: type: object properties: @@ -735,43 +829,121 @@ components: type: string name: type: string - PageableObject: + EntityTemplateCreateDtoIn: type: object + description: Input DTO for creating an entity template properties: - paged: - type: boolean - pageNumber: + identifier: + type: string + description: Unique Entity Template identifier + example: service + minLength: 1 + name: + type: string + description: Unique Entity Template name + example: Service + maxLength: 255 + minLength: 0 + pattern: '^[a-zA-Z0-9 _-]+$' + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + $ref: '#/components/schemas/PropertyDefinitionDtoIn' + relations_definitions: + type: array + description: List of relation definitions for this template + items: + $ref: '#/components/schemas/RelationDefinitionDtoIn' + required: + - identifier + - name + EntitySearchRequestDtoIn: + type: object + description: Request body for the POST /api/v1/entities/search endpoint + properties: + query: + type: string + description: >- + Free-text search string. When present, returns entities whose + identifier, name, templateIdentifier, or any property value contains + this string (case-insensitive). Can be combined with filter. + example: checkout + filter: + $ref: '#/components/schemas/FilterNodeDtoIn' + description: >- + Root node of the search filter tree. May be omitted or null to + return all entities. + page: type: integer format: int32 - pageSize: + default: 0 + description: Zero-based page index. Defaults to 0. + example: 0 + size: type: integer format: int32 + default: 20 + description: Number of entities per page. Defaults to 20. + example: 20 sort: - "$ref": "#/components/schemas/SortObject" - unpaged: - type: boolean - offset: - type: integer - format: int64 - SortObject: + type: string + description: 'Sort expression in the form field:asc|desc, e.g. identifier:asc.' + example: 'identifier:asc' + FilterNodeDtoIn: type: object + description: >- + A node in the search filter tree. Either a logical group (connector + + criteria) or a leaf criterion (field + operation + value). properties: - sorted: - type: boolean - unsorted: - type: boolean - empty: - type: boolean - TemplatePageResponse: + connector: + type: string + description: >- + Logical connector for a group node. One of: AND, OR. Required for + group nodes. + example: AND + criteria: + type: array + description: >- + Child filter nodes for a group node. Required for group nodes (must + be non-empty). + items: + $ref: '#/components/schemas/FilterNodeDtoIn' + field: + type: string + description: >- + Field to filter on for a criterion node. Required for leaf nodes. + Examples: template, identifier, name, relation, property.language, + relation.api-link, relation.api-link.identifier, + relations_as_target.api-link.name + example: template + operation: + type: string + description: >- + Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, + NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for + leaf nodes. + example: EQ + value: + type: string + description: >- + Value to compare against for a criterion node. Required for leaf + nodes. + example: microservice + EntityPageResponse: type: object - description: Paginated response containing Template objects + description: Paginated response containing Entity objects properties: content: type: array items: - "$ref": "#/components/schemas/EntityTemplateDtoOut" + $ref: '#/components/schemas/EntityDtoOut' pageable: - "$ref": "#/components/schemas/PageableObject" + $ref: '#/components/schemas/PageableObject' totalElements: type: integer format: int64 @@ -781,7 +953,7 @@ components: last: type: boolean sort: - "$ref": "#/components/schemas/SortObject" + $ref: '#/components/schemas/SortObject' numberOfElements: type: integer format: int32 @@ -795,16 +967,43 @@ components: format: int32 empty: type: boolean - EntityPageResponse: + PageableObject: type: object - description: Paginated response containing Entity objects + properties: + paged: + type: boolean + pageNumber: + type: integer + format: int32 + pageSize: + type: integer + format: int32 + sort: + $ref: '#/components/schemas/SortObject' + unpaged: + type: boolean + offset: + type: integer + format: int64 + SortObject: + type: object + properties: + unsorted: + type: boolean + sorted: + type: boolean + empty: + type: boolean + TemplatePageResponse: + type: object + description: Paginated response containing Template objects properties: content: type: array items: - "$ref": "#/components/schemas/EntityDtoOut" + $ref: '#/components/schemas/EntityTemplateDtoOut' pageable: - "$ref": "#/components/schemas/PageableObject" + $ref: '#/components/schemas/PageableObject' totalElements: type: integer format: int64 @@ -814,7 +1013,7 @@ components: last: type: boolean sort: - "$ref": "#/components/schemas/SortObject" + $ref: '#/components/schemas/SortObject' numberOfElements: type: integer format: int32 @@ -850,18 +1049,18 @@ components: type: array description: All entity nodes in the graph items: - "$ref": "#/components/schemas/EntityGraphNodeFlatDtoOut" + $ref: '#/components/schemas/EntityGraphNodeFlatDtoOut' edges: type: array description: All directed relation edges in the graph items: - "$ref": "#/components/schemas/EntityGraphEdgeDtoOut" + $ref: '#/components/schemas/EntityGraphEdgeDtoOut' EntityGraphNodeFlatDtoOut: type: object properties: id: type: string - description: Unique node identifier composed of templateIdentifier:identifier + description: 'Unique node identifier composed of templateIdentifier:identifier' label: type: string description: Human-readable entity name @@ -874,8 +1073,9 @@ components: data: type: object additionalProperties: {} - description: Entity property values keyed by property name; present only - when include_data=true is requested + description: >- + Entity property values keyed by property name; present only when + include_data=true is requested securitySchemes: clientId: type: oauth2 @@ -883,7 +1083,7 @@ components: name: clientId flows: clientCredentials: - tokenUrl: http://localhost:8080/auth/token + tokenUrl: 'https://preprod.idpdecathlon.oxylane.com/as/token.oauth2' bearer: type: http description: bearer authentication diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 4d989ee..af23ff7 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -98,15 +98,20 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Guard: return a stub leaf if this node was already fully built in another branch. // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). + // Properties are still included so data is not silently dropped for shared nodes. var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); if (!visitedNodeIds.add(nodeId)) { + List stubProperties = includeProperties ? entity.properties() : List.of(); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - List.of(), List.of(), List.of()); + stubProperties, List.of(), List.of()); } + // Depth exhausted — return a leaf with no relations but still carry properties + // so the deepest reachable entities expose their data when include_data=true. if (remainingDepth <= 0) { + List leafProperties = includeProperties ? entity.properties() : List.of(); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - List.of(), List.of(), List.of()); + leafProperties, List.of(), List.of()); } List outboundRelations = entity.relations().stream() diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 46d3a8e..267370e 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -159,4 +159,4 @@ VALUES ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); INSERT INTO idp_core.entity_relations (entity_id, relation_id) VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); \ No newline at end of file + ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003');