diff --git a/docker-compose.yml b/docker-compose.yml index be4859d8..13f77845 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,15 @@ --- -version: "3.8" services: postgres: - image: postgres:14 + image: postgres:18 environment: POSTGRES_USER: idpcore 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/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 4a789453..ecabb504 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,106 @@ 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. +- **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. + +### 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 +173,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 +193,222 @@ 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": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + }, + { + "name": "owned-by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] +} +``` + +### Relations in Responses + +In API responses, relations are grouped by name and include summary information about each target entity: ```json { "relations": { - "owned_by": ["platform-team"] + "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. + +### One-to-One Relations (`to_many: false`) + +For consistency, even single relations are represented as arrays: + +```json +{ + "relations": [ + { + "name": "owned_by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] +} +``` + ### One-to-Many Relations (`to_many: true`) -When multiple related entities are allowed, you can list several identifiers in the relation array: +When multiple related entities are allowed, list several identifiers: ```json { - "relations": { - "components": ["frontend", "backend", "database"] - } + "relations": [ + { + "name": "components", + "target_entity_identifiers": [ + "frontend", + "backend", + "database" + ] + } + ] } ``` --- -## Audit Fields +## 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}/{entityIdentifier} +``` + +--- + +## Updating an Entity + +You update an existing entity by sending a `PUT` request on the entity resource path. + +### Update Endpoint -Every entity tracks who created/modified it and when: +```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 { - "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" + "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" + ] + } + ] } ``` -The `created_by` and `updated_by` fields contain: +### 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 -- User IDs for manual operations -- Integration IDs for automated data ingestion +| 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 | --- @@ -157,7 +417,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 +429,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 8b6a25ca..ad1dbf92 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 37c9d488..5e12c848 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -6,13 +6,17 @@ info: servers: - url: http://localhost:8084 security: - - clientId: [] - - bearer: [] +- 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 paths: - /api/v1/entity-templates/{identifier}: + '/api/v1/entity-templates/{identifier}': get: tags: - Entities Templates Management @@ -42,7 +46,9 @@ 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 @@ -63,12 +69,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 @@ -90,6 +118,93 @@ paths: '*/*': 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: get: tags: @@ -116,12 +231,14 @@ 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: type: string - default: identifier,asc + default: 'identifier,asc' responses: '200': description: Paginated templates retrieved successfully @@ -160,52 +277,233 @@ 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 + '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/search: + post: + tags: + - 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 + responses: + '200': + description: Entities retrieved successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityPageResponse' + '400': + description: Invalid search filter + 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: 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: Flat entity graph successfully retrieved + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityGraphFlatDtoOut' + '404': + description: Entity not found with the provided identifier + content: + '*/*': + 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 - 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 properties: name: type: string - description: Entity Template name + description: Unique Entity Template name example: Service maxLength: 255 - minLength: 1 - pattern: "^[a-zA-Z0-9 _-]+$" + minLength: 0 + pattern: '^[a-zA-Z0-9 _-]+$' description: type: string description: Entity Template description @@ -278,7 +576,7 @@ components: 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 @@ -356,11 +654,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 +682,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 @@ -412,7 +700,7 @@ components: 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 @@ -437,11 +725,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 +746,230 @@ 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 - PageableObject: + EntityDtoIn: type: object + description: Input DTO for creating or updating an entity properties: - offset: + 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 + 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 + 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: + 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: + 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 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 sort: $ref: '#/components/schemas/SortObject' - unpaged: + numberOfElements: + type: integer + format: int32 + first: type: boolean + size: + type: integer + format: int32 + number: + type: integer + format: int32 + empty: + type: boolean + PageableObject: + type: object + properties: paged: type: boolean pageNumber: @@ -553,14 +978,21 @@ components: pageSize: type: integer format: int32 + sort: + $ref: '#/components/schemas/SortObject' + unpaged: + type: boolean + offset: + type: integer + format: int64 SortObject: type: object properties: - empty: + unsorted: type: boolean sorted: type: boolean - unsorted: + empty: type: boolean TemplatePageResponse: type: object @@ -572,14 +1004,19 @@ components: $ref: '#/components/schemas/EntityTemplateDtoOut' pageable: $ref: '#/components/schemas/PageableObject' - last: - type: boolean + totalElements: + type: integer + format: int64 totalPages: type: integer format: int32 - totalElements: + last: + type: boolean + sort: + $ref: '#/components/schemas/SortObject' + numberOfElements: type: integer - format: int64 + format: int32 first: type: boolean size: @@ -588,13 +1025,57 @@ components: number: type: integer format: int32 - sort: - $ref: '#/components/schemas/SortObject' - numberOfElements: - 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 @@ -602,7 +1083,7 @@ components: name: clientId flows: clientCredentials: - tokenUrl: https://my-oauth-server.com/as/token.oauth2 + tokenUrl: 'https://preprod.idpdecathlon.oxylane.com/as/token.oauth2' bearer: type: http description: bearer authentication diff --git a/scripts/init-extensions.sql b/scripts/init-extensions.sql new file mode 100644 index 00000000..131be460 --- /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/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index c0232921..59789d86 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 @@ -22,8 +22,27 @@ 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_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."; + // 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"; public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; @@ -31,18 +50,21 @@ 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."; - // 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 ENTITY_IDENTIFIER_MUST_MATCH_PATH = "Entity identifier in body must match path identifier"; - 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) { @@ -63,4 +85,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, 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 00000000..edf295fe --- /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/exception/entity/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java new file mode 100644 index 00000000..82437486 --- /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 2942d910..42c60f67 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 00000000..42756f0e --- /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/entity_template/EntityTemplateAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java index adbb5974..c389889b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java @@ -4,6 +4,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; /// 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/entity_template/EntityTemplateNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java index b690d2af..12e34571 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java @@ -4,6 +4,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; /// 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/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java new file mode 100644 index 00000000..3ce489ed --- /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 2ec901e0..2292ecd4 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; @@ -19,18 +21,25 @@ /// /// 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 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/EntityCompositeKey.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java new file mode 100644 index 00000000..30a0f994 --- /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/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 4c15dcd9..5e7281ed 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/SearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java new file mode 100644 index 00000000..2aee81bc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java @@ -0,0 +1,49 @@ +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` — 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 { + + /// 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/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java new file mode 100644 index 00000000..fff35643 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -0,0 +1,34 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +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). +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs +/// - Understanding relationship chains between entities +/// - Providing a hierarchical view of entity connections +/// +/// @param templateIdentifier the template identifier this entity belongs to +/// @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/model/entity_graph/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java new file mode 100644 index 00000000..d770639d --- /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/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 9a0fb0b0..2d694f10 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/model/enums/LogicalConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java new file mode 100644 index 00000000..b167f3ac --- /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 00000000..6b861c0f --- /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/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java new file mode 100644 index 00000000..82996a2a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -0,0 +1,46 @@ +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) + /// @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, + int depth, + boolean includeProperties); +} 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 02584b92..8f0e1c6c 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. @@ -33,6 +35,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); @@ -42,4 +46,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 00000000..7f970ef0 --- /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 deleted file mode 100644 index b21d8713..00000000 --- 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 00000000..455f0fc5 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -0,0 +1,166 @@ +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; +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.SearchFilterNode; +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.EntitySearchService; +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; + private final EntitySearchService entitySearchService; + + /// 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); + } + + /// 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); + } + + /// 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/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java new file mode 100644 index 00000000..b26b8e7d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -0,0 +1,99 @@ +package com.decathlon.idp_core.domain.service.entity; + +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 com.decathlon.idp_core.domain.service.relation.RelationValidationService; + +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; + private final RelationValidationService relationValidationService; + + /// 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); + } + + /// 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); + } + + /// 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 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, + 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)); + + propertyValidationService.validatePropertiesAgainstTemplate(template, definitions, propertiesByName, violations); + + relationValidationService.validateRelationsAgainstTemplate(template, entity.relations(), violations); + + 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 00000000..3aeb9b44 --- /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. +public final class Violations { + private final List messages = new ArrayList<>(); + void add(String message) { + messages.add(message); + } + public 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`. + public 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_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java new file mode 100644 index 00000000..af23ff72 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -0,0 +1,176 @@ +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; + +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; +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 +/// - 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 { + + 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. + /// + /// @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 + /// @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); + + Entity rootEntity = entityRepositoryPort + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + + Map entityMap = entityGraphRepositoryPort + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); + + EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); + + // 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). + /// + /// [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, + 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). + // 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(), + 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(), + leafProperties, List.of(), List.of()); + } + + List outboundRelations = entity.relations().stream() + .map(relation -> new EntityGraphRelation( + relation.name(), + relation.targetEntityIdentifiers().stream() + .map(targetId -> buildGraphNode( + findKeyByIdentifier(targetId, entityMap), + entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) + .toList() + )) + .toList(); + + List inboundRelations = buildRelationsAsTargetFromMap( + entity.identifier(), entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); + + 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. + /// 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. + /// Passes [visitedNodeIds] through so that source nodes already expanded elsewhere are not + /// re-expanded here, preventing the mutual recursion that causes OOM at high depths. + private List buildRelationsAsTargetFromMap(String targetIdentifier, + Map entityMap, + int remainingDepth, + 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 ArrayList<>()) + .add(entry.getKey()); + } + } + } + + return sourcesByRelationName.entrySet().stream() + .map(e -> new EntityGraphRelation( + e.getKey(), + e.getValue().stream() + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, + includeProperties, visitedNodeIds)) + .toList() + )) + .toList(); + } +} 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 8b2b6776..3528e14c 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 cd1d6c77..79647cc9 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 00000000..25446ecf --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -0,0 +1,172 @@ +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_REQUIRED_MISSING; +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.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. + */ +@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); + }; + } + + /// 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)) { + 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/domain/service/relation/RelationValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java new file mode 100644 index 00000000..4cd92e81 --- /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/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 e81360a7..d71c3287 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,12 +10,11 @@ public record CorsProperties( List allowedOrigins, List allowedOriginPatterns ) { + /// 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 53aacc8a..babf3393 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"; @@ -63,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 @@ -78,11 +83,16 @@ 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_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"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; // --- Schema (class) descriptions --- @@ -95,6 +105,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 +116,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"; @@ -132,4 +151,33 @@ 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"; + + // --- 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 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"; + 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/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 3f94e446..9939cd99 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,23 +9,46 @@ 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.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; 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_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; +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 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 lombok.AllArgsConstructor; 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; @@ -33,25 +57,30 @@ 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.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.service.EntityService; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +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.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; 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. /// @@ -65,11 +94,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. /// @@ -77,14 +110,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 +138,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)) }) - @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{templateIdentifier}/{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,18 +161,94 @@ 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); } + + /// 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}/{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); + } + + /// 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/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java new file mode 100644 index 00000000..7ea3de4a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -0,0 +1,99 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +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.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.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; +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; +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.EntityGraphFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; +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; +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 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 +@Tag(name = "Entity Graph", description = "Entity relationship graph operations") +public class EntityGraphController { + + private final EntityGraphService entityGraphService; + + /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. + /// + /// 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) + /// @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) + @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 getEntityGraph( + @PathVariable @NotBlank String templateIdentifier, + @PathVariable @NotBlank String entityIdentifier, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) + @RequestParam(defaultValue = "1") int depth, + @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) + @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_RELATIONS_DESCRIPTION) + @RequestParam(required = false) List relations, + @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) + @RequestParam(required = false) List properties) { + + // 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, propertyFilter); + } +} 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 fa0f947d..57797136 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/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 33bd3566..75877117 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/dto/in/EntitySearchRequestDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java new file mode 100644 index 00000000..48e132f5 --- /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 00000000..cec644b4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java @@ -0,0 +1,42 @@ +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; + +/// 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) { + + 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/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 00000000..c61800dc --- /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 00000000..aa43eb8a --- /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/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java new file mode 100644 index 00000000..c1fa208c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -0,0 +1,49 @@ +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_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; + +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. +/// +/// 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( + + @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, + + @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/RelationAsTargetSummaryDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java new file mode 100644 index 00000000..1e734bd1 --- /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/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 70ca673b..e5d63784 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 @@ -1,29 +1,35 @@ package com.decathlon.idp_core.infrastructure.adapters.api.handler; +import static org.springframework.http.HttpStatus.NOT_FOUND; + import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; 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.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; +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.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -31,9 +37,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; /// Global exception handler providing centralized error handling for all API endpoints. /// @@ -67,6 +70,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 @@ -181,6 +194,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 +279,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 a5e6b8f8..5548ec05 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 2bd2b04a..20e59724 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; @@ -66,17 +65,67 @@ 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 /// 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( @@ -88,13 +137,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,13 +168,12 @@ 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); @@ -145,12 +192,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(); } @@ -162,63 +209,55 @@ private Map mapPropertiesDto(Entity entity, EntityTemplate entit .filter(prop -> prop.value() != null) .collect(Collectors.toMap( Property::name, - prop -> convertPropertyValue(prop, propertiesDefinitions.get(prop.name())))); - } - - /// Converts a property value to its typed representation based on the property definition. - /// - /// @param property the property to convert - /// @param definition the property definition for type information, may be null - /// @return the typed value, falling back to the raw string value - private Object convertPropertyValue(Property property, PropertyDefinition definition) { - String value = property.value(); - if (definition == null) { - return value; - } - PropertyType type = definition.type(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(value); - } catch (NumberFormatException _) { - return value; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(value); - } - return value; + prop -> { + PropertyDefinition def = propertiesDefinitions.get(prop.name()); + 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; + } + } 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()))); } - /// /// 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 +270,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 +288,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 +303,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 @@ -275,13 +311,11 @@ 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())); } - /// - /// 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 @@ -290,13 +324,11 @@ 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())); - } - /// 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 @@ -313,10 +345,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/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 00000000..8b90f8bc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -0,0 +1,180 @@ +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.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; +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 + } + + /// 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; + /// 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, + Set propertyFilter) { + if (root == null) { + return new EntityGraphFlatDtoOut(List.of(), List.of()); + } + + 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, state, relationFilter, propertyFilter); + + // When a relation filter is active, prune nodes that are not connected to any + // 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(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 : state.edges()) { + referencedNodeIds.add(edge.source()); + referencedNodeIds.add(edge.target()); + } + finalNodes = state.nodes().stream() + .filter(n -> referencedNodeIds.contains(n.id())) + .toList(); + } + + return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); + } + + private static void traverse( + EntityGraphNode node, + 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 (!state.visitedNodeIds().add(nodeId)) { + return; + } + + state.nodes().add(new EntityGraphNodeFlatDtoOut( + nodeId, node.name(), node.templateIdentifier(), node.identifier(), + 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 + // 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()); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(state, nodeId, targetId, relation.name()); + } + traverse(target, state, relationFilter, propertyFilter); + } + } + + // 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()); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(state, sourceId, nodeId, relation.name()); + } + traverse(source, state, relationFilter, propertyFilter); + } + } + } + + /// 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( + TraversalState state, + String sourceId, + String targetId, + String label) { + + var signature = sourceId + "|" + targetId + "|" + label; + if (state.emittedEdgeSignatures().add(signature)) { + state.edges().add(new EntityGraphEdgeDtoOut( + "e" + state.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; + } + + /// Converts a node's property list to a name→value map for the `data` field. + /// + /// 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/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 00000000..6c58f903 --- /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", "relations_as_target"); + 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 680c4821..b4251b4f 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; @@ -40,9 +44,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 @@ -64,4 +75,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/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java new file mode 100644 index 00000000..d48828c7 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -0,0 +1,79 @@ +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 org.springframework.transaction.annotation.Transactional; + +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 + @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. + // 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); + + 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 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); + if (includeProperties) { + 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/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 120fd654..cc7edac1 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 cd3f143e..848693df 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 44e8ac93..0a8eb40e 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); @@ -27,6 +28,10 @@ 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); @Modifying(clearAutomatically = true, flushAutomatically = true) @@ -54,4 +59,114 @@ 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); + + /// 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/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 6c2c5434..57c0c666 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 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 00000000..55045300 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -0,0 +1,385 @@ +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.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}` → 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]) 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 { + + 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 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."; + 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. + /// 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) { + // No toLowerCase() needed — ILIKE is inherently case-insensitive. + String escaped = escapeLikeWildcards(query); + String pattern = "%" + escaped + "%"; + + Specification byIdentifier = + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(IDENTIFIER), pattern, LIKE_ESCAPE_CHAR); + + Specification byName = + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(NAME), pattern, LIKE_ESCAPE_CHAR); + + Specification byTemplate = + (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? + 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")), + ((HibernateCriteriaBuilder) cb).ilike(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 (RELATIONS_AS_TARGET.equals(field)) { + return relationsAsTargetNameSpec(c); + } + 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) -> { + // 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); + }; + } + + // --- Relation specs --- + + private static Specification relationNameSpec(SearchFilterNode.Criterion c) { + return (root, query, cb) -> { + // 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); + }; + } + + 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) -> { + // 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) -> { + // 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); + }; + } + + // --- 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('.'); + 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)); + } + 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 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); + yield hcb.ilike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case NOT_CONTAINS -> { + String escaped = escapeLikeWildcards(value); + yield hcb.notIlike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case STARTS_WITH -> { + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, escaped + "%", LIKE_ESCAPE_CHAR); + } + case ENDS_WITH -> { + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(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. + /// 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)) + .replace("%", LIKE_ESCAPE_CHAR + "%") + .replace("_", LIKE_ESCAPE_CHAR + "_"); + } + +} 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 00000000..11255aa8 --- /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/main/resources/db/migration/V3_5__add_search_performance_indexes.sql b/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql new file mode 100644 index 00000000..21aa28ef --- /dev/null +++ b/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql @@ -0,0 +1,75 @@ +-- Flyway migration script: add search performance indexes +-- Purpose: Accelerate the search endpoint with GIN trigram indexes (ILIKE pattern matching) +-- and functional btree indexes (EQ/NEQ equality matching). +-- +-- Strategy: +-- - 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 +-- ========================================================================= + +-- 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'; + +-- 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'; + +-- 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); + +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)); + +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) 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 a0a35cfc..8142739a 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/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 00000000..403f6bc7 --- /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/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java new file mode 100644 index 00000000..7a9ca334 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -0,0 +1,222 @@ +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.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; + +@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); + } + + @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/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java new file mode 100644 index 00000000..7a4711d5 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -0,0 +1,162 @@ +package com.decathlon.idp_core.domain.service.entity; + +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.when; + +import java.util.Collections; +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.Test; +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; +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.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") +class EntityValidationServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private RelationValidationService relationValidationService; + + @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(any(), any()); + } + + @Test + @DisplayName("Should validate entity successfully by delegating to property and relation validation services") + void shouldValidateForCreationSuccessfullyWhenNoViolations() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + 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(property), + List.of(relation)); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + + verify(propertyValidationService).validatePropertiesAgainstTemplate( + eq(template), + eq(template.propertiesDefinitions()), + eq(Map.of("version", property)), + any(Violations.class) + ); + + verify(relationValidationService).validateRelationsAgainstTemplate( + eq(template), + eq(entity.relations()), + any(Violations.class) + ); + } + + @Test + @DisplayName("Should throw EntityValidationException when delegated validations populate the Violations aggregate") + void shouldThrowEntityValidationExceptionWhenViolationsExist() { + 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()); + + 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")); + })) { + + var exception = assertThrows(EntityValidationException.class, + () -> entityValidationService.validateForCreation(entity, template)); + + assertEquals(2, exception.getViolations().size()); + assertEquals("Delegated property error", exception.getViolations().get(0)); + + verify(propertyValidationService).validatePropertiesAgainstTemplate(eq(template), any(), any(), any()); + verify(relationValidationService).validateRelationsAgainstTemplate(eq(template), any(), any()); + } + } + + 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/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java new file mode 100644 index 00000000..0f6b50f2 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -0,0 +1,378 @@ +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.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.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.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityGraphService Tests") +class EntityGraphServiceTest { + + @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, 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("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", 1, false)) + .isInstanceOf(EntityNotFoundException.class); + + verify(entityGraphRepositoryPort, never()) + .findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean()); + } + } + + // ======================== + @Nested + @DisplayName("Single Root — No Relations") + class SingleRootNoRelations { + + @Test + @DisplayName("Should return leaf node when entity has no relations") + void shouldReturnLeafNodeWhenNoRelations() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.identifier()).isEqualTo("api"); + assertThat(result.name()).isEqualTo("API Service"); + assertThat(result.relations()).isEmpty(); + assertThat(result.relationsAsTarget()).isEmpty(); + } + } + + // ======================== + @Nested + @DisplayName("Outbound Relations") + class OutboundRelations { + + @Test + @DisplayName("Should resolve outbound relation targets at depth 1") + void shouldResolveOutboundRelations() { + 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)); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relations()).hasSize(1); + 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 return fallback node when target is not in the pre-loaded entity map") + void shouldReturnFallbackNodeWhenTargetNotInMap() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "missing-db"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relations()).hasSize(1); + EntityGraphNode fallback = result.relations().get(0).targets().get(0); + assertThat(fallback.identifier()).isEqualTo("missing-db"); + } + } + + // ======================== + @Nested + @DisplayName("Inbound Relations (relationsAsTarget)") + class InboundRelations { + + @Test + @DisplayName("Should resolve inbound relations when another entity points to root") + void shouldResolveInboundRelations() { + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key(TEMPLATE, "consumer"), consumer)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()).isEqualTo("consumer"); + } + } + + // ======================== + @Nested + @DisplayName("Depth Clamping") + class DepthClamping { + + @Test + @DisplayName("Should clamp depth below 1 to 1") + void shouldClampDepthBelowOne() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); + } + + @Test + @DisplayName("Should clamp depth above MAX_DEPTH to MAX_DEPTH") + void shouldClampDepthAboveTen() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); + } + } + + // ======================== + @Nested + @DisplayName("Depth Limit — Leaf Nodes at Boundary") + class DepthLimit { + + @Test + @DisplayName("Should return target as leaf node when depth limit is reached") + void shouldReturnLeafNodeAtDepthBoundary() { + 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)); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres, + key("infra", "server-1"), server)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + 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("Multiple Named Relations") + class MultipleRelations { + + @Test + @DisplayName("Should resolve multiple distinct relation types") + void shouldResolveMultipleNamedRelations() { + 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)); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres, + key(TEMPLATE, "auth"), auth)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relations()).hasSize(2); + 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/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java new file mode 100644 index 00000000..a73c90e6 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -0,0 +1,454 @@ +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; +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.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 { + + @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 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() { + 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 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() { + 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 { + + @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); + + 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/domain/service/relation/RelationValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java new file mode 100644 index 00000000..447cd9e8 --- /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); + } + } +} 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 9675a337..97eb0ca9 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,8 +1,11 @@ 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; +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; @@ -10,28 +13,30 @@ 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 - /// 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_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 + private MockMvc mockMvc; /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated /// retrieval). @@ -44,14 +49,14 @@ 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()) - .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)) @@ -63,7 +68,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 +76,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 +86,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,12 +105,12 @@ 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()) - .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)) @@ -113,10 +118,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 @@ -124,7 +129,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 +141,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 +150,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 +164,935 @@ 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"))) + .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") + ))); + } + + } + + @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": "9090", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "PYTHON" + }, + "relations": [ + { + "name": "database", + "target_entity_identifiers": ["cache-service-1"] + } + ] + } + """; + + 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()); + } + } + + @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(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) - .andExpect(status().isCreated()) + .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 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 + 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/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java new file mode 100644 index 00000000..ca30cf21 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -0,0 +1,223 @@ +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()); + } + } + + @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/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 f8a5a022..f8397e78 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") @@ -570,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") 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 880ade35..dd840bca 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/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 00000000..2c8f76f1 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntitySearchDomainMapperTest.java @@ -0,0 +1,384 @@ +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' 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() { + 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 00000000..45c05bd6 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java @@ -0,0 +1,303 @@ +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 '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() { + 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/init/init-extensions.sql b/src/test/resources/db/init/init-extensions.sql new file mode 100644 index 00000000..144c6baf --- /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; 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 bab7cb5a..2bc54599 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; 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 45e62ff0..267370e0 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,8 @@ --- Insert sample entities into idp_core.entity -INSERT INTO idp_core.entity (id, identifier, name, template_identifier) +-- ----------------------------------------------------------------------- +-- 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'), @@ -16,3 +19,144 @@ 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) +-- ----------------------------------------------------------------------- + +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'), + ('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-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 00000000..4c00a50a --- /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 00000000..996e5608 --- /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 82367a22..5b947d10 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": "PYTHON" + } } 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 00000000..678a6bf1 --- /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 00000000..86438624 --- /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 00000000..20e6fe29 --- /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 00000000..7c0b057e --- /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 00000000..570184e7 --- /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 00000000..9bb5cbcc --- /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 00000000..e850f2b8 --- /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": [] +} 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 00000000..0e0d5c7e --- /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 00000000..ee724e9c --- /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 00000000..0e0d5c7e --- /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 00000000..17f5db1b --- /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 00000000..22b30c3a --- /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 00000000..0773375e --- /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_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 00000000..d4c56ed1 --- /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 00000000..0199b84e --- /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 +} 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 00000000..9c8bf925 --- /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 00000000..302a750a --- /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 00000000..2b5f2bc2 --- /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 00000000..72591748 --- /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 } +}