diff --git a/docs/src/concepts/index.md b/docs/src/concepts/index.md index 7ff6f3c2..764ec856 100644 --- a/docs/src/concepts/index.md +++ b/docs/src/concepts/index.md @@ -29,27 +29,31 @@ graph TB --- - Blueprints that define the structure of your entities—like database schemas defined at runtime. + Blueprints that define the structure of your entities—like database schemas defined at runtime. - 🗄️ **[Entities](entities.md)** --- - Instances of templates with actual data—your software catalog items. + Instances of templates with actual data—your software catalog items. - 📋 **[Properties](properties.md)** --- - Data fields with types, validation rules, and constraints. + Data fields with types, validation rules, and constraints. - 🔗 **[Relations](relations.md)** --- - Connections between entities forming a knowledge graph. + Connections between entities forming a knowledge graph. -- 🔍 **[Filtering Entities](entity-filtering.md)** +- 🌐 **[Webhooks](webhooks.md)** + + --- + + Runtime-configurable connectors to push your data from external systems within your IDP. You can map any source in your data model in minutes!- 🔍 **[Filtering Entities](entity-filtering.md)** --- @@ -111,7 +115,7 @@ erDiagram ## Quick Reference | Concept | What It Is | Example | -| ------------------- | --------------------------------------------------- | --------------------------------------------------------- | +|---------------------|-----------------------------------------------------|-----------------------------------------------------------| | **Entity Template** | Blueprint/schema | `service`, `team`, `repository` | | **Entity** | Instance based on a template that contains the data | `payment-service`, `platform-team`, `idp-core-repository` | | **Property** | Data field | `name`, `status`, `url` | @@ -136,3 +140,4 @@ Dive deeper into each concept: - **[Entity Templates](entity-templates.md)** - Learn how to design your data model - **[Properties](properties.md)** - Understand property types and validation - **[Relations](relations.md)** - Connect your entities into a graph +- **[Webhooks](webhooks.md)** - Configure inbound integrations and security strategies diff --git a/docs/src/concepts/webhooks.md b/docs/src/concepts/webhooks.md new file mode 100644 index 00000000..e30ea8ed --- /dev/null +++ b/docs/src/concepts/webhooks.md @@ -0,0 +1,226 @@ +--- +title: Webhooks +description: Understand webhook connectors, security strategies, and dynamic mappings in IDP-Core +--- + +Webhooks let external systems push JSON events to the Internal Developer Platform through a generic HTTP endpoint. You configure a webhook connector at runtime, choose a security strategy, and define mappings that translate incoming payloads into entity data with JSLT expressions. + +## Overview + +A webhook connector combines three concerns: + +- **Connector metadata** - Identifier, title, description, and enabled flag +- **Security** - How IDP-Core authenticates incoming requests +- **Mappings** - How the payload maps to an Entity Template + +```mermaid +flowchart LR + S[External system] --> E[POST /webhooks/{configurationId}] + E --> H[InboundWebhookHandler] + H --> D[Security dispatcher] + D --> C[WebhookConnector] + C --> M[Dynamic mappings] + M --> T[Entity Template] +``` + +## Webhook Connector + +A webhook connector is the runtime configuration stored by IDP-Core for one inbound integration. + +| Field | Type | Description | +| --------------------- | ------- | ------------------------------------------------------ | +| `identifier` | String | Stable key used in the webhook URL and management APIs | +| `title` | String | Human-readable name | +| `description` | String | Optional explanation of the connector purpose | +| `enabled` | Boolean | Enables or disables request processing | +| `mapping_identifiers` | Array | One or more dynamic mapping identifier | +| `security` | Object | Authentication strategy and configuration | + +### Webhook Connector Example + +```json +{ + "identifier": "github-repositories", + "title": "GitHub repositories", + "description": "Receives repository events from GitHub", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_WEBHOOK_SECRET", + "prefix": "sha256=" + } + } +} +``` + +## Dynamic Mappings + +Each connector contains at least one dynamic mapping. A mapping targets one Entity Template and describes how to derive entity fields from the incoming JSON payload with a JSLT filter and entity projections. + +| Field | Type | Description | +| ------------- | ------ | --------------------------------------------------------------------------- | +| `template` | String | Identifier of the target Entity Template | +| `identifier` | String | Stable and unique key for this specific mapping | +| `name` | String | Human-readable name of the mapping | +| `description` | String | Optional explanation of the mapping purpose | +| `filter` | String | JSLT boolean expression to evaluate if the payload should be processed | +| `entity` | Object | JSLT projections defining how to map the payload to the entity's attributes | + +### Dynamic Mapping Example + +```json +{ + "template": "github_repository", + "identifier": "mapping-github", + "name": "mapping github", + "description": "mapping github description", + "filter": ".repository != null", + "entity": { + "identifier": "replace(.repository.name, \" \", \"-\")", + "title": ".repository.name", + "properties": { + "name": ".repository.name", + "url": ".repository.html_url", + "stars": "\"\" + .repository.stargazers_count", + "is_public": "if (.repository.private) \"false\" else \"true\"" + }, + "relations": {} + } +} +``` + +### Validation Rules + +When you create or update a connector, the IDP validates each mapping against the target Entity Template. + +It checks that: + +- The referenced template exists +- Every mapped property exists in the template +- Every required property is mapped +- Every mapped relation exists in the template +- Every required relation is mapped + +This validation keeps the connector configuration aligned with the current data model. + +## Security Strategies + +Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests again at runtime. + +| Type | Required configuration keys | Runtime behavior | +| -------------- | --------------------------------------- | -------------------------------------------------------------------------------------- | +| `HMAC_SHA256` | `header_name`, `secret_alias`, `prefix` | Computes the SHA-256 HMAC of the raw body and compares it with the request header | +| `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment | +| `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret | +| `JWT_BEARER` | `jwks_uri` | Validates the bearer token against a JWKS endpoint | +| `NONE` | none | Skips authentication | + +> [!IMPORTANT] +> Security configuration keys accept `snake_case` and `camelCase` variants for the supported fields. +> [!WARNING] +> `secret_alias` must reference an environment variable alias in `UPPER_SNAKE_CASE`. It does not store the raw secret value in the connector configuration. + +### Example Security Configurations + +=== "HMAC_SHA256" + +```json +{ + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_WEBHOOK_SECRET", + "prefix": "sha256=" + } +} +``` + +=== "STATIC_TOKEN" + +```json +{ + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Webhook-Token", + "secret_alias": "WEBHOOK_SHARED_TOKEN" + } +} +``` + +=== "BASIC_AUTH" + +```json +{ + "type": "BASIC_AUTH", + "config": { + "username": "webhook-user", + "secret_alias": "WEBHOOK_PASSWORD" + } +} +``` + +=== "JWT_BEARER" + +```json +{ + "type": "JWT_BEARER", + "config": { + "jwks_uri": "https://issuer.example.com/.well-known/jwks.json" + } +} +``` + +## Runtime Flow + +The webhook runtime uses a single generic endpoint: + +```text +POST /webhooks/{configurationId} +``` + +The request flow is: + +1. IDP-Core receives the request on the generic webhook endpoint. +2. The `configurationId` resolves the stored `WebhookConnector`. +3. If the connector is disabled, IDP-Core ignores the event. +4. The security dispatcher selects the matching strategy for the connector security type. +5. The strategy validates the headers and, when needed, the raw request body. +6. After authentication, the event is accepted for downstream processing. + +> [!IMPORTANT] +> The connector model, security validation, management APIs, and mapping validation are implemented now. + +## Management API Methods + +You manage webhook connectors through the inbound webhook management API, which exposes standard CRUD methods. + +| HTTP Method | Endpoint | Purpose | +| ----------- | ----------------------------------------- | ---------------- | +| `POST` | `/api/v1/inbound_webhooks` | Create connector | +| `GET` | `/api/v1/inbound_webhooks` | List connectors | +| `GET` | `/api/v1/inbound_webhooks/{identifier}` | Get connector | +| `PUT` | `/api/v1/inbound_webhooks/{identifier}` | Update connector | +| `DELETE` | `/api/v1/inbound_webhooks/{identifier}` | Delete connector | + +This separation keeps configuration management under versioned API routes while the event ingestion endpoint stays simple for external systems. + +## When to Use Webhooks + +Use webhooks when an external system can push JSON events over HTTP and you want to: + +- Ingest updates without redeploying IDP-Core +- Reuse one generic endpoint for multiple providers +- Apply connector-specific authentication rules +- Map external payloads to your own Entity Templates at runtime + +--- + +## Next Steps + +- **[Entity Templates](entity-templates.md)** - Define the target structures that mappings reference +- **[Entities](entities.md)** - Understand the records produced by successful ingestion +- **[Relations](relations.md)** - Model links that webhook mappings can populate +- **[Data Integration](../features/data-integration.md)** - Explore the broader ingestion roadmap diff --git a/docs/src/features/data-integration.md b/docs/src/features/data-integration.md index 131b2c7d..4018fa2d 100644 --- a/docs/src/features/data-integration.md +++ b/docs/src/features/data-integration.md @@ -14,7 +14,7 @@ The Internal Developer Platform provides flexible data integration, allowing you Data integration in the Internal Developer Platform follows a three-step pattern: 1. **Configure a connector** - Set up a Webhook, Kafka consumer, or Pub/Sub subscription -2. **Define mappings** - Use JQ expressions to transform incoming data +2. **Define mappings** - Use JSLT expressions to transform incoming data 3. **Ingest data** - Data flows automatically, creating and updating entities ```mermaid @@ -55,6 +55,19 @@ flowchart LR Webhooks allow external systems to push data to IDP-Core via HTTP POST requests. +### Methods + +| Method | Endpoint | Purpose | +| ------ | -------- | ------- | +| `POST` | `/webhooks/{configurationId}` | Receive an inbound event for the connector identified in the URL | +| `POST` | `/api/v1/inbound_webhooks` | Create a webhook connector configuration | +| `GET` | `/api/v1/inbound_webhooks` | List webhook connector configurations | +| `GET` | `/api/v1/inbound_webhooks/{identifier}` | Read one webhook connector configuration | +| `PUT` | `/api/v1/inbound_webhooks/{identifier}` | Update one webhook connector configuration | +| `DELETE` | `/api/v1/inbound_webhooks/{identifier}` | Delete one webhook connector configuration | + + These HTTP routes map to the `InboundWebhookManagementController` methods for connector management. + ### Webhook Configuration ```json @@ -83,33 +96,37 @@ Webhooks allow external systems to push data to IDP-Core via HTTP POST requests. } ], "security": { - "signature_header_name": "X-Sonar-Webhook-HMAC-SHA256", - "signature_value": "your-secret-token" + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Sonar-Webhook-HMAC-SHA256", + "secret_alias": "SONAR_WEBHOOK_SECRET", + "prefix": "sha256=" + } } } ``` ### Configuration Fields -| Field | Description | -| ------------- | ---------------------------- | -| `identifier` | Unique key for this webhook | -| `title` | Human-readable name | -| `description` | Purpose of the webhook | -| `enabled` | Toggle ingestion on/off | -| `mappings` | Array of mapping rules | -| `security` | Authentication configuration | +| Field | Description | +|---------------|-----------------------------------------------------------------| +| `identifier` | Unique key for this webhook | +| `title` | Human-readable name | +| `description` | Purpose of the webhook | +| `enabled` | Toggle ingestion on/off | +| `mappings` | Array of mapping rules | +| `security` | Authentication configuration using a `type` + `config` contract | ### Mapping Structure -| Field | Description | -| ------------------- | ------------------------------------------- | -| `template` | Target Entity Template identifier | -| `filter` | JQ expression to filter incoming payloads | -| `entity.identifier` | JQ expression to generate entity identifier | -| `entity.title` | JQ expression for entity title | -| `entity.properties` | Map of property names to JQ expressions | -| `entity.relations` | Map of relation names to JQ expressions | +| Field | Description | +|---------------------|-----------------------------------------------| +| `template` | Target Entity Template identifier | +| `filter` | JSLT expression to filter incoming payloads | +| `entity.identifier` | JSLT expression to generate entity identifier | +| `entity.title` | JSLT expression for entity title | +| `entity.properties` | Map of property names to JSLT expressions | +| `entity.relations` | Map of relation names to JSLT expressions | --- @@ -165,9 +182,9 @@ spring: --- -## JQ Mapping Reference +## JSLT Mapping Reference -The Internal Developer Platform will use [JQ](https://jqlang.github.io/jq/) for data transformation. It will access to the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Please refer to the JQ documentation for detailed usage. +The Internal Developer Platform uses [JSLT](https://github.com/schibsted/jslt) for data transformation. It accesses the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Refer to the JSLT documentation for detailed usage. --- @@ -201,8 +218,12 @@ Configure a webhook to receive GitHub repository events: } ], "security": { - "signature_header_name": "X-Hub-Signature-256", - "signature_value": "sha256=your-webhook-secret" + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_WEBHOOK_SECRET", + "prefix": "sha256=" + } } } ``` @@ -218,8 +239,11 @@ Webhooks support signature-based authentication: ```json { "security": { - "signature_header_name": "X-Webhook-Signature", - "signature_value": "expected-secret-or-hmac" + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Webhook-Signature", + "secret_alias": "WEBHOOK_SHARED_TOKEN" + } } } ``` diff --git a/docs/src/features/index.md b/docs/src/features/index.md index 32deb947..42a7a559 100644 --- a/docs/src/features/index.md +++ b/docs/src/features/index.md @@ -13,7 +13,7 @@ The Internal Developer Platform provides a comprehensive set of features to buil --- - Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JQ expressions. + Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JSLT expressions. **Status:** 🕐 Planned diff --git a/docs/src/features/self-service-actions.md b/docs/src/features/self-service-actions.md index 5330bb6a..518a65ba 100644 --- a/docs/src/features/self-service-actions.md +++ b/docs/src/features/self-service-actions.md @@ -348,7 +348,7 @@ Use template expressions to inject dynamic values: ```bash {{ .entity.identifier }} // Entity ID -{{ .entity.title }} // Entity title +{{ .entity.name }} // Entity name {{ .entity.properties.owner }} // Entity property {{ .entity.relations.team }} // Entity relation ``` diff --git a/docs/src/index.md b/docs/src/index.md index 3e7bb9ff..e19bd660 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -131,7 +131,7 @@ Define your own **Entity Templates** that mirror your organization's specific ne ### Multi-Source Data Ingestion -Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JQ expressions. +Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JSLT expressions. ### Scorecards & Metrics diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 07eb8ccc..28befc80 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -11,14 +11,200 @@ security: tags: - name: Entity Graph description: Entity relationship graph operations + - name: Inbound Webhook Management + description: Operations for managing inbound webhook connector configurations - name: Entities Management description: Operations related to entity management - - name: Entities Templates Management - description: Operations related to entity template management - name: Audit description: Operations related to audit history + - name: Entities Templates Management + description: Operations related to entity template management + - name: Entity dynamic mapping + description: Operations related to entity dynamic mapping management paths: - '/api/v1/entity-templates/{identifier}': + /api/v1/inbound_webhooks/{identifier}: + get: + tags: + - Inbound Webhook Management + summary: Get a webhook connector by identifier + description: Retrieve a specific webhook connector using its string identifier + operationId: getWebhookConnectorByIdentifier + parameters: + - name: identifier + in: path + required: true + schema: + type: string + responses: + "200": + description: Webhook connector found + content: + "*/*": + schema: + $ref: "#/components/schemas/InboundWebhookDtoOut" + "404": + description: Webhook connector not found with the provided identifier + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + put: + tags: + - Inbound Webhook Management + summary: Update an existing webhook connector by identifier + description: Update the details of an existing webhook connector identified by + its unique string identifier + operationId: putWebhookConnector + parameters: + - name: identifier + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/InboundWebhookUpdateDtoIn" + required: true + responses: + "200": + description: Webhook connector updated successfully + content: + "*/*": + schema: + $ref: "#/components/schemas/InboundWebhookDtoOut" + "400": + description: Invalid request payload + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Webhook connector not found with the provided identifier + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Webhook connector name already exists + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + tags: + - Inbound Webhook Management + summary: Delete a webhook connector by identifier + description: Remove a webhook connector from the system using its unique identifier + operationId: deleteWebhookConnector + parameters: + - name: identifier + in: path + required: true + schema: + type: string + responses: + "204": + description: Webhook connector deleted successfully + "404": + description: Webhook connector not found with the provided identifier + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/entity_dynamic_mappings/{identifier}: + get: + tags: + - Entity dynamic mapping + summary: Get an entity dynamic mapping by identifier + description: Retrieve an entity dynamic mapping using its string identifier + operationId: getEntityDynamicMappingByIdentifier + parameters: + - name: identifier + in: path + required: true + schema: + type: string + responses: + "200": + description: Entity dynamic mapping found + content: + "*/*": + schema: + $ref: "#/components/schemas/EntityDynamicMappingDtoOut" + "404": + description: Entity dynamic mapping not found with the provided identifier + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + put: + tags: + - Entity dynamic mapping + summary: Update an existing entity dynamic mapping by identifier + description: Update the details of an existing entity dynamic + mapping identified by its unique string identifier + operationId: updateEntityDynamicMapping + parameters: + - name: identifier + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EntityDynamicMappingUpdateDtoIn" + required: true + responses: + "200": + description: Entity dynamic mapping updated successfully + content: + "*/*": + schema: + $ref: "#/components/schemas/EntityDynamicMappingDtoOut" + "400": + description: Bad Request + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Entity dynamic mapping not found with the provided identifier + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Conflict + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + tags: + - Entity dynamic mapping + summary: Delete an entity dynamic mapping by identifier + description: Remove an entity dynamic from the system using its unique identifier + operationId: deleteEntityDynamicMapping + parameters: + - name: identifier + in: path + required: true + schema: + type: string + responses: + "204": + description: Entity dynamic mapping deleted successfully + "404": + description: Entity dynamic mapping not found with the provided identifier + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/entity-templates/{identifier}: get: tags: - Entities Templates Management @@ -32,24 +218,23 @@ paths: schema: type: string responses: - '200': + "200": description: Template found content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' - '404': + $ref: "#/components/schemas/EntityTemplateDtoOut" + "404": description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: "#/components/schemas/ErrorResponse" put: tags: - Entities Templates Management summary: Update an existing template by template identifier - description: >- - Update the details of an existing template identified by its unique + description: Update the details of an existing template identified by its unique string identifier operationId: updateTemplate parameters: @@ -62,21 +247,21 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' + $ref: "#/components/schemas/EntityTemplateUpdateDtoIn" required: true responses: - '200': + "200": description: Template update successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' - '404': + $ref: "#/components/schemas/EntityTemplateDtoOut" + "404": description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: "#/components/schemas/ErrorResponse" delete: tags: - Entities Templates Management @@ -90,22 +275,21 @@ paths: schema: type: string responses: - '204': + "204": description: Template deleted successfully - '404': + "404": description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '/api/v1/entities/{templateIdentifier}/{entityIdentifier}': + $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 + description: Retrieve a specific entity using its string identifier and its + template identifier operationId: getEntity parameters: - name: templateIdentifier @@ -119,18 +303,18 @@ paths: schema: type: string responses: - '200': + "200": description: Entity found content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' - '404': + $ref: "#/components/schemas/EntityDtoOut" + "404": description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: "#/components/schemas/ErrorResponse" put: tags: - Entities Management @@ -154,43 +338,42 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EntityUpdateDtoIn' + $ref: "#/components/schemas/EntityUpdateDtoIn" required: true responses: - '200': + "200": description: Entity updated successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' - '400': + $ref: "#/components/schemas/EntityDtoOut" + "400": description: Invalid entity data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '401': + $ref: "#/components/schemas/ErrorResponse" + "401": description: Unauthorized - Missing or invalid token - '403': + "403": description: Insufficient rights - '404': + "404": description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '500': + $ref: "#/components/schemas/ErrorResponse" + "500": description: Unexpected server-side failure content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: "#/components/schemas/ErrorResponse" delete: tags: - Entities Management summary: Delete an existing entity - description: >- - Delete an entity from the system using its template and entity + description: Delete an entity from the system using its template and entity identifiers. This operation removes the entity and automatically cleans up any relations from other entities that reference it. operationId: deleteEntity @@ -208,36 +391,193 @@ paths: type: string minLength: 1 responses: - '204': + "204": description: Entity deleted successfully - '400': + "400": description: Invalid entity data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '401': + $ref: "#/components/schemas/ErrorResponse" + "401": description: Unauthorized - Missing or invalid token - '403': + "403": description: Insufficient rights - '404': + "404": description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '409': + $ref: "#/components/schemas/ErrorResponse" + "409": description: Target entity has required relations content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '500': + $ref: "#/components/schemas/ErrorResponse" + "500": description: Unexpected server-side failure content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: "#/components/schemas/ErrorResponse" + /api/v1/inbound_webhooks: + get: + tags: + - Inbound Webhook Management + summary: Get paginated Webhook connectors + description: Retrieve a paginated list of webhook connectors with optional sorting + operationId: getWebhooksPaginated + parameters: + - name: page + in: query + description: Page number for pagination. Defaults to 0. + content: + "*/*": + schema: + type: integer + default: "0" + - name: size + in: query + description: Number of items per page. Defaults to 20. + content: + "*/*": + schema: + type: integer + default: "20" + - name: sort + in: query + description: "Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc." + content: + "*/*": + schema: + type: string + default: identifier,asc + responses: + "200": + description: Paginated webhook connector retrieved successfully + content: + "*/*": + schema: + $ref: "#/components/schemas/WebhookConnectorPageResponse" + "400": + description: Invalid pagination parameters + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + post: + tags: + - Inbound Webhook Management + summary: Create a new webhook connector configuration + description: Creates a webhook connector configuration used by the generic + inbound webhook endpoint + operationId: createInboundWebhook + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/InboundWebhookCreateDtoIn" + required: true + responses: + "201": + description: Webhook connector created + content: + "*/*": + schema: + $ref: "#/components/schemas/InboundWebhookDtoOut" + "400": + description: Invalid webhook connector data provided + content: + "*/*": + schema: + $ref: "#/components/schemas/InboundWebhookDtoOut" + "409": + description: Webhook connector already exists in this entityTemplateIdentifier + content: + "*/*": + schema: + $ref: "#/components/schemas/InboundWebhookDtoOut" + /api/v1/entity_dynamic_mappings: + get: + tags: + - Entity dynamic mapping + summary: Get paginated entity dynamic mappings + description: Retrieve a paginated list of entity dynamic mappings with optional + sorting + operationId: getEntityDynamicMappingPaginated + parameters: + - name: page + in: query + description: Page number for pagination. Defaults to 0. + content: + "*/*": + schema: + type: integer + default: "0" + - name: size + in: query + description: Number of items per page. Defaults to 20. + content: + "*/*": + schema: + type: integer + default: "20" + - name: sort + in: query + description: "Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc." + content: + "*/*": + schema: + type: string + default: identifier,asc + responses: + "200": + description: Paginated entity dynamic mapping retrieved successfully + content: + "*/*": + schema: + $ref: "#/components/schemas/EntityDynamicMappingPageResponse" + "400": + description: Invalid pagination parameters + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + post: + tags: + - Entity dynamic mapping + summary: Create entity dynamic mapping + description: Creates a new entity dynamic mapping used by the generic inbound + webhook endpoint + operationId: createDynamicMapping + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EntityDynamicMappingCreateDtoIn" + required: true + responses: + "201": + description: Entity dynamic mapping created + content: + "*/*": + schema: + $ref: "#/components/schemas/EntityDynamicMappingDtoOut" + "400": + description: Invalid entity dynamic mapping data provided + content: + "*/*": + schema: + $ref: "#/components/schemas/EntityDynamicMappingDtoOut" + "409": + description: Identifier already exists + content: + "*/*": + schema: + $ref: "#/components/schemas/EntityDynamicMappingDtoOut" /api/v1/entity-templates: get: tags: @@ -250,41 +590,40 @@ paths: in: query description: Page number for pagination. Defaults to 0. content: - '*/*': + "*/*": schema: type: integer - default: '0' + default: "0" - name: size in: query description: Number of items per page. Defaults to 20. content: - '*/*': + "*/*": schema: type: integer - default: '20' + 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': + "200": description: Paginated templates retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/TemplatePageResponse' - '400': + $ref: "#/components/schemas/TemplatePageResponse" + "400": description: Invalid pagination parameters content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: "#/components/schemas/ErrorResponse" post: tags: - Entities Templates Management @@ -295,22 +634,22 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateCreateDtoIn' + $ref: "#/components/schemas/EntityTemplateCreateDtoIn" required: true responses: - '201': + "201": description: Template created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' - '400': + $ref: "#/components/schemas/EntityTemplateDtoOut" + "400": description: Invalid template data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '/api/v1/entities/{templateIdentifier}': + $ref: "#/components/schemas/ErrorResponse" + /api/v1/entities/{templateIdentifier}: get: tags: - Entities Management @@ -323,19 +662,19 @@ paths: description: Page number for pagination. Defaults to 0. required: false content: - '*/*': + "*/*": schema: type: integer - default: '0' + default: "0" - name: size in: query description: Number of items per page. Defaults to 20. required: false content: - '*/*': + "*/*": schema: type: integer - default: '20' + default: "20" - name: templateIdentifier in: path required: true @@ -349,32 +688,31 @@ paths: with names containing 'idp'. required: false content: - '*/*': + "*/*": schema: type: string - name: sort in: query - description: >- - Sorting criteria in the format: property(,asc|desc). Defaults to - identifier,asc. + description: "Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc." content: - '*/*': + "*/*": schema: type: string - default: 'identifier,asc' + default: identifier,asc responses: - '200': + "200": description: Paginated entities retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityPageResponse' - '400': + $ref: "#/components/schemas/EntityPageResponse" + "400": description: Invalid filter query syntax content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: "#/components/schemas/ErrorResponse" post: tags: - Entities Management @@ -392,80 +730,78 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EntityCreateDtoIn' + $ref: "#/components/schemas/EntityCreateDtoIn" required: true responses: - '201': + "201": description: Entity created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' - '400': + $ref: "#/components/schemas/EntityDtoOut" + "400": description: Invalid entity data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '401': + $ref: "#/components/schemas/ErrorResponse" + "401": description: Unauthorized - Missing or invalid token - '403': + "403": description: Insufficient rights - '404': + "404": description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '409': + $ref: "#/components/schemas/ErrorResponse" + "409": description: Entity already exists in this template content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '500': + $ref: "#/components/schemas/ErrorResponse" + "500": description: Unexpected server-side failure content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: "#/components/schemas/ErrorResponse" /api/v1/entities/search: post: tags: - Entities Management summary: Search entities - description: >- - Search for entities across all templates using nested filter queries. - Supports complex logical compositions (AND / OR) of filter criteria on - template, identifier, name, properties, relations, and reverse - relations. + description: Search for entities across all templates using nested filter + queries. Supports complex logical compositions (AND / OR) of filter + criteria on template, identifier, name, properties, relations, and + reverse relations. operationId: searchEntities requestBody: content: application/json: schema: - $ref: '#/components/schemas/EntitySearchRequestDtoIn' + $ref: "#/components/schemas/EntitySearchRequestDtoIn" required: true responses: - '200': + "200": description: Entities retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityPageResponse' - '400': + $ref: "#/components/schemas/EntityPageResponse" + "400": description: Invalid search filter content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph': + $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 + 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 @@ -484,9 +820,8 @@ paths: minLength: 1 - name: depth in: query - description: >- - Maximum traversal depth for relationship resolution. Clamped between - 1 and 10. + description: Maximum traversal depth for relationship resolution. Clamped + between 1 and 6. required: false schema: type: integer @@ -494,8 +829,7 @@ paths: default: 1 - name: include_data in: query - description: >- - When true, each graph node includes a data object containing the + description: When true, each graph node includes a data object containing the entity's property values. Defaults to false. required: false schema: @@ -503,8 +837,7 @@ paths: default: false - name: traversal_mode in: query - description: >- - Specifies the traversal mode for the entity graph. Defaults to + description: Specifies the traversal mode for the entity graph. Defaults to DIRECT_LINEAGE. required: false schema: @@ -516,8 +849,7 @@ paths: - OUTBOUND_ONLY - name: relations in: query - description: >- - When provided, only relations whose name matches one of the listed + 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: @@ -526,8 +858,7 @@ paths: type: string - name: properties in: query - description: >- - When provided, each node's data object is restricted to the listed + 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 @@ -536,24 +867,24 @@ paths: items: type: string responses: - '200': + "200": description: Flat entity graph successfully retrieved content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityGraphFlatDtoOut' - '404': + $ref: "#/components/schemas/EntityGraphFlatDtoOut" + "404": description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: "#/components/schemas/ErrorResponse" /api/v1/audit/entities/{templateIdentifier}/{entityIdentifier}: get: tags: - Audit - summary: Get audit history - description: Retrieve the complete audit history for a specific object, + summary: Get entity audit history + description: Retrieve the complete audit history for a specific entity, including all revisions with timestamps and modification types operationId: getEntityAuditHistory parameters: @@ -568,41 +899,179 @@ paths: required: true schema: type: string - minLength: 1 - responses: - "200": - description: Successfully retrieved entity audit history - content: - "*/*": - schema: - type: array - items: - $ref: "#/components/schemas/EntityAuditDtoOut" - "400": - description: Invalid template or entity identifier - 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 or Entity not found - with the provided identifier - content: - "*/*": - schema: - $ref: "#/components/schemas/ErrorResponse" - "500": - description: Unexpected server-side failure - content: - "*/*": - schema: - $ref: "#/components/schemas/ErrorResponse" -components: - schemas: + minLength: 1 + responses: + "200": + description: Successfully retrieved entity audit history + content: + "*/*": + schema: + type: array + items: + $ref: "#/components/schemas/EntityAuditDtoOut" + "400": + description: Invalid template or entity identifier + 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 or Entity not found + with the provided identifier + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Unexpected server-side failure + content: + "*/*": + schema: + $ref: "#/components/schemas/ErrorResponse" +components: + schemas: + InboundWebhookSecurityContractDtoIn: + type: object + properties: + type: + type: string + minLength: 1 + config: + type: object + additionalProperties: + type: string + required: + - config + - type + InboundWebhookUpdateDtoIn: + type: object + properties: + name: + type: string + maxLength: 255 + minLength: 0 + description: + type: string + enabled: + type: boolean + mapping_identifiers: + type: array + items: + type: string + security: + $ref: "#/components/schemas/InboundWebhookSecurityContractDtoIn" + required: + - name + EntityDynamicMappingDtoOut: + type: object + properties: + identifier: + type: string + entityTemplateIdentifier: + type: string + filter: + type: string + name: + type: string + description: + type: string + entity: + $ref: "#/components/schemas/InboundWebhookEntityMappingDtoOut" + InboundWebhookDtoOut: + type: object + properties: + identifier: + type: string + name: + type: string + description: + type: string + enabled: + type: boolean + mappings: + type: array + items: + $ref: "#/components/schemas/EntityDynamicMappingDtoOut" + security: + $ref: "#/components/schemas/InboundWebhookSecurityDtoOut" + InboundWebhookEntityMappingDtoOut: + type: object + properties: + identifier: + type: string + name: + type: string + properties: + type: object + additionalProperties: + type: string + relations: + type: object + additionalProperties: + type: string + InboundWebhookSecurityDtoOut: + type: object + properties: + type: + type: string + config: + type: object + additionalProperties: + type: string + ErrorResponse: + type: object + properties: + error: + type: string + errorDescription: + type: string + EntityDynamicMappingUpdateDtoIn: + type: object + properties: + entity_template_identifier: + type: string + minLength: 1 + filter: + type: string + minLength: 1 + name: + type: string + minLength: 1 + description: + type: string + entity: + $ref: "#/components/schemas/EntityMappingDtoIn" + required: + - entity + - entity_template_identifier + - filter + - name + EntityMappingDtoIn: + type: object + properties: + identifier: + type: string + minLength: 1 + name: + type: string + minLength: 1 + properties: + type: object + additionalProperties: + type: string + relations: + type: object + additionalProperties: + type: string + required: + - identifier + - name + - properties + - relations EntityTemplateUpdateDtoIn: type: object description: Input DTO for updating an entity template @@ -613,7 +1082,7 @@ components: example: Service maxLength: 255 minLength: 0 - pattern: '^[a-zA-Z0-9 _-]+$' + pattern: ^[a-zA-Z0-9 _-]+$ description: type: string description: Entity Template description @@ -622,12 +1091,12 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' + $ref: "#/components/schemas/PropertyDefinitionDtoIn" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' + $ref: "#/components/schemas/RelationDefinitionDtoIn" required: - name PropertyDefinitionDtoIn: @@ -658,7 +1127,7 @@ components: description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoIn' + $ref: "#/components/schemas/PropertyRulesDtoIn" description: Property validation rules required: - description @@ -686,7 +1155,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 @@ -754,12 +1223,12 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoOut' + $ref: "#/components/schemas/PropertyDefinitionDtoOut" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoOut' + $ref: "#/components/schemas/RelationDefinitionDtoOut" PropertyDefinitionDtoOut: type: object description: Output DTO for property definition @@ -810,7 +1279,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 @@ -851,13 +1320,6 @@ components: type: boolean description: Whether this relation can have multiple targets example: true - ErrorResponse: - type: object - properties: - error: - type: string - errorDescription: - type: string EntityUpdateDtoIn: type: object description: Input DTO for updating an entity @@ -873,13 +1335,13 @@ components: type: string description: Map of property name to value for this entity example: - port: '8080' + port: "8080" environment: dev relations: type: array description: List of relations for this entity items: - $ref: '#/components/schemas/RelationDtoIn' + $ref: "#/components/schemas/RelationDtoIn" required: - name RelationDtoIn: @@ -919,13 +1381,13 @@ components: additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + $ref: "#/components/schemas/EntitySummaryDto" relations_as_target: type: object additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + $ref: "#/components/schemas/EntitySummaryDto" EntitySummaryDto: type: object properties: @@ -933,6 +1395,55 @@ components: type: string name: type: string + InboundWebhookCreateDtoIn: + type: object + properties: + identifier: + type: string + maxLength: 255 + minLength: 0 + name: + type: string + maxLength: 255 + minLength: 0 + description: + type: string + enabled: + type: boolean + mapping_identifiers: + type: array + items: + type: string + security: + $ref: "#/components/schemas/InboundWebhookSecurityContractDtoIn" + required: + - identifier + - name + EntityDynamicMappingCreateDtoIn: + type: object + properties: + identifier: + type: string + minLength: 1 + entity_template_identifier: + type: string + minLength: 1 + filter: + type: string + minLength: 1 + name: + type: string + minLength: 1 + description: + type: string + entity: + $ref: "#/components/schemas/EntityMappingDtoIn" + required: + - entity + - entity_template_identifier + - filter + - identifier + - name EntityTemplateCreateDtoIn: type: object description: Input DTO for creating an entity template @@ -948,7 +1459,7 @@ components: example: Service maxLength: 255 minLength: 0 - pattern: '^[a-zA-Z0-9 _-]+$' + pattern: ^[a-zA-Z0-9 _-]+$ description: type: string description: Entity Template description @@ -957,12 +1468,12 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' + $ref: "#/components/schemas/PropertyDefinitionDtoIn" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' + $ref: "#/components/schemas/RelationDefinitionDtoIn" required: - identifier - name @@ -986,13 +1497,13 @@ components: type: string description: Map of property name to value for this entity example: - port: '8080' + port: "8080" environment: dev relations: type: array description: List of relations for this entity items: - $ref: '#/components/schemas/RelationDtoIn' + $ref: "#/components/schemas/RelationDtoIn" required: - identifier - name @@ -1002,15 +1513,13 @@ components: properties: query: type: string - description: >- - Free-text search string. When present, returns entities whose + 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 + $ref: "#/components/schemas/FilterNodeDtoIn" + description: Root node of the search filter tree. May be omitted or null to return all entities. page: type: integer @@ -1026,48 +1535,41 @@ components: example: 20 sort: type: string - description: >- - Sorting criteria in the format: property(,asc|desc). Defaults to - identifier,asc. - example: 'identifier:asc' + description: "Sorting criteria in the format: property(,asc|desc). Defaults to + 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). + 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. + 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 + description: Child filter nodes for a group node. Required for group nodes (must be non-empty). items: - $ref: '#/components/schemas/FilterNodeDtoIn' + $ref: "#/components/schemas/FilterNodeDtoIn" field: type: string - description: >- - Field to filter on for a criterion node. Required for leaf nodes. + 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 + relations_as_target.api-link.name" example: template operation: type: string - description: >- - Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, + 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. + leaf nodes." example: EQ value: type: string - description: >- - Value to compare against for a criterion node. Required for leaf + description: Value to compare against for a criterion node. Required for leaf nodes. example: microservice EntityPageResponse: @@ -1077,22 +1579,22 @@ components: content: type: array items: - $ref: '#/components/schemas/EntityDtoOut' + $ref: "#/components/schemas/EntityDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + $ref: "#/components/schemas/PageableObject" + last: + type: boolean totalElements: type: integer format: int64 totalPages: type: integer format: int32 - last: - type: boolean - sort: - $ref: '#/components/schemas/SortObject' numberOfElements: type: integer format: int32 + sort: + $ref: "#/components/schemas/SortObject" first: type: boolean size: @@ -1107,7 +1609,7 @@ components: type: object properties: sort: - $ref: '#/components/schemas/SortObject' + $ref: "#/components/schemas/SortObject" paged: type: boolean pageNumber: @@ -1130,29 +1632,95 @@ components: type: boolean empty: type: boolean - TemplatePageResponse: + WebhookConnectorPageResponse: type: object - description: Paginated response containing Template objects + description: Paginated response containing Inbound Webhook Connector objects properties: content: type: array items: - $ref: '#/components/schemas/EntityTemplateDtoOut' + $ref: "#/components/schemas/InboundWebhookDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + $ref: "#/components/schemas/PageableObject" + last: + type: boolean totalElements: type: integer format: int64 totalPages: type: integer format: int32 + numberOfElements: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + first: + type: boolean + size: + type: integer + format: int32 + number: + type: integer + format: int32 + empty: + type: boolean + EntityDynamicMappingPageResponse: + type: object + description: Paginated response containing Entity Dynamic Mapping objects + properties: + content: + type: array + items: + $ref: "#/components/schemas/EntityDynamicMappingDtoOut" + pageable: + $ref: "#/components/schemas/PageableObject" last: type: boolean + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 sort: - $ref: '#/components/schemas/SortObject' + $ref: "#/components/schemas/SortObject" + first: + type: boolean + size: + type: integer + format: int32 + number: + type: integer + format: int32 + empty: + type: boolean + TemplatePageResponse: + type: object + description: Paginated response containing Template objects + properties: + content: + type: array + items: + $ref: "#/components/schemas/EntityTemplateDtoOut" + pageable: + $ref: "#/components/schemas/PageableObject" + last: + type: boolean + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 numberOfElements: type: integer format: int32 + sort: + $ref: "#/components/schemas/SortObject" first: type: boolean size: @@ -1163,6 +1731,54 @@ components: 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 EntityAuditDtoOut: type: object description: Audit information for an entity revision @@ -1191,11 +1807,6 @@ components: type: object description: Snapshot of entity state at a specific audit revision properties: - id: - type: string - format: uuid - description: Unique identifier - example: 550e8400-e29b-41d4-a716-446655440000 template_identifier: type: string description: Template identifier @@ -1218,20 +1829,10 @@ components: description: Relations of the entity at this revision items: $ref: "#/components/schemas/RelationSnapshotDtoOut" - modified_flags: - type: object - additionalProperties: - type: boolean - description: Map of modified flags (for example, 'name_mod', true) PropertySnapshotDtoOut: type: object description: Snapshot of a property at a specific audit revision properties: - id: - type: string - format: uuid - description: Unique identifier of the property - example: 550e8400-e29b-41d4-a716-446655440000 name: type: string description: Name of the property matching a PropertyDefinition @@ -1240,20 +1841,10 @@ components: type: string description: Value of the property at this revision example: My service description - modified_flags: - type: object - additionalProperties: - type: boolean - description: Map of modified flags (for example, 'name_mod', true, 'value_mod', false) RelationSnapshotDtoOut: type: object description: Snapshot of a relation at a specific audit revision properties: - id: - type: string - format: uuid - description: Unique identifier of the relation - example: 550e8400-e29b-41d4-a716-446655440000 name: type: string description: Name of the relation matching a RelationDefinition @@ -1270,60 +1861,6 @@ components: - staging-cluster items: type: string - modified_flags: - type: object - additionalProperties: - type: boolean - description: Map of modified flags (for example, 'name_mod', true, 'target_template_identifier_mod', false) - 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 diff --git a/docs/zensical.toml b/docs/zensical.toml index f72132b5..f9434c84 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -37,7 +37,8 @@ nav = [ "concepts/entity-filtering.md" ]}, "concepts/properties.md", - "concepts/relations.md" + "concepts/relations.md", + "concepts/webhooks.md" ]}, { "Features" = [ "features/index.md", diff --git a/pom.xml b/pom.xml index 6c9f57bd..bdac46d8 100644 --- a/pom.xml +++ b/pom.xml @@ -246,6 +246,14 @@ 3.20.0 + + + + com.schibsted.spt.data + jslt + 0.1.14 + + org.springframework.boot spring-boot-starter-actuator 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 e32977de..cf575833 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 @@ -15,6 +15,7 @@ public class ValidationMessages { public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; + public static final String TEMPLATE_ALREADY_MAPPED = "Cannot delete template because it is currently mapped to '%s' entity dynamic mappings. Please remove the associated entity dynamic mappings before deleting the template."; // Property Definition validation messages public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; @@ -62,6 +63,15 @@ public class ValidationMessages { 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"; + // Webhook connector validation messages + public static final String WEBHOOK_CONNECTOR_ALREADY_EXIST = "Webhook Connector already exists with the same identifier"; + public static final String WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY = "Webhook Connector identifier is mandatory and cannot be blank"; + public static final String WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST = "Webhook Connector already exist with the same name"; + public static final String WEBHOOK_IDENTIFIER_NOT_FOUND = "Target webhook with identifier '%s' does not exist"; + public static final String ENTITY_DYNAMIC_MAPPING_NOT_FOUND = "Entity dynamic mapping with identifier '%s' does not exist"; + public static final String ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS = "Entity dynamic mapping already exists with the same identifier '%s'"; + public static final String ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE = "Entity dynamic mapping already in use, please remove it from the associated webhook connector '%s' before deleting it"; + // 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'"; @@ -114,4 +124,24 @@ public static String minMaxConstraintViolated(String constraint) { 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"; + // Entity Dynamic mapping validation messages + public static final String ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY = "Webhook mapping filter is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY = "Entity dynamic mapping identifier is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY = "Entity dynamic mapping name is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template Identifier is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY = "Dynamic mapping entity section is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY = "Entity name is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MANDATORY = "Entity relations section is mandatory"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_PROPERTIES_MANDATORY = "Entity properties section is mandatory"; + + public static final String WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY = "Webhook security type is mandatory"; + public static final String WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY = "Webhook security config is mandatory"; + public static final String WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH = "Webhook identifier must not exceed 255 characters"; + public static final String WEBHOOK_CONNECTOR_NAME_MAX_LENGTH = "Webhook name must not exceed 255 characters"; + public static final String WEBHOOK_CONNECTOR_NAME_MANDATORY = "Webhook name is mandatory"; + public static final String PROPERTY_NOT_EXPECTED_FORMAT = "Property '%s' does not match expected format"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_PROPERTIES_MISSING = "The mapping is missing required properties: %s"; + public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MISSING = "The mapping is missing required relations: %s"; + } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java new file mode 100644 index 00000000..3f37a79e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS; + +public class EntityDynamicMappingAlreadyExistsException extends RuntimeException { + + public EntityDynamicMappingAlreadyExistsException(String identifier) { + super(String.format(ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java new file mode 100644 index 00000000..63cfee53 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE; + +import java.util.List; + +public class EntityDynamicMappingAlreadyInUseException extends RuntimeException { + + public EntityDynamicMappingAlreadyInUseException(List identifier) { + super(ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE.formatted(identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java new file mode 100644 index 00000000..512c3f56 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping; + +public class EntityDynamicMappingConfigurationException extends RuntimeException { + + public EntityDynamicMappingConfigurationException(String message) { + super(message); + } + + public EntityDynamicMappingConfigurationException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoPropertiesException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoPropertiesException.java new file mode 100644 index 00000000..26abc8b2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoPropertiesException.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping; + +public class EntityDynamicMappingHasNoPropertiesException extends RuntimeException { + + public EntityDynamicMappingHasNoPropertiesException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoRelationsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoRelationsException.java new file mode 100644 index 00000000..ceb1152a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoRelationsException.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping; + +public class EntityDynamicMappingHasNoRelationsException extends RuntimeException { + + public EntityDynamicMappingHasNoRelationsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java new file mode 100644 index 00000000..7b5dadbc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_NOT_FOUND; + +public class EntityDynamicMappingNotFoundException extends RuntimeException { + + public EntityDynamicMappingNotFoundException(String identifier) { + super(String.format(ENTITY_DYNAMIC_MAPPING_NOT_FOUND, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateUsedByDynamicMappingException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateUsedByDynamicMappingException.java new file mode 100644 index 00000000..ad5edb3c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateUsedByDynamicMappingException.java @@ -0,0 +1,32 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + +/// Domain exception for missing [EntityTemplate] business entities. +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to access an EntityTemplate that doesn't exist in the system. This is a +/// critical business error since entities cannot be created without valid templates. +/// +/// **Exception design rationale:** +/// - Multiple constructors support different lookup scenarios (ID, identifier, field-based) +/// - Meaningful error messages aid in debugging and API error responses +/// - Domain-level exception keeps business logic separate from HTTP concerns +/// +/// **Usage patterns:** +/// - Template validation before entity operations +/// - Template-based entity queries +/// - Template management operations +public class EntityTemplateUsedByDynamicMappingException extends RuntimeException { + + /// Constructs a new exception with a custom error message. + /// + /// **Why this exists:** Allows for specific error messages that provide more + /// context about the search criteria or operation that failed. + /// + /// @param message the detail message explaining what was not found + public EntityTemplateUsedByDynamicMappingException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java index 2ce1db43..151aa0ae 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java @@ -1,7 +1,5 @@ package com.decathlon.idp_core.domain.exception.entity_template; -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 @@ -18,7 +16,7 @@ public class PropertyDefinitionRulesConflictException extends RuntimeException { /// @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, + public PropertyDefinitionRulesConflictException(String propertyName, String propertyType, String violationMessage) { super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java new file mode 100644 index 00000000..5658985b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java @@ -0,0 +1,7 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +public class PropertyNameNotFoundEntityTemplatePropertiesException extends RuntimeException { + public PropertyNameNotFoundEntityTemplatePropertiesException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java index 44d8d2a7..0f1b685b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java @@ -2,8 +2,6 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_CANNOT_CHANGE; -import com.decathlon.idp_core.domain.model.enums.PropertyType; - /// Exception thrown when attempting any property type change. /// /// This exception represents a business rule violation where type changes are blocked @@ -20,8 +18,7 @@ public class PropertyTypeChangeException extends RuntimeException { /// @param propertyName the name of the property whose type is being changed /// @param fromType the current property type /// @param toType the requested new property type - public PropertyTypeChangeException(String propertyName, PropertyType fromType, - PropertyType toType) { + public PropertyTypeChangeException(String propertyName, String fromType, String toType) { super(String.format(PROPERTY_TYPE_CANNOT_CHANGE, propertyName, fromType, toType)); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java new file mode 100644 index 00000000..3ac2b906 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java @@ -0,0 +1,7 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +public class RelationNameNotFoundEntityTemplateRelationsException extends RuntimeException { + public RelationNameNotFoundEntityTemplateRelationsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index 737c7c84..6f4ddd0d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -1,7 +1,5 @@ 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 @@ -18,7 +16,7 @@ public class PropertyDefinitionRulesConflictException extends RuntimeException { /// @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, + public PropertyDefinitionRulesConflictException(String propertyName, String propertyType, String violationMessage) { super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java new file mode 100644 index 00000000..eafbc81a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java @@ -0,0 +1,11 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookAuthenticationException extends RuntimeException { + public WebhookAuthenticationException(String message) { + super(message); + } + + public WebhookAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java new file mode 100644 index 00000000..26b07865 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_ALREADY_EXIST; + +public class WebhookConnectorAlreadyExistException extends RuntimeException { + + public WebhookConnectorAlreadyExistException(String identifier) { + super(String.format("%s:%s", WEBHOOK_CONNECTOR_ALREADY_EXIST, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java new file mode 100644 index 00000000..8e848a26 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java @@ -0,0 +1,7 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookConnectorConfigurationException extends RuntimeException { + public WebhookConnectorConfigurationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java new file mode 100644 index 00000000..bd9fc6bc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_IDENTIFIER_NOT_FOUND; + +public class WebhookConnectorNotFoundException extends RuntimeException { + + public WebhookConnectorNotFoundException(String identifier) { + super(String.format(WEBHOOK_IDENTIFIER_NOT_FOUND, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java new file mode 100644 index 00000000..da65c822 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST; + +public class WebhookConnectorTitleAlreadyExistsException extends RuntimeException { + public WebhookConnectorTitleAlreadyExistsException(String webhookName) { + super(String.format("%s:%s", WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST, webhookName)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java new file mode 100644 index 00000000..6bc70eb0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookSecurityConfigurationException extends RuntimeException { + + public WebhookSecurityConfigurationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java new file mode 100644 index 00000000..8d13c103 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java @@ -0,0 +1,55 @@ +package com.decathlon.idp_core.domain.model.entity_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import java.util.Map; +import java.util.UUID; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; + +/// Domain model representing dynamic entity mapping configuration. +/// +/// Each mapping defines how to transform inbound webhook events into entity instances, +/// including property/relation mappings and JSLT transformation rules. +/// +/// Note: The technical ID is managed purely at the infrastructure layer +/// (persisted in entity_dynamic_mapping table) and is NOT part of the domain model. +public record EntityDynamicMapping(UUID id, String identifier, String entityTemplateIdentifier, + String filter, String name, String description, String entityIdentifier, String entityName, + Map properties, Map relations) { + + public EntityDynamicMapping { + if (isBlank(identifier)) { + throw new EntityDynamicMappingConfigurationException( + ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY); + } + if (isBlank(name)) { + throw new EntityDynamicMappingConfigurationException(ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY); + } + if (isBlank(entityTemplateIdentifier)) { + throw new EntityDynamicMappingConfigurationException( + ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY); + } + + if (isBlank(filter)) { + throw new EntityDynamicMappingConfigurationException(ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY); + } + + if (isBlank(entityIdentifier)) { + throw new EntityDynamicMappingConfigurationException( + ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY); + } + + if (isBlank(entityName)) { + throw new EntityDynamicMappingConfigurationException( + ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY); + } + + properties = properties == null ? Map.of() : Map.copyOf(properties); + relations = relations == null ? Map.of() : Map.copyOf(relations); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java new file mode 100644 index 00000000..992e7dc8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java @@ -0,0 +1,16 @@ +package com.decathlon.idp_core.domain.model.enums; + +/// Discriminator for the security validation strategy of a [WebhookConnector]. +/// +/// | Strategy | headerName | secretAlias | prefix | username | jwksUri | +/// |--------------|------------|--------------------|----------|----------|---------| +/// | HMAC_SHA256 | Required | Required (hash key)| Optional | — | — | +/// | JWT_BEARER | — | — | — | — | Required| +/// | STATIC_TOKEN | Required | Required (target) | — | — | — | +/// | BASIC_AUTH | — | Required (password)| — | Required | — | +/// | NONE | — | — | — | — | — | +/// +/// `NONE` means the connector intentionally accepts unauthenticated requests. +public enum WebhookSecurityType { + HMAC_SHA256, JWT_BEARER, STATIC_TOKEN, BASIC_AUTH, NONE +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java new file mode 100644 index 00000000..828a9207 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java @@ -0,0 +1,47 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import java.util.List; +import java.util.UUID; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorConfigurationException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +public record WebhookConnector(UUID id, String identifier, String name, String description, + boolean enabled, List mappings, WebhookSecurity security) { + public WebhookConnector { + mappings = mappings == null ? List.of() : List.copyOf(mappings); + + if (security == null) { + throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY); + } + + if (isBlank(identifier)) { + throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY); + } + if (identifier.length() > 255) { + throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH); + } + + if (isBlank(name)) { + throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_NAME_MANDATORY); + } + if (name.length() > 255) { + throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_NAME_MAX_LENGTH); + } + } + + /// Creates a copy of this connector with the specified enabled state. + /// + /// @param enabled the new enabled state + /// @return a new WebhookConnector instance with the updated enabled field + public WebhookConnector withEnabled(boolean enabled) { + return new WebhookConnector(id, identifier, name, description, enabled, mappings, security); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java new file mode 100644 index 00000000..9aba1e97 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY; + +import java.util.Map; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +public record WebhookSecurity(WebhookSecurityType type, Map config) { + + public WebhookSecurity { + if (type == null) { + throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY); + } + if (config == null) { + throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY); + } + config = Map.copyOf(config); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java new file mode 100644 index 00000000..9f8ff75e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import java.util.UUID; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + +/// Domain model representing the mapping between a webhook event and an entity entityTemplateIdentifier. +/// +/// Per the webhook_template_mapping schema: +/// - Links a webhook connector to an entity entityTemplateIdentifier for event ingestion +/// - Contains the JSLT filter to apply during transformation +/// - Includes both technical IDs (from persistence) and functional domain objects +/// +/// @param id technical identifier of the mapping record +/// @param webhookConnector domain model of the associated webhook connector +/// @param entityTemplate domain model of the target entity entityTemplateIdentifier +/// @param entityDynamicMapping domain model of the dynamic mapping configuration +/// @param jsltFilter JSLT filter expression for event ingestion +public record WebhookTemplateMapping(UUID id, WebhookConnector webhookConnector, + EntityTemplate entityTemplate, EntityDynamicMapping entityDynamicMapping, String jsltFilter) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java new file mode 100644 index 00000000..7c55a86f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.port; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +public interface EntityDynamicMapperValidator { + + void validate(EntityDynamicMapping mapping); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java new file mode 100644 index 00000000..e11af1c6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +public interface EntityDynamicMappingPort { + + List findByEntityTemplateIdentifier(String templateIdentifier); + List findByEntityTemplateId(UUID templateId); + + Boolean existsByEntityTemplateIdentifier(String templateIdentifier); + + boolean existsByIdentifier(String identifier); + + Optional findByIdentifier(String identifier); + + EntityDynamicMapping save(EntityDynamicMapping entityDynamicMapping); + + Page findAll(Pageable pageable); + + void deleteByIdentifier(String identifier); + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java index 5f4f9106..0ffcebc2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java @@ -35,4 +35,6 @@ public interface EntityTemplateRepositoryPort { EntityTemplate save(EntityTemplate entityTemplate); void deleteByIdentifier(String identifier); + + boolean existsById(UUID id); } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java new file mode 100644 index 00000000..fba415ae --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java @@ -0,0 +1,23 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; + +public interface WebhookConnectorRepositoryPort { + + Optional findByIdentifier(String identifier); + + Page findAll(Pageable pageable); + + boolean existsByIdentifier(String identifier); + + boolean existsByTitle(String title); + + WebhookConnector save(WebhookConnector connector); + + void deleteByIdentifier(String identifier); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java new file mode 100644 index 00000000..afa3be65 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java @@ -0,0 +1,14 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.List; +import java.util.UUID; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; + +public interface WebhookMappingLinkPort { + + boolean existsByEntityMappingId(UUID id); + + List findByEntityMappingId(UUID id); + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java new file mode 100644 index 00000000..1a7868f3 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java @@ -0,0 +1,30 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.Map; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +/// Unified strategy contract for webhook security handling. +/// +/// This interface consolidates two responsibilities that were previously scattered: +/// 1. Validating security configuration at creation/update time +/// 2. Validating incoming webhook requests at runtime +/// +/// Implementations should focus on security logic without side effects. +public interface WebhookSecurityStrategy { + + /// Checks if this strategy supports the given security type. + /// + /// @param securityType the security type to check + /// @return true if this strategy handles this security type + boolean supports(WebhookSecurityType securityType); + + /// Validates the security configuration provided at creation/update time. + /// + /// @param config the security configuration map (e.g., username, secret_alias) + /// @throws + /// com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException + /// if validation fails + void validateConfiguration(Map config); + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingService.java new file mode 100644 index 00000000..dfbfc685 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingService.java @@ -0,0 +1,111 @@ +package com.decathlon.idp_core.domain.service.entity_dynamic_mapping; + +import java.util.List; +import java.util.UUID; + +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; + +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_dynamic_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort; + +import lombok.RequiredArgsConstructor; + +@Service +@Validated +@RequiredArgsConstructor +public class EntityDynamicMappingService { + + private final EntityDynamicMappingPort entityDynamicMappingPort; + private final WebhookMappingLinkPort webhookTemplateMappingPort; + private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService; + + @Transactional + public EntityDynamicMapping createEntityDynamicMapping( + EntityDynamicMapping entityDynamicMapping) { + validateIdentifierUniqueness(entityDynamicMapping.identifier()); + webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping); + return entityDynamicMappingPort.save(entityDynamicMapping); + } + + public Page getAllEntityDynamicMapping(Pageable pageable) { + return entityDynamicMappingPort.findAll(pageable); + } + + @Transactional + public void deleteEntityDynamicMapping(String entityDynamicMappingIdentifier) { + validateIdentifierExists(entityDynamicMappingIdentifier); + UUID dynamicMappingIdentifier = entityDynamicMappingPort + .findByIdentifier(entityDynamicMappingIdentifier).orElseThrow( + () -> new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier)) + .id(); + validateIsNotInUse(dynamicMappingIdentifier); + entityDynamicMappingPort.deleteByIdentifier(entityDynamicMappingIdentifier); + } + + private void validateIsNotInUse(UUID entityDynamicMappingId) { + if (webhookTemplateMappingPort.existsByEntityMappingId(entityDynamicMappingId)) { + List webhookTemplateMappingList = webhookTemplateMappingPort + .findByEntityMappingId(entityDynamicMappingId); + List webhookIdentifiers = webhookTemplateMappingList.stream() + .map(WebhookTemplateMapping::webhookConnector) + .filter(webhook -> webhook != null && webhook.identifier() != null) + .map(WebhookConnector::identifier).distinct().toList(); + throw new EntityDynamicMappingAlreadyInUseException(webhookIdentifiers); + } + } + + private void validateIdentifierExists(String entityDynamicMappingIdentifier) { + if (!entityDynamicMappingPort.existsByIdentifier(entityDynamicMappingIdentifier)) { + throw new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier); + } + } + + /// Ensures no other dynamic mapping already uses the provided identifier. + /// + /// This enforces the `entity_dynamic_mapping_identifier_key` unique constraint + /// at the domain level, returning a meaningful conflict instead of letting the + /// database raise a low-level integrity violation. + /// + /// @param identifier the candidate mapping identifier + /// @throws EntityDynamicMappingAlreadyExistsException when the identifier is + /// already used + private void validateIdentifierUniqueness(String identifier) { + if (entityDynamicMappingPort.existsByIdentifier(identifier)) { + throw new EntityDynamicMappingAlreadyExistsException(identifier); + } + } + + public EntityDynamicMapping getEntityDynamicMapping(String identifier) { + return entityDynamicMappingPort.findByIdentifier(identifier) + .orElseThrow(() -> new EntityDynamicMappingNotFoundException(identifier)); + } + + @Transactional + public EntityDynamicMapping updateEntityDynamicMapping(String identifier, + @Valid EntityDynamicMapping entityDynamicMapping) { + EntityDynamicMapping existingMapping = getEntityDynamicMapping(identifier); + webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping); + + EntityDynamicMapping mergedMapping = new EntityDynamicMapping(existingMapping.id(), + existingMapping.identifier(), entityDynamicMapping.entityTemplateIdentifier(), + entityDynamicMapping.filter(), entityDynamicMapping.name(), + entityDynamicMapping.description(), entityDynamicMapping.entityIdentifier(), + entityDynamicMapping.entityName(), entityDynamicMapping.properties(), + entityDynamicMapping.relations()); + + return entityDynamicMappingPort.save(mergedMapping); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java new file mode 100644 index 00000000..eafde38c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java @@ -0,0 +1,55 @@ +package com.decathlon.idp_core.domain.service.entity_dynamic_mapping; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; +import com.decathlon.idp_core.domain.service.relation.RelationValidationService; + +import lombok.RequiredArgsConstructor; + +@Service +@Validated +@RequiredArgsConstructor +public class EntityDynamicMappingValidationService { + + private final EntityTemplateService entityTemplateService; + private final EntityDynamicMapperValidator entityDynamicMapperValidator; + private final PropertyValidationService propertyValidationService; + private final RelationValidationService relationValidationService; + + public void validateMappings(List mappings) { + mappings.forEach(this::validateMapping); + } + + public void validateMapping(EntityDynamicMapping entityDynamicMapping) { + String templateIdentifier = entityDynamicMapping.entityTemplateIdentifier(); + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(templateIdentifier); + + Map properties = entityDynamicMapping.properties() != null + ? entityDynamicMapping.properties() + : Collections.emptyMap(); + Map relations = entityDynamicMapping.relations() != null + ? entityDynamicMapping.relations() + : Collections.emptyMap(); + + // Validate properties against template + propertyValidationService.validateMappingPropertiesAgainstTemplate(entityTemplate, + List.copyOf(properties.keySet())); + + // Validate relations against template + relationValidationService.validateMappingRelationsAgainstTemplate(entityTemplate, + List.copyOf(relations.keySet())); + + entityDynamicMapperValidator.validate(entityDynamicMapping); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 4ec24faa..9d3d4371 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -1,21 +1,17 @@ package com.decathlon.idp_core.domain.service.entity_template; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_MAPPED; + +import java.util.List; import java.util.Objects; import org.springframework.stereotype.Service; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.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.PropertyNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.*; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; 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.EntityDynamicMappingPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import lombok.RequiredArgsConstructor; @@ -34,6 +30,7 @@ public class EntityTemplateValidationService { private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; private final PropertyDefinitionValidationService propertyDefinitionValidationService; private final RelationDefinitionValidationService relationDefinitionValidationService; + private final EntityDynamicMappingPort entityDynamicMappingPort; /// Validates all business rules before creating a new entity template. /// @@ -139,6 +136,33 @@ public void validateForDeletion(String identifier) { throw new EntityTemplateNotFoundException("identifier", "null"); } validateTemplateExists(identifier); + validateTemplateIsNotUsedInEntityDynamicMapper(identifier); + } + + /// Validates that the entity template is not currently referenced by any + /// dynamic mapping. + /// + /// Design Note: This check relies directly on the `EntityDynamicMappingPort` + /// rather than + /// injecting `DynamicMappingService`. This is a deliberate architectural choice + /// to prevent + /// a circular dependency between our domain services (TemplateValidation -> + /// DynamicMapping -> TemplateValidation). + /// It allows the template domain to enforce its own referential integrity + /// before deletion + /// while remaining loosely coupled. + /// + /// **Precondition:** Template existence must be validated before calling this + /// method (via [#validateTemplateExists]). + public void validateTemplateIsNotUsedInEntityDynamicMapper(String entityTemplateIdentifier) { + List mappings = entityDynamicMappingPort + .findByEntityTemplateIdentifier(entityTemplateIdentifier); + if (!mappings.isEmpty()) { + List mappingIdentifiers = mappings.stream().map(EntityDynamicMapping::identifier) + .toList(); + throw new EntityTemplateUsedByDynamicMappingException( + TEMPLATE_ALREADY_MAPPED.formatted(mappingIdentifiers)); + } } /// Checks that the entity template exists. 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 d61a4bda..8063c293 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 @@ -102,7 +102,8 @@ public void validateTypeChanges(List existingProperties, boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); if (propertyTypeChanged) { - throw new PropertyTypeChangeException(existing.name(), existing.type(), updated.type()); + throw new PropertyTypeChangeException(existing.name(), existing.type().name(), + updated.type().name()); } } } @@ -177,18 +178,18 @@ private void validateStringPropertyRules(String propertyName, PropertyRules rule private void validateStringConstraints(String propertyName, PropertyRules rules) { // Validate min_length is non-negative if (rules.minLength() != null && rules.minLength() < 0) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE); } // Validate max_length is not zero or negative if (rules.maxLength() != null && rules.maxLength() <= 0) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), PROPERTY_RULES_MAX_LENGTH_POSITIVE); } // Validate min_length is below or equal to max_length if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), minMaxConstraintViolated(LENGTH)); } } @@ -210,31 +211,31 @@ private void validateStringIncompatibleRules(String propertyName, PropertyRules // Reject numeric rules for STRING type if (rules.maxValue() != null || rules.minValue() != null) { String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName)); } // format, regex, and enum_values are incompatible with each other if (rules.format() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(FORMAT, ENUM_VALUES)); } if (rules.format() != null && rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(FORMAT, REGEX)); } if (rules.regex() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(REGEX, ENUM_VALUES)); } // enum_values and length constraints are incompatible with each other if (rules.enumValues() != null && rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH)); } if (rules.enumValues() != null && rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH)); } @@ -254,33 +255,33 @@ private void validateStringIncompatibleRules(String propertyName, PropertyRules /// or min/max value constraints are violated private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { if (rules.format() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(FORMAT, PropertyType.NUMBER.name())); } if (rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name())); } if (rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(REGEX, PropertyType.NUMBER.name())); } if (rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name())); } if (rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name())); } if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(), minMaxConstraintViolated(VALUE)); } } @@ -299,7 +300,7 @@ private void validateBooleanPropertyRules(String propertyName, PropertyRules rul || rules.maxLength() != null || rules.minLength() != null || rules.maxValue() != null || rules.minValue() != null) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN.name(), PROPERTY_RULES_BOOLEAN_NOT_ALLOWED); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java index ee4aca98..7a98f8d3 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java @@ -52,12 +52,12 @@ public class PropertyRegexValidationService { /// @throws PropertyDefinitionRulesConflictException if any security check fails public void validateRegexPattern(String propertyName, String regexPattern) { if (regexPattern.length() > MAX_REGEX_LENGTH) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex pattern too long (max " + MAX_REGEX_LENGTH + " characters)"); } if (containsDangerousPatterns(regexPattern)) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex pattern contains potentially unsafe constructs"); } @@ -65,7 +65,7 @@ public void validateRegexPattern(String propertyName, String regexPattern) { try { compiledRegexPattern = Pattern.compile(regexPattern); } catch (PatternSyntaxException e) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Invalid regex pattern: " + e.getMessage()); } @@ -91,14 +91,14 @@ private void validatePatternWithTimeout(String propertyName, Pattern pattern) { future.get(VALIDATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); } catch (TimeoutException _) { future.cancel(true); - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); } catch (InterruptedException _) { Thread.currentThread().interrupt(); - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex pattern validation was interrupted"); } catch (ExecutionException e) { - throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(), "Regex validation failed: " + e.getCause().getMessage()); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index f1e8ff44..f7628a49 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -1,27 +1,20 @@ 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_NOT_DEFINED_IN_TEMPLATE; -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 static com.decathlon.idp_core.domain.constant.ValidationMessages.*; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.stream.Collectors; import org.springframework.stereotype.Service; -import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoPropertiesException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException; 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; @@ -76,6 +69,7 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, /// 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 @@ -118,6 +112,53 @@ public void validatePropertiesAgainstTemplate(final EntityTemplate template, } } + public void validateMappingPropertiesAgainstTemplate(EntityTemplate template, + List mappedPropertyNames) { + validateNamesExistInTemplate(template, mappedPropertyNames); + validateRequiredPropertiesAreMapped(template, mappedPropertyNames); + } + + private void validateNamesExistInTemplate(EntityTemplate template, List propertyNames) { + if (propertyNames == null || propertyNames.isEmpty()) { + return; + } + + Set definedPropertyNames = getDefinedPropertyNames(template); + + propertyNames.stream().filter(name -> !definedPropertyNames.contains(name)).findFirst() + .ifPresent(name -> { + throw new PropertyNameNotFoundEntityTemplatePropertiesException( + PROPERTY_NOT_EXPECTED_FORMAT.formatted(name)); + }); + } + + /// Validates that all required property definitions in the template are mapped. + private void validateRequiredPropertiesAreMapped(EntityTemplate template, + List mappedPropertyNames) { + List definitions = template.propertiesDefinitions() != null + ? template.propertiesDefinitions() + : List.of(); + + List mappedNames = mappedPropertyNames != null ? mappedPropertyNames : List.of(); + + List missingProperties = definitions.stream().filter(PropertyDefinition::required) + .map(PropertyDefinition::name).filter(requiredName -> !mappedNames.contains(requiredName)) + .toList(); + + if (!missingProperties.isEmpty()) { + throw new EntityDynamicMappingHasNoPropertiesException( + String.format(ENTITY_DYNAMIC_MAPPING_ENTITY_PROPERTIES_MISSING, missingProperties)); + } + } + + private Set getDefinedPropertyNames(EntityTemplate template) { + if (template.propertiesDefinitions() == null) { + return Set.of(); + } + return template.propertiesDefinitions().stream().map(PropertyDefinition::name) + .collect(Collectors.toSet()); + } + private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { if (!(rawValue instanceof String stringValue)) { diff --git a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java index 45408d79..00fd7c84 100644 --- 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 @@ -1,4 +1,6 @@ package com.decathlon.idp_core.domain.service.relation; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MISSING; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND; @@ -11,6 +13,8 @@ import org.springframework.stereotype.Service; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoRelationsException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; @@ -21,12 +25,90 @@ import lombok.RequiredArgsConstructor; /// Domain service validating entity relations against template relation definitions. +/// +/// This service provides two levels of validation: +/// 1. **Mapping validation** ([#validateMappingRelationsAgainstTemplate]): validates that +/// relation names exist in the template and required relations are mapped. +/// Used by dynamic mapping validation. +/// 2. **Full relation validation** ([#validateRelationsAgainstTemplate]): validates complete +/// [Relation] objects including cardinality and target existence. Used by entity validation. @Service @RequiredArgsConstructor public class RelationValidationService { private final EntityRepositoryPort entityRepository; + /// Validates mapping relation names against the template's relation + /// definitions. + /// + /// This is the **unified validation method for dynamic mappings**, similar to + /// `PropertyValidationService.validateAgainstTemplate()`. + /// + /// **Validations performed:** + /// 1. All relation names must exist in the template + /// 2. All required relations must have mappings + /// + /// **Fail-fast:** Throws on the first validation error encountered. + /// + /// @param template the target template whose relation definitions are the + /// source of truth + /// @param mappedRelationNames the relation names that have mappings + /// @throws RelationNameNotFoundEntityTemplateRelationsException when a name is + /// not declared + /// in the template + /// @throws EntityDynamicMappingHasNoRelationsException when required relations + /// are missing + public void validateMappingRelationsAgainstTemplate(EntityTemplate template, + List mappedRelationNames) { + validateNamesExistInTemplate(template, mappedRelationNames); + validateRequiredRelationsAreMapped(template, mappedRelationNames); + } + + /// Validates that all relation names in the provided list exist in the + /// template. + /// + /// **Fail-fast:** Throws on the first unknown relation name encountered. + private void validateNamesExistInTemplate(EntityTemplate template, List relationNames) { + if (relationNames == null || relationNames.isEmpty()) { + return; + } + + Set definedRelationNames = getDefinedRelationNames(template); + + relationNames.stream().filter(name -> !definedRelationNames.contains(name)).findFirst() + .ifPresent(name -> { + throw new RelationNameNotFoundEntityTemplateRelationsException( + String.format(RELATION_NOT_DEFINED_IN_TEMPLATE, name, template.identifier())); + }); + } + + /// Validates that all required relation definitions in the template are mapped. + private void validateRequiredRelationsAreMapped(EntityTemplate template, + List mappedRelationNames) { + List definitions = template.relationsDefinitions() != null + ? template.relationsDefinitions() + : List.of(); + + List mappedNames = mappedRelationNames != null ? mappedRelationNames : List.of(); + + List missingRelations = definitions.stream().filter(RelationDefinition::required) + .map(RelationDefinition::name).filter(requiredName -> !mappedNames.contains(requiredName)) + .toList(); + + if (!missingRelations.isEmpty()) { + throw new EntityDynamicMappingHasNoRelationsException( + String.format(ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MISSING, missingRelations)); + } + } + + private Set getDefinedRelationNames(EntityTemplate template) { + if (template.relationsDefinitions() == null) { + return Set.of(); + } + return template.relationsDefinitions().stream().map(RelationDefinition::name) + .collect(Collectors.toSet()); + } + /// 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 diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java new file mode 100644 index 00000000..adf6a2db --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java @@ -0,0 +1,82 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import java.util.List; + +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; + +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_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; + +import lombok.RequiredArgsConstructor; + +@Service +@Validated +@RequiredArgsConstructor +public class WebhookConnectorService { + + private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + private final WebhookConnectorValidationService webhookConnectorValidationService; + private final EntityDynamicMappingPort entityDynamicMappingPort; + + public List resolveAndValidateMappings(List mappingIdentifiers) { + if (mappingIdentifiers == null || mappingIdentifiers.isEmpty()) { + return List.of(); + } + return mappingIdentifiers.stream().map(this::resolveMappingOrThrow).toList(); + } + + private EntityDynamicMapping resolveMappingOrThrow(String identifier) { + return entityDynamicMappingPort.findByIdentifier(identifier) + .orElseThrow(() -> new EntityDynamicMappingNotFoundException(identifier)); + } + + public WebhookConnector getWebhookConnector(String identifier) { + return webhookConnectorRepositoryPort.findByIdentifier(identifier) + .orElseThrow(() -> new WebhookConnectorNotFoundException(identifier)); + } + + @Transactional + public WebhookConnector createWebhookConnector(WebhookConnector connector) { + webhookConnectorValidationService.validateWebhookConnectorForCreation(connector); + + WebhookConnector connectorToSave = connector.mappings().isEmpty() && connector.enabled() + ? connector.withEnabled(false) + : connector; + + return webhookConnectorRepositoryPort.save(connectorToSave); + } + + @Transactional + public WebhookConnector updateWebhookConnector(String identifier, + @Valid WebhookConnector connectorToUpdate) { + WebhookConnector webhookConnectorInDb = getWebhookConnector(identifier); + webhookConnectorValidationService.validateWebhookConnectorForUpdate(connectorToUpdate); + boolean enabledValue = !connectorToUpdate.mappings().isEmpty() && connectorToUpdate.enabled(); + WebhookConnector mergedConnector = new WebhookConnector(webhookConnectorInDb.id(), + webhookConnectorInDb.identifier(), connectorToUpdate.name(), + connectorToUpdate.description(), enabledValue, connectorToUpdate.mappings(), + connectorToUpdate.security()); + + return webhookConnectorRepositoryPort.save(mergedConnector); + } + + @Transactional + public void deleteWebhookConnector(String webhookConnectorIdentifier) { + webhookConnectorValidationService.validateIdentifierExists(webhookConnectorIdentifier); + webhookConnectorRepositoryPort.deleteByIdentifier(webhookConnectorIdentifier); + } + + public Page getAllWebhookConnector(Pageable pageable) { + return webhookConnectorRepositoryPort.findAll(pageable); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java new file mode 100644 index 00000000..fac62077 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java @@ -0,0 +1,63 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorAlreadyExistException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingValidationService; +import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService; + +import lombok.RequiredArgsConstructor; + +/// Domain validation service for webhook connector lifecycle operations. +/// It validates connector uniqueness rules and delegates mapping and security +/// validation to dedicated domain services. +@Service +@Validated +@RequiredArgsConstructor +public class WebhookConnectorValidationService { + + private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + private final EntityDynamicMappingValidationService entityDynamicMappingValidationService; + private final WebhookSecurityValidationService webhookSecurityValidationService; + + public void validateWebhookConnectorForCreation(WebhookConnector webhookConnector) { + validateIdentifierUniqueness(webhookConnector.identifier()); + webhookSecurityValidationService.validateForCreation(webhookConnector.security()); + + } + + public void validateWebhookConnectorForUpdate(WebhookConnector webhookConnectorToUpdate) { + validateMappingsIfPresent(webhookConnectorToUpdate); + webhookSecurityValidationService.validateForCreation(webhookConnectorToUpdate.security()); + } + + private void validateMappingsIfPresent(WebhookConnector webhookConnector) { + if (!webhookConnector.mappings().isEmpty()) { + entityDynamicMappingValidationService.validateMappings(webhookConnector.mappings()); + } + } + + /// Checks that no other [WebhookConnector] exists with the same identifier + /// before allowing creation. + /// + /// @param webhookConnectorIdentifier the webhook connector identifier to check + /// for uniqueness + /// @throws WebhookConnectorAlreadyExistException if a connector with the same + /// identifier already exists + private void validateIdentifierUniqueness(String webhookConnectorIdentifier) { + if (webhookConnectorRepositoryPort.existsByIdentifier(webhookConnectorIdentifier)) { + throw new WebhookConnectorAlreadyExistException(webhookConnectorIdentifier); + } + } + + public void validateIdentifierExists(String webhookConnectorIdentifier) { + if (!webhookConnectorRepositoryPort.existsByIdentifier(webhookConnectorIdentifier)) { + throw new WebhookConnectorNotFoundException(webhookConnectorIdentifier); + } + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java new file mode 100644 index 00000000..97f95b33 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java @@ -0,0 +1,56 @@ +package com.decathlon.idp_core.domain.service.webhook.security; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +/// Domain service for validating webhook security configuration at creation/update time. +/// +/// This service ensures that the security configuration provided when creating or updating +/// a webhook connector is valid before storing it in the database. +@Service +public class WebhookSecurityValidationService { + + private final List strategies; + + public WebhookSecurityValidationService(List strategies) { + this.strategies = List.copyOf(strategies); + } + + /// Validates webhook security configuration for creation or update. + /// + /// @param security the security configuration to validate + /// @throws WebhookSecurityConfigurationException if the configuration is + /// invalid + public void validateForCreation(WebhookSecurity security) { + if (security == null) { + throw new WebhookSecurityConfigurationException("Webhook security section is mandatory"); + } + + Map config = security.config(); + + if (security.type() == WebhookSecurityType.NONE) { + validateNoSecurityConfig(config); + return; + } + + strategies.stream().filter(strategy -> strategy.supports(security.type())).findFirst() + .ifPresentOrElse(strategy -> strategy.validateConfiguration(config), () -> { + throw new WebhookSecurityConfigurationException( + "No validator registered for security type: " + security.type()); + }); + } + + private void validateNoSecurityConfig(Map config) { + if (!config.isEmpty()) { + throw new WebhookSecurityConfigurationException( + "Webhook security config must be empty when type is NONE"); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java index 47ce8abb..a5e679ab 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java @@ -14,7 +14,9 @@ import org.springframework.data.domain.Pageable; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.core.jackson.ModelResolver; @@ -100,4 +102,21 @@ public EntityPageResponse(List content, Pageable pageable, long to } } + @Schema(description = "Paginated response containing Inbound Webhook Connector objects") + public static class WebhookConnectorPageResponse extends PageImpl { + public WebhookConnectorPageResponse(List content, Pageable pageable, + long total) { + super(content, pageable, total); + } + } + + @Schema(description = "Paginated response containing Entity Dynamic Mapping objects") + public static class EntityDynamicMappingPageResponse + extends + PageImpl { + public EntityDynamicMappingPageResponse(List content, + Pageable pageable, long total) { + super(content, pageable, total); + } + } } 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 5470466b..6bdcc93e 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 @@ -42,9 +42,6 @@ public class SwaggerDescription { public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; @@ -68,11 +65,41 @@ 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"; public static final String ENDPOINT_DELETE_ENTITY_SUMMARY = "Delete an existing entity"; public static final String ENDPOINT_DELETE_ENTITY_DESCRIPTION = "Delete an entity from the system using its template and entity identifiers. This operation removes the entity and automatically cleans up any relations from other entities that reference it."; + public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_SUMMARY = "Get paginated Webhook connectors"; + public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_DESCRIPTION = "Retrieve a paginated list of webhook connectors with optional sorting"; + + public static final String ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY = "Delete a webhook connector by identifier"; + public static final String ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION = "Remove a webhook connector from the system using its unique identifier"; + + public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_SUMMARY = "Get a webhook connector by identifier"; + public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific webhook connector using its string identifier"; + + public static final String ENDPOINT_PUT_WEBHOOK_CONNECTOR_SUMMARY = "Update an existing webhook connector by identifier"; + public static final String ENDPOINT_PUT_WEBHOOK_CONNECTOR_DESCRIPTION = "Update the details of an existing webhook connector identified by its unique string identifier"; + + public static final String ENDPOINT_POST_WEBHOOK_CONNECTOR_SUMMARY = "Create a new webhook connector configuration"; + public static final String ENDPOINT_POST_WEBHOOK_CONNECTOR_DESCRIPTION = "Creates a webhook connector configuration used by the generic inbound webhook endpoint"; + + public static final String ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Create entity dynamic mapping"; + public static final String ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Creates a new entity dynamic mapping used by the generic inbound webhook endpoint"; + + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY = "Get paginated entity dynamic mappings"; + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entity dynamic mappings with optional sorting"; + + public static final String ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Delete an entity dynamic mapping by identifier"; + public static final String ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Remove an entity dynamic from the system using its unique identifier"; + + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_SUMMARY = "Get an entity dynamic mapping by identifier"; + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_DESCRIPTION = "Retrieve an entity dynamic mapping using its string identifier"; + + public static final String ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Update an existing entity dynamic mapping by identifier"; + public static final String ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Update the details of an existing entity dynamic mapping identified by its unique string identifier"; /// Entity Audit API endpoint constants public static final String ENDPOINT_GET_ENTITY_AUDIT_SUMMARY = "Get entity audit history"; public static final String ENDPOINT_GET_ENTITY_AUDIT_DESCRIPTION = "Retrieve the complete audit history for a specific entity, including all revisions with timestamps and modification types"; @@ -103,6 +130,21 @@ public class SwaggerDescription { 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"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS = "Paginated webhook connector retrieved successfully"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_DELETED = "Webhook connector deleted successfully"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER = "Webhook connector not found with the provided identifier"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_FOUND = "Webhook connector found"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_CREATED = "Webhook connector created"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_UPDATED = "Webhook connector updated successfully"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_CONFLICT = "Webhook connector already exists in this entityTemplateIdentifier"; + public static final String RESPONSE_INVALID_WEBHOOK_CONNECTOR_DATA = "Invalid webhook connector data provided"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_CREATED = "Entity dynamic mapping created"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_DATA = "Invalid entity dynamic mapping data provided"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS = "Paginated entity dynamic mapping retrieved successfully"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED = "Entity dynamic mapping deleted successfully"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER = "Entity dynamic mapping not found with the provided identifier"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND = "Entity dynamic mapping found"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED = "Entity dynamic mapping updated successfully"; // --- Schema (class) descriptions --- public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java new file mode 100644 index 00000000..d5402559 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java @@ -0,0 +1,109 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; +import static org.springframework.http.HttpStatus.*; + +import jakarta.validation.Valid; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingService; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingMapper; + +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.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/entity_dynamic_mappings") +@Tag(name = "Entity dynamic mapping", description = "Operations related to entity dynamic mapping management") +public class EntityDynamicMappingController { + + private final EntityDynamicMappingMapper dynamicMappingMapper; + private final EntityDynamicMappingService dynamicMappingService; + + @Operation(summary = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_CREATED) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DATA) + @ApiResponse(responseCode = CONFLICT_CODE, description = "Identifier already exists") + @PostMapping + @ResponseStatus(CREATED) + public EntityDynamicMappingDtoOut createDynamicMapping( + @Valid @RequestBody EntityDynamicMappingCreateDtoIn inboundWebhookMappingDtoIn) { + EntityDynamicMapping entityDynamicMapping = dynamicMappingService + .createEntityDynamicMapping(dynamicMappingMapper.toDomain(inboundWebhookMappingDtoIn)); + return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping); + } + + @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.EntityDynamicMappingPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @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"))) + @GetMapping + @ResponseStatus(OK) + public Page getEntityDynamicMappingPaginated( + @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { + return dynamicMappingService.getAllEntityDynamicMapping(pageable) + .map(dynamicMappingMapper::fromEntityMappingToDto); + } + + @Operation(summary = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ResponseStatus(NO_CONTENT) + @DeleteMapping("/{identifier}") + public void deleteEntityDynamicMapping(@PathVariable String identifier) { + dynamicMappingService.deleteEntityDynamicMapping(identifier); + } + + @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND, content = { + @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{identifier}") + @ResponseStatus(OK) + public EntityDynamicMappingDtoOut getEntityDynamicMappingByIdentifier( + @PathVariable String identifier) { + EntityDynamicMapping entityDynamicMapping = dynamicMappingService + .getEntityDynamicMapping(identifier); + return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping); + } + + @Operation(summary = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED, content = { + @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = CONFLICT_CODE, description = "", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PutMapping("/{identifier}") + @ResponseStatus(OK) + public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable String identifier, + @Valid @RequestBody EntityDynamicMappingUpdateDtoIn entityDynamicMappingDtoIn) { + return dynamicMappingMapper + .fromEntityMappingToDto(dynamicMappingService.updateEntityDynamicMapping(identifier, + dynamicMappingMapper.toDomainForUpdate(identifier, entityDynamicMappingDtoIn))); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java new file mode 100644 index 00000000..9a846012 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java @@ -0,0 +1,115 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; +import static org.springframework.http.HttpStatus.*; + +import jakarta.validation.Valid; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook.InboundWebhookMapper; + +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.RequiredArgsConstructor; + +/// REST controller exposing inbound webhook configuration management endpoints. +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/inbound_webhooks") +@Tag(name = "Inbound Webhook Management", description = "Operations for managing inbound webhook connector configurations") +public class InboundWebhookConfigurationController { + + private final WebhookConnectorService webhookConnectorService; + private final InboundWebhookMapper inboundWebhookMapper; + + /// Creates a new inbound webhook connector configuration. + /// + /// @param request creation payload + /// @return created connector response + @Operation(summary = ENDPOINT_POST_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_POST_WEBHOOK_CONNECTOR_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_CREATED) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_WEBHOOK_CONNECTOR_DATA) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_CONFLICT) + @PostMapping + @ResponseStatus(CREATED) + public InboundWebhookDtoOut createInboundWebhook( + @Valid @RequestBody InboundWebhookCreateDtoIn request) { + WebhookConnector webhookConnector = webhookConnectorService + .createWebhookConnector(inboundWebhookMapper.toDomain(request, + webhookConnectorService.resolveAndValidateMappings(request.mappingIdentifiers()))); + return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector); + } + + @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.WebhookConnectorPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @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"))) + @GetMapping + @ResponseStatus(OK) + public Page getWebhooksPaginated( + @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { + return webhookConnectorService.getAllWebhookConnector(pageable) + .map(inboundWebhookMapper::fromWebhookConnectorToDto); + } + + @Operation(summary = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_DELETED) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ResponseStatus(NO_CONTENT) + @DeleteMapping("/{identifier}") + public void deleteWebhookConnector(@PathVariable String identifier) { + webhookConnectorService.deleteWebhookConnector(identifier); + } + + @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_FOUND, content = { + @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{identifier}") + @ResponseStatus(OK) + public InboundWebhookDtoOut getWebhookConnectorByIdentifier(@PathVariable String identifier) { + WebhookConnector webhookConnector = webhookConnectorService.getWebhookConnector(identifier); + return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector); + } + + @Operation(summary = ENDPOINT_PUT_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_PUT_WEBHOOK_CONNECTOR_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_UPDATED, content = { + @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid request payload", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = CONFLICT_CODE, description = "Webhook connector name already exists", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PutMapping("/{identifier}") + @ResponseStatus(OK) + public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier, + @Valid @RequestBody InboundWebhookUpdateDtoIn request) { + var resolvedMappings = webhookConnectorService + .resolveAndValidateMappings(request.mappingIdentifiers()); + return inboundWebhookMapper + .fromWebhookConnectorToDto(webhookConnectorService.updateWebhookConnector(identifier, + inboundWebhookMapper.toDomainForUpdate(identifier, request, resolvedMappings))); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java new file mode 100644 index 00000000..589b6ef9 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java @@ -0,0 +1,27 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Mapping rule request for inbound webhook transformation. +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record EntityDynamicMappingCreateDtoIn( + + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY) String identifier, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description, + @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid EntityMappingDtoIn entity) { + + /// Returns a CommonFields view for compatibility with the mapper. + public EntityDynamicMappingDtoInCommonFields commonFields() { + return new EntityDynamicMappingDtoInCommonFields(entityTemplateIdentifier, filter, name, + description, entity); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java new file mode 100644 index 00000000..6912954e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java @@ -0,0 +1,19 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Common fields for entity dynamic mapping requests (create and update). +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record EntityDynamicMappingDtoInCommonFields( + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description, + @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid EntityMappingDtoIn entity) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java new file mode 100644 index 00000000..ef391ee2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Mapping rule request for inbound webhook update. +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record EntityDynamicMappingUpdateDtoIn( + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description, + @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid EntityMappingDtoIn entity) { + + /// Returns a CommonFields view for compatibility with the mapper. + public EntityDynamicMappingDtoInCommonFields commonFields() { + return new EntityDynamicMappingDtoInCommonFields(entityTemplateIdentifier, filter, name, + description, entity); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityMappingDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityMappingDtoIn.java new file mode 100644 index 00000000..15b05158 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityMappingDtoIn.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import java.util.Map; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/// Entity projection section for an inbound webhook mapping. +public record EntityMappingDtoIn( + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY) String identifier, + @NotBlank(message = ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY) String name, + @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_PROPERTIES_MANDATORY) Map properties, + @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MANDATORY) Map relations) { + + public EntityMappingDtoIn { + properties = properties != null ? Map.copyOf(properties) : null; + relations = relations != null ? Map.copyOf(relations) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java new file mode 100644 index 00000000..899c5f8f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java @@ -0,0 +1,24 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; + +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record InboundWebhookCreateDtoIn( + @NotBlank(message = WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY) @Size(max = 255, message = WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH) String identifier, + @NotBlank(message = WEBHOOK_CONNECTOR_NAME_MANDATORY) @Size(max = 255, message = WEBHOOK_CONNECTOR_NAME_MAX_LENGTH) String name, + String description, boolean enabled, List mappingIdentifiers, + @Valid InboundWebhookSecurityContractDtoIn security) { + + public InboundWebhookCreateDtoIn { + mappingIdentifiers = mappingIdentifiers != null ? List.copyOf(mappingIdentifiers) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java new file mode 100644 index 00000000..ee59b552 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java @@ -0,0 +1,19 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY; + +import java.util.Map; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/// Security contract request payload represented as `{ type, config }`. +public record InboundWebhookSecurityContractDtoIn( + @NotBlank(message = WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY) String type, + @NotNull(message = WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY) Map config) { + + public InboundWebhookSecurityContractDtoIn { + config = config != null ? Map.copyOf(config) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookUpdateDtoIn.java new file mode 100644 index 00000000..1e4486d2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookUpdateDtoIn.java @@ -0,0 +1,24 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_NAME_MAX_LENGTH; + +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record InboundWebhookUpdateDtoIn( + @NotBlank(message = WEBHOOK_CONNECTOR_NAME_MANDATORY) @Size(max = 255, message = WEBHOOK_CONNECTOR_NAME_MAX_LENGTH) String name, + String description, boolean enabled, List mappingIdentifiers, + @Valid InboundWebhookSecurityContractDtoIn security) { + + public InboundWebhookUpdateDtoIn { + mappingIdentifiers = mappingIdentifiers != null ? List.copyOf(mappingIdentifiers) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java new file mode 100644 index 00000000..f56b8e64 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java @@ -0,0 +1,17 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping; + +import java.util.Map; + +/// Mapping rule returned by the inbound webhook management API. +public record EntityDynamicMappingDtoOut(String identifier, String entityTemplateIdentifier, + String filter, String name, String description, InboundWebhookEntityMappingDtoOut entity) { + /// Entity projection details exposed in webhook mapping responses. + public static record InboundWebhookEntityMappingDtoOut(String identifier, String name, + Map properties, Map relations) { + + public InboundWebhookEntityMappingDtoOut { + properties = properties != null ? Map.copyOf(properties) : null; + relations = relations != null ? Map.copyOf(relations) : null; + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java new file mode 100644 index 00000000..f6bcddb1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping; + +import java.util.Map; + +/// Entity projection details exposed in webhook mapping responses. +public record InboundWebhookEntityMappingDtoOut(String identifier, String title, + Map properties, Map relations) { + + public InboundWebhookEntityMappingDtoOut { + properties = properties != null ? Map.copyOf(properties) : null; + relations = relations != null ? Map.copyOf(relations) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java new file mode 100644 index 00000000..459a9085 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java @@ -0,0 +1,15 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +import java.util.List; + +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; + +/// Response payload for created inbound webhook connector. +public record InboundWebhookDtoOut(String identifier, String name, String description, + boolean enabled, List mappings, + InboundWebhookSecurityDtoOut security) { + + public InboundWebhookDtoOut { + mappings = mappings != null ? List.copyOf(mappings) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java new file mode 100644 index 00000000..a02994b1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +import java.util.Map; + +/// Security strategy returned for webhook configuration responses. +/// Only returns the strategy type to avoid exposing technical secret references. +public record InboundWebhookSecurityDtoOut(String type, Map config) { + + public InboundWebhookSecurityDtoOut { + config = config != null ? Map.copyOf(config) : null; + } +} 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 9a21a6d0..4c69fc17 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 @@ -10,6 +10,7 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -24,19 +25,11 @@ import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; 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.PropertyNameAlreadyExistsException; -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.RelationNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.*; +import com.decathlon.idp_core.domain.exception.entity_template.*; import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; +import com.decathlon.idp_core.domain.exception.webhook.*; import lombok.AllArgsConstructor; import lombok.Getter; @@ -79,6 +72,11 @@ public ResponseEntity handleTemplateNotFoundException( return ResponseEntity.status(NOT_FOUND).body(errorResponse); } + /// Handles domain exception for malformed filter query strings (`q=` DSL). + /// + /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad + /// Request + /// so API consumers receive clear feedback about invalid `q` parameter syntax. @ExceptionHandler(InvalidFilterDslException.class) public ResponseEntity handleInvalidFilterDslException( InvalidFilterDslException ex) { @@ -106,25 +104,27 @@ public ResponseEntity handleInvalidSearchQueryException( @ExceptionHandler(EntityTemplateAlreadyExistsException.class) public ResponseEntity handleEntityTemplateAlreadyExistsException( EntityTemplateAlreadyExistsException ex) { - log.warn("Entity template already exists: {}", ex.getMessage()); + log.warn("Entity entityTemplateIdentifier 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 template names already exist. + /// Handles domain exception when entity entityTemplateIdentifier names already + /// exist. /// /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to /// HTTP 409 status indicating business rule conflict for duplicate - /// template names. + /// entityTemplateIdentifier names. @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) public ResponseEntity handleEntityTemplateNameAlreadyExistsException( EntityTemplateNameAlreadyExistsException ex) { - log.warn("Entity template name already exists: {}", ex.getMessage()); + log.warn("Entity entityTemplateIdentifier name already exists: {}", ex.getMessage()); ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } - /// Handles domain exception when attempting to change an entity template + /// Handles domain exception when attempting to change an entity + /// entityTemplateIdentifier /// identifier. /// /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException @@ -133,25 +133,26 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( EntityTemplateIdentifierCannotChangeException ex) { - log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); + log.warn("Entity entityTemplateIdentifier identifier cannot be changed: {}", ex.getMessage()); ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } - /// Handles domain exception for wrong entity template property rules. + /// Handles domain exception for wrong entity entityTemplateIdentifier property + /// rules. /// /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to /// HTTP 400 status indicating validation error for wrong property rules. @ExceptionHandler(PropertyDefinitionRulesConflictException.class) public ResponseEntity handleWrongPropertyRulesException( PropertyDefinitionRulesConflictException ex) { - log.warn("Wrong Entity template property rules: {}", ex.getMessage()); + log.warn("Wrong Entity entityTemplateIdentifier property rules: {}", ex.getMessage()); ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /// Handles domain exception when property names are duplicated within a - /// template. + /// entityTemplateIdentifier. /// /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 /// status indicating validation error for duplicate property names. @@ -163,7 +164,7 @@ public ResponseEntity handlePropertyNameAlreadyExistsException( } /// Handles domain exception when relation names are duplicated within a - /// template. + /// entityTemplateIdentifier. /// /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 /// status indicating validation error for duplicate relation names. @@ -175,14 +176,15 @@ public ResponseEntity handleRelationNameAlreadyExistsException( } /// Handles domain exception when a relation references a non-existent target - /// template. + /// entityTemplateIdentifier. /// /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 - /// status indicating validation error for missing target template. + /// status indicating validation error for missing target + /// entityTemplateIdentifier. @ExceptionHandler(TargetTemplateNotFoundException.class) public ResponseEntity handleTargetTemplateNotFoundException( TargetTemplateNotFoundException ex) { - log.warn("Target template not found: {}", ex.getMessage()); + log.warn("Target entityTemplateIdentifier not found: {}", ex.getMessage()); return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } @@ -196,20 +198,23 @@ public ResponseEntity handleTypeChangeException(PropertyTypeChang return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } - /// Handles domain exception when relation target template changes are + /// Handles domain exception when relation target entityTemplateIdentifier + /// changes are /// attempted. /// /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP - /// 400 status indicating validation error for immutable target template field. + /// 400 status indicating validation error for immutable target + /// entityTemplateIdentifier field. @ExceptionHandler(RelationTargetTemplateChangeException.class) public ResponseEntity handleRelationTargetTemplateChangeException( RelationTargetTemplateChangeException ex) { - log.warn("Relation target template change error: {}", ex.getMessage()); + log.warn("Relation target entityTemplateIdentifier change error: {}", ex.getMessage()); return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } - /// Handles domain exception when a relation's target template identifier is the - /// template itself. + /// Handles domain exception when a relation's target entityTemplateIdentifier + /// identifier is the + /// entityTemplateIdentifier itself. /// /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP /// 400 @@ -288,6 +293,39 @@ public ResponseEntity handleHttpMessageNotReadableException( return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); } + /// Handles invalid dynamic mapping expressions (JSLT) provided in webhook + /// configuration. + /// + /// **HTTP mapping:** Maps domain mapping configuration failures to HTTP 400, + /// because clients can fix these expressions and retry. + @ExceptionHandler(EntityDynamicMappingConfigurationException.class) + public ResponseEntity handleEntityDynamicMappingConfigurationException( + EntityDynamicMappingConfigurationException ex) { + log.warn("Invalid entity dynamic mapping configuration: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(PropertyNameNotFoundEntityTemplatePropertiesException.class) + public ResponseEntity handlePropertyNameNotFoundEntityTemplatePropertiesException( + PropertyNameNotFoundEntityTemplatePropertiesException ex) { + log.warn("Webhook mapping references unknown property: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(RelationNameNotFoundEntityTemplateRelationsException.class) + public ResponseEntity handleRelationNameNotFoundEntityTemplateRelationsException( + RelationNameNotFoundEntityTemplateRelationsException ex) { + log.warn("Webhook mapping references unknown relation: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(WebhookSecurityConfigurationException.class) + public ResponseEntity handleWebhookSecurityConfigurationException( + WebhookSecurityConfigurationException ex) { + log.warn("Invalid webhook security configuration: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles domain exception when entities are not found. /// /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status @@ -442,6 +480,116 @@ public ResponseEntity handleGenericException(Exception ex) { return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); } + /// Handles webhook signature and credential validation failures. + /// + /// HTTP mapping: Maps WebhookAuthenticationException to HTTP 401 Unauthorized. + @ExceptionHandler(WebhookAuthenticationException.class) + public ResponseEntity handleWebhookAuthenticationException( + WebhookAuthenticationException ex) { + log.warn("Webhook authentication failed: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.UNAUTHORIZED.name(), + ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + + /// Handles missing webhook connector configuration. + /// + /// HTTP mapping: Maps WebhookConnectorNotFoundException to HTTP 404 Not Found. + @ExceptionHandler(WebhookConnectorNotFoundException.class) + public ResponseEntity handleWebhookConnectorNotFoundException( + WebhookConnectorNotFoundException ex) { + log.warn("Webhook connector not found: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + /// Handles a webhook connector referencing a non-existent entity dynamic + /// mapping. + /// + /// HTTP mapping: Maps EntityDynamicMappingNotFoundException to HTTP 404 Not + /// Found, because the referenced mapping must be created beforehand. + @ExceptionHandler(EntityDynamicMappingNotFoundException.class) + public ResponseEntity handleEntityDynamicMappingNotFoundException( + EntityDynamicMappingNotFoundException ex) { + log.warn("Referenced entity dynamic mapping not found: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + /// Handles creation of a dynamic mapping whose identifier already exists. + /// + /// HTTP mapping: Maps EntityDynamicMappingAlreadyExistsException to HTTP 409 + /// Conflict, surfacing the uniqueness violation with business meaning. + @ExceptionHandler(EntityDynamicMappingAlreadyExistsException.class) + public ResponseEntity handleEntityDynamicMappingAlreadyExistsException( + EntityDynamicMappingAlreadyExistsException ex) { + log.warn("Entity dynamic mapping identifier conflict: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles low-level database integrity violations (for example, unique + /// constraint breaches) that were not caught earlier by domain validation. + /// + /// HTTP mapping: Maps DataIntegrityViolationException to HTTP 409 Conflict to + /// avoid leaking technical SQL details while signaling a conflicting state. + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException( + DataIntegrityViolationException ex) { + log.warn("Data integrity violation: {}", ex.getMostSpecificCause().getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), + "The request conflicts with the current state of the resource"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + @ExceptionHandler(EntityDynamicMappingAlreadyInUseException.class) + public ResponseEntity handleEntityDynamicMappingAlreadyInUseException( + EntityDynamicMappingAlreadyInUseException ex) { + log.warn("Entity dynamic mapping already in use: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles webhook connector identifier duplication conflicts. + @ExceptionHandler(WebhookConnectorAlreadyExistException.class) + public ResponseEntity handleWebhookConnectorAlreadyExistException( + WebhookConnectorAlreadyExistException ex) { + log.warn("Webhook connector identifier conflict: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + @ExceptionHandler(EntityTemplateUsedByDynamicMappingException.class) + public ResponseEntity handleTemplateAlreadyMappedInWebhookConfiguration( + EntityTemplateUsedByDynamicMappingException ex) { + log.warn("Entity entityTemplateIdentifier in use by webhook mapping conflict: {}", + ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles webhook connector name duplication conflicts. + @ExceptionHandler(WebhookConnectorTitleAlreadyExistsException.class) + public ResponseEntity handleWebhookConnectorTitleAlreadyExistsException( + WebhookConnectorTitleAlreadyExistsException ex) { + log.warn("Webhook connector name conflict: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + @ExceptionHandler(EntityDynamicMappingHasNoPropertiesException.class) + public ResponseEntity handleEntityDynamicMappingHasNoPropertiesException( + EntityDynamicMappingHasNoPropertiesException ex) { + log.warn("Entity dynamic mapping has no properties: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(EntityDynamicMappingHasNoRelationsException.class) + public ResponseEntity handleEntityDynamicMappingHasNoRelationsException( + EntityDynamicMappingHasNoRelationsException ex) { + log.warn("Entity dynamic mapping has no relations: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + private static ResponseEntity createErrorResponse(HttpStatus httpStatus, String errorMessage) { return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java new file mode 100644 index 00000000..76aa51e4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java @@ -0,0 +1,95 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookSecurityDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingMapper; + +import lombok.AllArgsConstructor; + +/// Maps inbound webhook API DTOs to domain models and back. +@Component +@AllArgsConstructor +public class InboundWebhookMapper { + + private final EntityDynamicMappingMapper dynamicMappingMapper; + + /// Converts API input payload to the domain aggregate. + /// + /// @param dto inbound webhook creation request + /// @param resolvedMappings the existing dynamic mappings referenced by the + /// request, already resolved and validated by the domain layer + /// @return domain webhook connector + public WebhookConnector toDomain(InboundWebhookCreateDtoIn dto, + List resolvedMappings) { + return new WebhookConnector(null, dto.identifier(), dto.name(), dto.description(), + dto.enabled(), safeMappings(resolvedMappings), toDomain(dto.security())); + } + + /// Converts API update payload to domain aggregate using the path identifier as + /// source of truth. + /// + /// @param identifier webhook connector identifier from URL path + /// @param dto inbound webhook update request body + /// @param resolvedMappings the existing dynamic mappings referenced by the + /// request, already resolved and validated by the domain layer + /// @return domain webhook connector prepared for update + public WebhookConnector toDomainForUpdate(String identifier, InboundWebhookUpdateDtoIn dto, + List resolvedMappings) { + return new WebhookConnector(null, identifier, dto.name(), dto.description(), dto.enabled(), + safeMappings(resolvedMappings), toDomain(dto.security())); + } + + /// Converts domain aggregate to API response payload. + /// + /// @param domain created webhook connector + /// @return response DTO + public InboundWebhookDtoOut fromWebhookConnectorToDto(WebhookConnector domain) { + List mappings = domain.mappings().stream() + .map(dynamicMappingMapper::fromEntityMappingToDto).toList(); + InboundWebhookSecurityDtoOut security = new InboundWebhookSecurityDtoOut( + domain.security().type().name(), domain.security().config()); + return new InboundWebhookDtoOut(domain.identifier(), domain.name(), domain.description(), + domain.enabled(), mappings, security); + } + + private List safeMappings(List mappings) { + return mappings == null ? List.of() : List.copyOf(mappings); + } + + private WebhookSecurity toDomain(InboundWebhookSecurityContractDtoIn security) { + if (security == null) { + return new WebhookSecurity(WebhookSecurityType.NONE, Map.of()); + } + + var type = parseSecurityType(security.type()); + var config = safeMap(security.config()); + + return new WebhookSecurity(type, config); + } + + private WebhookSecurityType parseSecurityType(String typeString) { + try { + return WebhookSecurityType.valueOf(typeString.toUpperCase()); + } catch (IllegalArgumentException _) { + throw new WebhookSecurityConfigurationException("Unsupported security type: " + typeString); + } + } + + private Map safeMap(Map input) { + return input == null ? Map.of() : Map.copyOf(input); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/EntityDynamicMappingMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/EntityDynamicMappingMapper.java new file mode 100644 index 00000000..f5c73595 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/EntityDynamicMappingMapper.java @@ -0,0 +1,66 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingDtoInCommonFields; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut; + +@Component +public class EntityDynamicMappingMapper { + + public EntityDynamicMapping toDomain(EntityDynamicMappingCreateDtoIn mapping) { + // Map each DTO field explicitly to its matching domain field. The + // EntityDynamicMapping + // constructor order is (id, identifier, entityTemplateIdentifier, filter, + // entityIdentifier, + // entityName, properties, relations); keeping this alignment prevents the + // entityTemplateIdentifier + // identifier and the filter expression from being swapped. + EntityDynamicMappingDtoInCommonFields fields = mapping.commonFields(); + return new EntityDynamicMapping(null, // id (assigned by persistence layer) + mapping.identifier(), // identifier + fields.entityTemplateIdentifier(), // entityTemplateIdentifier + fields.filter(), // filter + fields.name(), // titre + fields.description(), fields.entity().identifier(), // entityIdentifier + fields.entity().name(), // entityName + safeMap(fields.entity().properties()), // properties + safeMap(fields.entity().relations())); // relations + } + + public EntityDynamicMappingDtoOut fromEntityMappingToDto(EntityDynamicMapping mapping) { + return new EntityDynamicMappingDtoOut(mapping.identifier(), mapping.entityTemplateIdentifier(), + mapping.filter(), mapping.name(), mapping.description(), + new EntityDynamicMappingDtoOut.InboundWebhookEntityMappingDtoOut(mapping.entityIdentifier(), + mapping.entityName(), Map.copyOf(mapping.properties()), + Map.copyOf(mapping.relations()))); + } + + /// Converts an update DTO to domain model, using the identifier from the path. + /// + /// @param identifier the mapping identifier from the URL path + /// @param dto the update request body + /// @return the domain model for update + public EntityDynamicMapping toDomainForUpdate(String identifier, + EntityDynamicMappingUpdateDtoIn dto) { + var fields = dto.commonFields(); + return new EntityDynamicMapping(null, // id (will be set from existing entity) + identifier, // identifier from path + fields.entityTemplateIdentifier(), // entityTemplateIdentifier + fields.filter(), // filter + fields.name(), // titre + fields.description(), fields.entity().identifier(), // entityIdentifier + fields.entity().name(), // entityName + safeMap(fields.entity().properties()), // properties + safeMap(fields.entity().relations())); // relations + } + + private Map safeMap(Map input) { + return input == null ? Map.of() : Map.copyOf(input); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java new file mode 100644 index 00000000..12da5445 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java @@ -0,0 +1,100 @@ +package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator; +import com.schibsted.spt.data.jslt.JsltException; +import com.schibsted.spt.data.jslt.Parser; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class JsltEntityMappingValidator implements EntityDynamicMapperValidator { + + private static final Pattern LOCATION_PATTERN = Pattern + .compile("line\\s+(\\d+),\\s+column\\s+(\\d+)"); + private static final Pattern TOKEN_PATTERN = Pattern.compile("Encountered\\s+\"([^\"]+)\""); + + @Override + public void validate(EntityDynamicMapping mapping) { + List errors = new ArrayList<>(); + + checkExpression(errors, "filter", mapping.filter()); + + checkExpression(errors, "entityIdentifier", mapping.entityIdentifier()); + checkExpression(errors, "entityName", mapping.entityName()); + + if (mapping.properties() != null && !mapping.properties().isEmpty()) { + mapping.properties() + .forEach((key, expr) -> checkExpression(errors, "properties." + key, expr)); + } + if (mapping.relations() != null && !mapping.relations().isEmpty()) { + mapping.relations().forEach((key, expr) -> checkExpression(errors, "relations." + key, expr)); + } + + if (!errors.isEmpty()) { + throw new EntityDynamicMappingConfigurationException(String.format( + "Validation failed with %d errors: %s", errors.size(), String.join(" | ", errors))); + } + } + + private void checkExpression(List errors, String fieldName, String expression) { + if (!StringUtils.hasText(expression)) { + errors.add( + String.format("Field '%s' is required and must contain a JSLT expression.", fieldName)); + return; + } + + try { + new Parser(new StringReader(expression)).compile(); + } catch (JsltException exception) { + errors.add(String.format("Invalid expression for '%s': %s", fieldName, + formatJsltErrorMessage(exception.getMessage()))); + } + } + + private String formatJsltErrorMessage(String rawMessage) { + if (!StringUtils.hasText(rawMessage)) { + return "JSLT syntax error."; + } + + String normalized = rawMessage.replaceAll("\\s+", " ").trim(); + if (normalized.startsWith("Parse error:")) { + normalized = normalized.substring("Parse error:".length()).trim(); + } + + String line = null; + String column = null; + Matcher locationMatcher = LOCATION_PATTERN.matcher(rawMessage); + if (locationMatcher.find()) { + line = locationMatcher.group(1); + column = locationMatcher.group(2); + } + + String token = null; + Matcher tokenMatcher = TOKEN_PATTERN.matcher(rawMessage); + if (tokenMatcher.find()) { + token = tokenMatcher.group(1); + } + + if (line != null && column != null && token != null) { + return String.format("JSLT syntax error at line %s, column %s (unexpected token: %s).", line, + column, token); + } + if (line != null && column != null) { + return String.format("JSLT syntax error at line %s, column %s.", line, column); + } + + return normalized; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java new file mode 100644 index 00000000..dadc21c9 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java @@ -0,0 +1,94 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter for [EntityDynamicMapping] read and write operations. +@Component +@RequiredArgsConstructor +public class EntityDynamicMappingAdaptor implements EntityDynamicMappingPort { + private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository; + private final EntityDynamicMappingPersistenceMapper entityDynamicMappingPersistenceMapper; + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + + @Override + public List findByEntityTemplateIdentifier(String identifier) { + return jpaEntityDynamicMappingRepository.findByTemplateIdentifier(identifier).stream() + .map(entityDynamicMappingPersistenceMapper::toDomain).toList(); + } + + @Override + public List findByEntityTemplateId(UUID templateId) { + return jpaEntityDynamicMappingRepository.findByTemplateId(templateId).stream() + .map(entityDynamicMappingPersistenceMapper::toDomain).toList(); + } + + @Override + public Boolean existsByEntityTemplateIdentifier(String templateIdentifier) { + return jpaEntityDynamicMappingRepository.existsByTemplateIdentifier(templateIdentifier); + } + + @Override + public boolean existsByIdentifier(String identifier) { + return jpaEntityDynamicMappingRepository.existsByIdentifier(identifier); + } + + @Override + public Optional findByIdentifier(String identifier) { + return jpaEntityDynamicMappingRepository.findByIdentifier(identifier) + .map(entityDynamicMappingPersistenceMapper::toDomain); + } + + @Override + public EntityDynamicMapping save(EntityDynamicMapping entityDynamicMapping) { + // The domain model references the entityTemplateIdentifier by its business + // identifier, but the + // foreign key persisted in `entity_dynamic_mapping.template_id` is the + // entityTemplateIdentifier + // UUID. Resolve the identifier to the entityTemplateIdentifier id before saving + // (fail-fast). + UUID templateId = entityTemplateRepositoryPort + .findByIdentifier(entityDynamicMapping.entityTemplateIdentifier()).map(EntityTemplate::id) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", + entityDynamicMapping.entityTemplateIdentifier())); + + EntityDynamicMappingJpaEntity entityToPersist = entityDynamicMappingPersistenceMapper + .toJpa(entityDynamicMapping); + entityToPersist.setEntityTemplateId(templateId); + EntityDynamicMappingJpaEntity persistedEntity = jpaEntityDynamicMappingRepository + .save(entityToPersist); + + return new EntityDynamicMapping(persistedEntity.getId(), entityDynamicMapping.identifier(), + entityDynamicMapping.entityTemplateIdentifier(), entityDynamicMapping.filter(), + entityDynamicMapping.name(), entityDynamicMapping.description(), + entityDynamicMapping.entityIdentifier(), entityDynamicMapping.entityName(), + entityDynamicMapping.properties(), entityDynamicMapping.relations()); + } + + @Override + public Page findAll(Pageable pageable) { + return jpaEntityDynamicMappingRepository.findAll(pageable) + .map(entityDynamicMappingPersistenceMapper::toDomain); + } + + @Override + public void deleteByIdentifier(String identifier) { + jpaEntityDynamicMappingRepository.deleteByIdentifier(identifier); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java index af72cfd0..b4de92e6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java @@ -90,6 +90,11 @@ public void deleteByIdentifier(String identifier) { jpaEntityTemplateRepository.deleteByIdentifier(identifier); } + @Override + public boolean existsById(UUID id) { + return jpaEntityTemplateRepository.existsById(id); + } + // ── Merge helpers to update a managed JPA entity from domain values ── private void mergeIntoExisting(EntityTemplateJpaEntity jpa, EntityTemplate domain) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java new file mode 100644 index 00000000..a74f9ee4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java @@ -0,0 +1,188 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookConnectorRepository; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter implementing [WebhookConnectorRepositoryPort]. +/// +/// Delegates to Spring Data JPA and uses [WebhookConnectorPersistenceMapper] +/// to convert between JPA entities and domain models. +/// +/// Handles the complex persistence of mappings across three tables: +/// - webhook_connector (core connector data) +/// - entity_dynamic_mapping (mapping configurations) +/// - webhook_template_mapping (many-to-many link) +@Component +@RequiredArgsConstructor +public class PostgresWebhookConnectorAdapter implements WebhookConnectorRepositoryPort { + + private final JpaWebhookConnectorRepository jpaWebhookConnectorRepository; + private final WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository; + private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository; + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final EntityDynamicMappingPort entityDynamicMappingPort; + private final WebhookConnectorPersistenceMapper mapper; + private final EntityDynamicMappingPersistenceMapper mappingMapper; + + @Override + public Optional findByIdentifier(String identifier) { + return jpaWebhookConnectorRepository.findByIdentifier(identifier) + .map(this::loadConnectorWithMappings); + } + + @Override + public Page findAll(Pageable pageable) { + Page jpaPage = jpaWebhookConnectorRepository.findAll(pageable); + + if (jpaPage.isEmpty()) { + return jpaPage.map(mapper::toDomain); + } + + // Collect all webhook IDs from the page + List webhookIds = jpaPage.stream().map(WebhookConnectorJpaEntity::getId).toList(); + + // Batch load all entityTemplateIdentifier mappings for all webhooks in the page + List allTemplateMappings = jpaWebhookTemplateMappingRepository + .findByWebhookIdIn(webhookIds); + + // Collect all unique mapping IDs + List allMappingIds = allTemplateMappings.stream() + .map(WebhookMappingLinkJpaEntity::getEntityMappingId).distinct().toList(); + + // Batch load all entity mappings in one query + Map allMappingsById = jpaEntityDynamicMappingRepository + .findAllById(allMappingIds).stream() + .collect(Collectors.toMap(EntityDynamicMappingJpaEntity::getId, mappingMapper::toDomain, + (existing, replacement) -> existing)); + + // Group mappings by webhook ID + Map> mappingsByWebhookId = allTemplateMappings.stream() + .collect(Collectors.groupingBy(WebhookMappingLinkJpaEntity::getWebhookId, + Collectors.mapping(wtm -> allMappingsById.get(wtm.getEntityMappingId()), + Collectors.filtering(Objects::nonNull, Collectors.toList())))); + + // Map each JPA entity to domain with its mappings + return jpaPage.map(jpaEntity -> { + WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity); + List mappings = mappingsByWebhookId.getOrDefault(jpaEntity.getId(), + Collections.emptyList()); + + return new WebhookConnector(connectorWithoutMappings.id(), + connectorWithoutMappings.identifier(), connectorWithoutMappings.name(), + connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings, + connectorWithoutMappings.security()); + }); + } + + @Override + public boolean existsByIdentifier(String identifier) { + return jpaWebhookConnectorRepository.existsByIdentifier(identifier); + } + + @Override + public boolean existsByTitle(String title) { + return jpaWebhookConnectorRepository.existsByName(title); + } + + @Override + public WebhookConnector save(WebhookConnector connector) { + WebhookConnectorJpaEntity savedConnector = jpaWebhookConnectorRepository + .save(mapper.toJpa(connector)); + persistTemplateMappings(savedConnector.getId(), connector); + return loadConnectorWithMappings(savedConnector); + } + + @Override + public void deleteByIdentifier(String identifier) { + jpaWebhookConnectorRepository.deleteByIdentifier(identifier); + } + + /// Loads a connector with its associated mappings from the + /// webhook_template_mapping table. + /// Since WebhookConnector is a Record (immutable), we create a new instance + /// with the loaded mappings. + private WebhookConnector loadConnectorWithMappings(WebhookConnectorJpaEntity jpaEntity) { + WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity); + List mappings = loadMappingsForWebhook(jpaEntity.getId()); + + // Since WebhookConnector is a Record, create a new instance with loaded + // mappings + return new WebhookConnector(connectorWithoutMappings.id(), + connectorWithoutMappings.identifier(), connectorWithoutMappings.name(), + connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings, + connectorWithoutMappings.security()); + } + + /// Loads all dynamic mappings associated with a webhook connector. + /// Uses batch loading to avoid N+1 query problem. + private List loadMappingsForWebhook(UUID webhookId) { + List templateMappings = jpaWebhookTemplateMappingRepository + .findByWebhookId(webhookId); + List mappingIds = templateMappings.stream() + .map(WebhookMappingLinkJpaEntity::getEntityMappingId).toList(); + if (mappingIds.isEmpty()) { + return List.of(); + } + Map mappingsById = jpaEntityDynamicMappingRepository + .findAllById(mappingIds).stream() + .collect(Collectors.toMap(EntityDynamicMappingJpaEntity::getId, mappingMapper::toDomain)); + return mappingIds.stream().map(mappingsById::get).filter(Objects::nonNull).toList(); + } + + /// Persists the webhook's entityTemplateIdentifier mappings in the + /// webhook_template_mapping + /// table. + /// This also persists each EntityDynamicMapping if it's new. + private void persistTemplateMappings(UUID webhookId, WebhookConnector connector) { + jpaWebhookTemplateMappingRepository.deleteByWebhookId(webhookId); + var mappings = connector.mappings().stream() + .map(mapping -> persistAndCreateTemplateMapping(webhookId, mapping)).toList(); + + if (!mappings.isEmpty()) { + jpaWebhookTemplateMappingRepository.saveAll(mappings); + } + } + + /// Persists a single EntityDynamicMapping and creates a + /// WebhookTemplateMappingJpaEntity link. + /// + /// The mapping is expected to already exist because it is created through the + /// dedicated inbound dynamic mapping endpoint. This method only creates the + /// association row in webhook_template_mapping. + private WebhookMappingLinkJpaEntity persistAndCreateTemplateMapping(UUID webhookId, + EntityDynamicMapping mapping) { + + EntityDynamicMapping entityDynamicMapping = entityDynamicMappingPort + .findByIdentifier(mapping.identifier()) + .orElseThrow(() -> new EntityDynamicMappingNotFoundException(mapping.identifier())); + + return WebhookMappingLinkJpaEntity.builder().webhookId(webhookId) + .entityMappingId(entityDynamicMapping.id()).jsltFilter(mapping.filter()).build(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java new file mode 100644 index 00000000..25ec2552 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java @@ -0,0 +1,34 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookMappingLinkPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter for webhook-entityTemplateIdentifier mapping read operations. +@Component +@RequiredArgsConstructor +public class WebhookTemplateMappingAdaptor implements WebhookMappingLinkPort { + + private final WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository; + private final WebhookMappingLinkPersistenceMapper webhookMappingLinkPersistenceMapper; + + @Override + public boolean existsByEntityMappingId(UUID id) { + return jpaWebhookTemplateMappingRepository.existsByEntityMappingId(id); + } + + @Override + public List findByEntityMappingId(UUID id) { + return jpaWebhookTemplateMappingRepository.findByEntityMappingId(id).stream() + .map(webhookMappingLinkPersistenceMapper::toDomain).toList(); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java new file mode 100644 index 00000000..d53ae887 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java @@ -0,0 +1,40 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import static org.mapstruct.MappingConstants.ComponentModel.SPRING; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.EntityDynamicMappingJsonbHelper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; + +/// MapStruct persistence mapper for [EntityDynamicMapping]. +/// +/// Maps between domain model (EntityDynamicMapping) and JPA entity (EntityDynamicMappingJpaEntity). +/// Handles JSONB columns for properties and relations via the dedicated helper. +@Mapper(componentModel = SPRING, uses = EntityDynamicMappingJsonbHelper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR) +public interface EntityDynamicMappingPersistenceMapper { + + @Mapping(target = "properties", qualifiedByName = "jsonStringToMap") + @Mapping(target = "relations", qualifiedByName = "jsonStringToMap") + @Mapping(target = "entityTemplateIdentifier", source = "template.identifier") + // Explicit self-mapping: MapStruct otherwise silently drops the + // `entityIdentifier` property, leaving the NOT NULL column unset. + @Mapping(target = "entityIdentifier", source = "entityIdentifier") + @Mapping(target = "entityName", source = "entityName") + EntityDynamicMapping toDomain(EntityDynamicMappingJpaEntity jpa); + + @Mapping(target = "properties", qualifiedByName = "mapToJsonString") + @Mapping(target = "relations", qualifiedByName = "mapToJsonString") + // The template foreign key (UUID) is resolved from the business identifier and + // set by the persistence adapter, so both template fields are ignored here. + @Mapping(target = "entityTemplateId", ignore = true) + @Mapping(target = "template", ignore = true) + // Explicit self-mapping: MapStruct otherwise silently drops the + // `entityIdentifier` property, leaving the NOT NULL column unset. + @Mapping(target = "entityIdentifier", source = "entityIdentifier") + @Mapping(target = "entityName", source = "entityName") + EntityDynamicMappingJpaEntity toJpa(EntityDynamicMapping domain); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java new file mode 100644 index 00000000..7cd398c4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import static org.mapstruct.MappingConstants.ComponentModel.SPRING; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.WebhookConnectorJsonbHelper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity; + +/// MapStruct persistence mapper for [WebhookConnector]. +/// +/// Maps the connector's direct fields (identifier, name, description, enabled, security). +/// The mappings list is handled separately by +/// [com.decathlon.idp_core.infrastructure.adapters.persistence.PostgresWebhookConnectorAdapter] +/// through the `webhook_template_mapping` table because it requires dedicated persistence +/// for `entity_dynamic_mapping` rows. +@Mapper(componentModel = SPRING, uses = WebhookConnectorJsonbHelper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR) +public interface WebhookConnectorPersistenceMapper { + + @Mapping(target = "mappings", ignore = true) + @Mapping(target = "security", qualifiedByName = "jsonToSecurity") + WebhookConnector toDomain(WebhookConnectorJpaEntity jpa); + + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "security", qualifiedByName = "securityToJson") + WebhookConnectorJpaEntity toJpa(WebhookConnector domain); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java new file mode 100644 index 00000000..ee15cd59 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java @@ -0,0 +1,57 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import java.util.UUID; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; + +/// Persistence mapper for [WebhookTemplateMapping]. +/// +/// Maps the association entity between webhook connector, entity entityTemplateIdentifier and +/// dynamic mapping configuration. Foreign keys are managed explicitly by adapters +/// when persisting new links. +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = { + WebhookConnectorPersistenceMapper.class, EntityTemplatePersistenceMapper.class, + EntityDynamicMappingPersistenceMapper.class}, injectionStrategy = InjectionStrategy.CONSTRUCTOR) +public interface WebhookMappingLinkPersistenceMapper { + + /// Maps JPA association data to the domain model. + /// + /// @param jpa persisted association entity + /// @return mapped domain model + @Mapping(target = "id", ignore = true) + @Mapping(target = "webhookConnector", source = "webhookConnector") + @Mapping(target = "entityDynamicMapping", source = "entityMapping") + @Mapping(target = "jsltFilter", source = "jsltFilter") + WebhookTemplateMapping toDomain(WebhookMappingLinkJpaEntity jpa); + + /// Maps domain model to JPA association entity. + /// + /// All technical IDs are preserved from the domain model. + /// + /// @param domain domain mapping object + /// @return fully mapped JPA association entity + @Mapping(target = "webhookId", source = "webhookConnector.id") + @Mapping(target = "entityMappingId", source = "entityDynamicMapping.id") + @Mapping(target = "jsltFilter", source = "jsltFilter") + @Mapping(target = "webhookConnector", ignore = true) + @Mapping(target = "entityMapping", ignore = true) + WebhookMappingLinkJpaEntity toJpa(WebhookTemplateMapping domain); + + /// Builds a link row with explicit foreign keys. + /// + /// @param webhookId webhook connector technical id + /// @param entityMappingId dynamic mapping technical id + /// @param jsltFilter JSLT filter expression + /// @return link entity ready for persistence + default WebhookMappingLinkJpaEntity toJpa(UUID webhookId, UUID entityMappingId, + String jsltFilter) { + return WebhookMappingLinkJpaEntity.builder().webhookId(webhookId) + .entityMappingId(entityMappingId).jsltFilter(jsltFilter).build(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java new file mode 100644 index 00000000..7fb0a383 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java @@ -0,0 +1,51 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common; + +import java.util.Map; + +import org.mapstruct.Named; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/// Technical helper for JSONB serialization/deserialization in the persistence layer. +/// +/// Provides named conversion methods used by [com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper] +/// via MapStruct's `qualifiedByName` annotation. +/// +/// This is a pure utility class with no Spring dependencies, facilitating testability and reusability. +@Component +public class EntityDynamicMappingJsonbHelper { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /// Converts JSONB string to `Map`. + /// Used when loading from database. + @Named("jsonStringToMap") + public Map toMap(String json) { + if (json == null || json.trim().isEmpty()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid JSON mapping configuration", e); + } + } + + /// Converts `Map` to JSONB string. + /// Used when persisting to database. + @Named("mapToJsonString") + public String toJsonString(Map map) { + if (map == null || map.isEmpty()) { + return "{}"; + } + try { + return OBJECT_MAPPER.writeValueAsString(map); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to serialize mapping configuration", e); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java new file mode 100644 index 00000000..ddddd4ae --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java @@ -0,0 +1,46 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common; + +import org.mapstruct.Named; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/// Technical helper for JSONB serialization/deserialization in the persistence layer. +/// +/// Provides named conversion methods used by [WebhookConnectorPersistenceMapper] +/// via MapStruct's `qualifiedByName` annotation. +/// +/// This is a pure utility class with no Spring dependencies, facilitating testability and reusability. +@Component +public class WebhookConnectorJsonbHelper { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /// Converts JSONB string to WebhookSecurity domain model. + /// Defaults to NONE type if JSON is empty. + @Named("jsonToSecurity") + public WebhookSecurity toSecurity(String json) { + if (json == null || json.trim().isEmpty()) { + return new WebhookSecurity(WebhookSecurityType.NONE, java.util.Map.of()); + } + try { + return OBJECT_MAPPER.readValue(json, WebhookSecurity.class); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid webhook connector security JSONB", e); + } + } + + /// Converts WebhookSecurity domain model to JSON string. + @Named("securityToJson") + public String toSecurityJson(WebhookSecurity security) { + try { + return OBJECT_MAPPER.writeValueAsString(security); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to serialize webhook connector security", e); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_dynamic_mapping/EntityDynamicMappingJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_dynamic_mapping/EntityDynamicMappingJpaEntity.java new file mode 100644 index 00000000..5680bfdd --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_dynamic_mapping/EntityDynamicMappingJpaEntity.java @@ -0,0 +1,88 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping; + +import java.util.UUID; + +import jakarta.persistence.*; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template.EntityTemplateJpaEntity; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +/// JPA entity for the `entity_dynamic_mapping` table. +/// +/// Stores dynamic mapping configurations used by webhook connectors to transform +/// inbound events into entities (JSLT filters, property/relation mappings). +@Entity +@Getter +@Setter +@ToString(onlyExplicitlyIncluded = true) +@EqualsAndHashCode +@Table(name = "entity_dynamic_mapping") +@NoArgsConstructor +@AllArgsConstructor +public class EntityDynamicMappingJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @ToString.Include + private UUID id; + + @Column(name = "identifier", nullable = false, unique = true) + @ToString.Include + private String identifier; + + /// Foreign key to the parent entity entityTemplateIdentifier + /// (`entity_template.id`). + /// + /// Persisted as the entityTemplateIdentifier UUID even though the public API + /// (DTO In) exposes + /// the entityTemplateIdentifier business identifier. The identifier is resolved + /// to this UUID + /// in the persistence adapter before saving. + @Column(name = "template_id", nullable = false) + private UUID entityTemplateId; + + /// Lazy, read-only navigation to the referenced entityTemplateIdentifier. + /// + /// Used to expose the entityTemplateIdentifier business identifier when mapping + /// back to the + /// domain model, without triggering an additional explicit query. Marked as + /// non-insertable/non-updatable because the `template_id` column above owns + /// the foreign key. + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "template_id", insertable = false, updatable = false) + @EqualsAndHashCode.Exclude + private EntityTemplateJpaEntity template; + + @Column(nullable = false) + private String filter; + + @Column(nullable = false) + @ToString.Include + private String name; + + @ToString.Include + private String description; + + @Column(name = "entity_identifier", nullable = false) + private String entityIdentifier; + + @Column(name = "entity_name", nullable = false) + private String entityName; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private String properties; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private String relations; +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java new file mode 100644 index 00000000..08462112 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java @@ -0,0 +1,70 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.*; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import lombok.*; + +/// JPA entity mapping to the `webhook_connector` PostgreSQL table. +/// +/// The `security` JSONB column is stored as a raw JSON string and deserialized +/// in [WebhookConnectorPersistenceMapper] using Jackson. +/// The webhook security payload follows the generic `{ type, config }` contract at the adapter boundary. +@Entity +@Table(name = "webhook_connector") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@Builder +@ToString(onlyExplicitlyIncluded = true) +@NoArgsConstructor +@AllArgsConstructor +public class WebhookConnectorJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @ToString.Include + private UUID id; + + /// Business key used in the webhook URL: POST /webhooks/{identifier} + @Column(nullable = false, unique = true, length = 255) + @ToString.Include + private String identifier; + + /// Human-readable name displayed in the management UI + @Column(nullable = false, length = 255) + private String name; + + /// Optional description of the connector purpose + @Column(columnDefinition = "TEXT") + private String description; + + /// When false, the connector rejects all inbound events without processing them + @Column(nullable = false) + private Boolean enabled; + + /// JSONB security configuration — deserialized to WebhookSecurity by the + /// mapper. + /// The "type" discriminator field drives polymorphic deserialization. + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private String security; + + /// Timestamp of connector creation + @CreatedDate + @Column(nullable = false, updatable = false) + private Instant createdAt; + + /// Timestamp of last connector update + @LastModifiedDate + @Column(nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookMappingLinkJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookMappingLinkJpaEntity.java new file mode 100644 index 00000000..4d2cfe02 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookMappingLinkJpaEntity.java @@ -0,0 +1,70 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook; + +import java.util.UUID; + +import jakarta.persistence.*; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; + +import lombok.*; + +/// JPA entity for the webhook mapping link, representing the association between a webhook connector, +/// an entity template, and the dynamic mapping (with JSLT filter) to apply during ingestion. +/// +/// The table uses a composite primary key `(webhook_id, entity_mapping_id)`; there is no +/// surrogate id column. +@Entity +@Table(name = "webhook_mapping_link") +@IdClass(WebhookMappingLinkJpaEntity.WebhookMappingLinkId.class) +@Getter +@Setter +@Builder +@ToString(onlyExplicitlyIncluded = true) +@NoArgsConstructor +@AllArgsConstructor +public class WebhookMappingLinkJpaEntity { + + /// Foreign key to the parent webhook connector. Part of the composite primary + /// key. + @Id + @ToString.Include + @Column(name = "webhook_id", nullable = false) + private UUID webhookId; + + /// Foreign key to the dynamic mapping configuration. Part of the composite + /// primary key. + @Id + @ToString.Include + @Column(name = "entity_mapping_id", nullable = false) + private UUID entityMappingId; + + /// The JSLT filter expression applied during event ingestion. + /// Typically derived from the dynamic mapping configuration, but stored here + /// for direct access and querying. + /// + @ToString.Include + @Column(name = "jslt_filter", columnDefinition = "TEXT") + private String jsltFilter; + + /// Lazy-loaded relationship to the webhook connector (optional, for + /// navigation). + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "webhook_id", nullable = false, insertable = false, updatable = false) + private WebhookConnectorJpaEntity webhookConnector; + + /// Lazy-loaded relationship to the dynamic mapping (optional, for navigation). + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "entity_mapping_id", nullable = false, insertable = false, updatable = false) + private EntityDynamicMappingJpaEntity entityMapping; + + /// Composite primary key class for [WebhookMappingLinkJpaEntity]. + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @EqualsAndHashCode + public static class WebhookMappingLinkId implements java.io.Serializable { + private UUID webhookId; + private UUID entityMappingId; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java new file mode 100644 index 00000000..c34a121c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java @@ -0,0 +1,69 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.jspecify.annotations.NonNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; + +/// JPA repository for EntityDynamicMapping persistence. +/// +/// Manages the `entity_dynamic_mapping` table which stores mapping configurations +/// (JSLT filters, property/relation mappings) used by webhook template mappings. +/// +/// Read methods that are mapped back to the domain model eagerly load the +/// `template` association (via [EntityGraph] or an explicit `join fetch`). The +/// persistence mapper navigates `template.identifier`, so leaving the LAZY proxy +/// uninitialized would raise a `LazyInitializationException` once the mapping +/// happens outside the session. +@Repository +public interface JpaEntityDynamicMappingRepository + extends + JpaRepository { + + /// Filters on the associated template business identifier. An explicit query + /// with `join fetch` is used because a derived query traversing + /// `template.identifier` does not bind reliably under Hibernate. + @Query(""" + select edm from EntityDynamicMappingJpaEntity edm + join fetch edm.template t + where t.identifier = :identifier + """) + List findByTemplateIdentifier( + @Param("identifier") String identifier); + + List findByTemplateId(UUID templateId); + + @Query(""" + select count(edm) > 0 from EntityDynamicMappingJpaEntity edm + where edm.template.identifier = :identifier + """) + boolean existsByTemplateIdentifier(@Param("identifier") String identifier); + + boolean existsByIdentifier(String identifier); + + @EntityGraph(attributePaths = "template") + Optional findByIdentifier(String identifier); + + @Override + @NonNull + @EntityGraph(attributePaths = "template") + Page findAll(@NonNull Pageable pageable); + + @Override + @NonNull + @EntityGraph(attributePaths = "template") + List findAllById(@NonNull Iterable ids); + + void deleteByIdentifier(String identifier); + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java new file mode 100644 index 00000000..7cd9d0ea --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java @@ -0,0 +1,20 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity; + +public interface JpaWebhookConnectorRepository + extends + JpaRepository { + Optional findByIdentifier(String identifier); + + boolean existsByIdentifier(String identifier); + + boolean existsByName(String name); + + void deleteByIdentifier(String identifier); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/WebhookMappingLinkRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/WebhookMappingLinkRepository.java new file mode 100644 index 00000000..a2f02803 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/WebhookMappingLinkRepository.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; + +public interface WebhookMappingLinkRepository + extends + JpaRepository { + + /// Deletes all mappings associated with a webhook connector. + void deleteByWebhookId(UUID webhookId); + + /// Retrieves all mappings associated with a webhook connector. + List findByWebhookId(UUID webhookId); + + List findByWebhookIdIn(List webhookIds); + + boolean existsByEntityMappingId(UUID entityMappingId); + + List findByEntityMappingId(UUID entityMappingId); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java new file mode 100644 index 00000000..03680eb7 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java @@ -0,0 +1,32 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +import lombok.NoArgsConstructor; + +/// Basic Authentication security strategy for webhooks. +/// +/// Validates Basic Auth credentials at both creation time (configuration validation) +/// and runtime (request authentication). +@Component +@NoArgsConstructor +public class BasicAuthSecurityValidator implements WebhookSecurityStrategy { + + @Override + public boolean supports(WebhookSecurityType securityType) { + return WebhookSecurityType.BASIC_AUTH == securityType; + } + + @Override + public void validateConfiguration(Map config) { + WebhookSecurityConfigurationUtils.required(config, "username"); + String alias = WebhookSecurityConfigurationUtils.required(config, "secret_alias", + "secretAlias"); + WebhookSecurityConfigurationUtils.validateSecretAliasFormat(alias); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java new file mode 100644 index 00000000..47e338da --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java @@ -0,0 +1,33 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +import lombok.NoArgsConstructor; + +/// HMAC SHA256 security strategy for webhooks. +/// +/// Validates HMAC SHA256 signature configuration at creation time and authenticates +/// incoming webhook requests by verifying the signature against a stored secret. +@Component +@NoArgsConstructor +public class HmacSha256SecurityValidator implements WebhookSecurityStrategy { + + @Override + public boolean supports(WebhookSecurityType securityType) { + return WebhookSecurityType.HMAC_SHA256 == securityType; + } + + @Override + public void validateConfiguration(Map config) { + WebhookSecurityConfigurationUtils.required(config, "header_name", "headerName"); + String alias = WebhookSecurityConfigurationUtils.required(config, "secret_alias", + "secretAlias"); + WebhookSecurityConfigurationUtils.validateSecretAliasFormat(alias); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java new file mode 100644 index 00000000..47ad1665 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java @@ -0,0 +1,36 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.nio.charset.StandardCharsets; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; + +@Component +public class HmacSignatureValidator { + + public String computeHexSha256(byte[] payload, String secret) { + try { + if (secret == null || secret.isBlank()) { + throw new WebhookAuthenticationException("Unable to compute HMAC signature"); + } + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] digest = mac.doFinal(payload); + return toHex(digest); + } catch (Exception e) { + throw new WebhookAuthenticationException("Unable to compute HMAC signature", e); + } + } + + private String toHex(byte[] input) { + StringBuilder sb = new StringBuilder(input.length * 2); + for (byte value : input) { + sb.append(String.format("%02x", value)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java new file mode 100644 index 00000000..34e1bb65 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java @@ -0,0 +1,37 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +import lombok.NoArgsConstructor; + +/// JWT Bearer security strategy for webhooks. +/// +/// Validates JWT Bearer configuration at creation time and authenticates incoming +/// webhook requests by verifying the JWT token against a JWKS endpoint. +@Component +@NoArgsConstructor +public class JwtBearerSecurityValidator implements WebhookSecurityStrategy { + + @Override + public boolean supports(WebhookSecurityType securityType) { + return WebhookSecurityType.JWT_BEARER == securityType; + } + + @Override + public void validateConfiguration(Map config) { + String jwksUriValue = WebhookSecurityConfigurationUtils.required(config, "jwks_uri", "jwksUri"); + if (jwksUriValue.isBlank()) { + throw new WebhookSecurityConfigurationException("Invalid jwks_uri for JWT_BEARER security"); + } + + if (WebhookSecurityConfigurationUtils.isEnvironmentReference(jwksUriValue)) { + WebhookSecurityConfigurationUtils.validateSecretAliasFormat(jwksUriValue); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java new file mode 100644 index 00000000..374cb303 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +/// Static Token security strategy for webhooks. +/// +/// Validates static token configuration at creation time and authenticates incoming +@Component +public class StaticTokenSecurityValidator implements WebhookSecurityStrategy { + + @Override + public boolean supports(WebhookSecurityType securityType) { + return WebhookSecurityType.STATIC_TOKEN == securityType; + } + + @Override + public void validateConfiguration(Map config) { + WebhookSecurityConfigurationUtils.required(config, "header_name", "headerName"); + String alias = WebhookSecurityConfigurationUtils.required(config, "secret_alias", + "secretAlias"); + WebhookSecurityConfigurationUtils.validateSecretAliasFormat(alias); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java new file mode 100644 index 00000000..70d8086d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.stereotype.Component; + +/// Builds and caches JwtDecoder instances keyed by jwks_uri. +@Component +public class WebhookJwtDecoderProvider { + + private final ConcurrentMap decodersByJwksUri = new ConcurrentHashMap<>(); + + public JwtDecoder get(String jwksUri) { + return decodersByJwksUri.computeIfAbsent(jwksUri, this::createDecoder); + } + + private JwtDecoder createDecoder(String jwksUri) { + var decoder = NimbusJwtDecoder.withJwkSetUri(jwksUri).build(); + decoder.setJwtValidator(JwtValidators.createDefault()); + return decoder; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java new file mode 100644 index 00000000..81b39cc1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java @@ -0,0 +1,122 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.StringUtils; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; + +/// Common utilities for webhook security validation. +/// +/// Provides shared methods for extracting and validating configuration keys across all security strategies. +/// This eliminates duplication between creation-time and runtime validation logic. +public final class WebhookSecurityConfigurationUtils { + + private static final Pattern BRACED_ENV_REFERENCE = Pattern.compile("^\\$\\{([A-Z0-9_]+)}$"); + private static final Pattern ENV_ALIAS = Pattern.compile("^[A-Z0-9_]+$"); + + private WebhookSecurityConfigurationUtils() { + } + + /// Retrieves a required configuration value, checking multiple key variants + /// (snake_case and camelCase). + /// + /// @param config the configuration map + /// @param keys the keys to check in order (e.g., "secret_alias", "secretAlias") + /// @return the first non-blank value found + /// @throws WebhookSecurityConfigurationException if no value is found (at + /// creation time) + /// @throws WebhookAuthenticationException if no value is found (at runtime) + public static String required(Map config, String... keys) { + return required(config, false, keys); + } + + /// Retrieves an optional configuration value, returning a default if not found. + /// + /// @param config the configuration map + /// @param key the key to look up + /// @param defaultValue the value to return if key is not found + /// @return the configuration value or the default + public static String optional(Map config, String key, String defaultValue) { + String value = config.get(key); + return value == null ? defaultValue : value; + } + + /// Validates that a secret alias follows the UPPER_SNAKE_CASE convention. + /// + /// @param alias the alias to validate + /// @throws WebhookSecurityConfigurationException if the alias format is invalid + /// (at creation time) + public static void validateSecretAliasFormat(String alias) { + String normalizedAlias = normalizeEnvironmentAlias(alias); + if (!ENV_ALIAS.matcher(normalizedAlias).matches()) { + throw new WebhookSecurityConfigurationException( + "Invalid 'secret_alias'. Use UPPER_SNAKE_CASE or an environment reference (${MY_SECRET} or env:MY_SECRET)"); + } + } + + /// Determines whether a configuration value references an environment variable. + /// + /// Supported formats: `${MY_VAR}` and `env:MY_VAR`. + /// + /// @param value configuration value + /// @return true when value is an environment variable reference + public static boolean isEnvironmentReference(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + String trimmed = value.trim(); + return trimmed.startsWith("env:") || BRACED_ENV_REFERENCE.matcher(trimmed).matches(); + } + + private static String normalizeEnvironmentAlias(String aliasOrReference) { + if (!StringUtils.hasText(aliasOrReference)) { + return aliasOrReference; + } + + String trimmed = aliasOrReference.trim(); + if (trimmed.startsWith("env:")) { + return trimmed.substring("env:".length()).trim(); + } + + Matcher matcher = BRACED_ENV_REFERENCE.matcher(trimmed); + if (matcher.matches()) { + return matcher.group(1); + } + + return trimmed; + } + + private static String required(Map config, boolean isRuntime, String... keys) { + for (String key : keys) { + String value = config.get(key); + if (StringUtils.hasText(value)) { + return value; + } + + // Priority 2: Environment suffix support (_env or Env) + // Example: if key is "secret_alias", we also check for "secret_alias_env" or + // "secret_aliasEnv" + String envValue = config.get(key + "_env"); + if (!StringUtils.hasText(envValue)) { + envValue = config.get(key + "Env"); + } + + if (StringUtils.hasText(envValue)) { + return "env:" + envValue; + } + } + + String keysStr = String.join(", ", keys); + if (isRuntime) { + throw new WebhookAuthenticationException( + "Missing security config key. Expected one of: " + keysStr); + } else { + throw new WebhookSecurityConfigurationException( + "Missing required security config key. Expected one of: " + keysStr); + } + } +} diff --git a/src/main/resources/db/local/R__1_Insert_sample_data.sql b/src/main/resources/db/local/R__1_Insert_sample_data.sql index acdcdec8..a31af1c5 100644 --- a/src/main/resources/db/local/R__1_Insert_sample_data.sql +++ b/src/main/resources/db/local/R__1_Insert_sample_data.sql @@ -117,7 +117,7 @@ INSERT INTO entity_template (id, identifier, name, description) VALUES ('550e8400-e29b-41d4-a716-446655440078', 'cache-service', 'Cache Service', 'Template for caching services'), ('550e8400-e29b-41d4-a716-446655440079', 'monitoring-service', 'Monitoring Service', 'Template for monitoring and observability services'); --- Link web-service template (comprehensive web API) +-- Link web-service entityTemplateIdentifier (comprehensive web API) INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440020'), -- applicationName ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440021'), -- ownerEmail @@ -139,7 +139,7 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440059'), -- monitoring ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440061'); -- secrets --- Link microservice template (lightweight service) +-- Link microservice entityTemplateIdentifier (lightweight service) INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES ('550e8400-e29b-41d4-a716-446655440071', '550e8400-e29b-41d4-a716-446655440020'), -- applicationName ('550e8400-e29b-41d4-a716-446655440071', '550e8400-e29b-41d4-a716-446655440021'), -- ownerEmail diff --git a/src/main/resources/db/migration/V6_1__create_webhook_connector_table.sql b/src/main/resources/db/migration/V6_1__create_webhook_connector_table.sql new file mode 100644 index 00000000..acfd17ff --- /dev/null +++ b/src/main/resources/db/migration/V6_1__create_webhook_connector_table.sql @@ -0,0 +1,18 @@ +-- Purpose: introduce webhook connector and relational mapping model to entity entityTemplateIdentifier and dynamic mapping for flexible webhook configuration and management +CREATE TABLE webhook_connector +( + id UUID PRIMARY KEY, + identifier VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + security JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +--create index for better performance +CREATE UNIQUE INDEX idx_webhook_connector_identifier ON webhook_connector (identifier); + +--create index for better performance on security field which is JSONB type, using GIN index for efficient querying +CREATE INDEX idx_webhook_security_type ON webhook_connector USING GIN (security); diff --git a/src/main/resources/db/migration/V6_2__create_entity_dynamic_mapping_table.sql b/src/main/resources/db/migration/V6_2__create_entity_dynamic_mapping_table.sql new file mode 100644 index 00000000..1fe03f4b --- /dev/null +++ b/src/main/resources/db/migration/V6_2__create_entity_dynamic_mapping_table.sql @@ -0,0 +1,16 @@ +-- This migration creates the 'entity_dynamic_mapping' table to store dynamic mappings of entities based on templates and filters. + +CREATE TABLE entity_dynamic_mapping +( + id UUID PRIMARY KEY, + identifier VARCHAR(255) UNIQUE NOT NULL, + template_id UUID NOT NULL, + filter TEXT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + entity_identifier TEXT NOT NULL, + entity_name TEXT NOT NULL, + properties JSONB NOT NULL DEFAULT '{}'::jsonb, + relations JSONB NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT fk_mapping_to_template FOREIGN KEY (template_id) REFERENCES entity_template (id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V6_3__create_webhook_mapping_link_table.sql b/src/main/resources/db/migration/V6_3__create_webhook_mapping_link_table.sql new file mode 100644 index 00000000..a97d3f1f --- /dev/null +++ b/src/main/resources/db/migration/V6_3__create_webhook_mapping_link_table.sql @@ -0,0 +1,21 @@ +--- Create the webhook_mapping_link table to link webhook connectors with entity templates and dynamic mappings. +CREATE TABLE webhook_mapping_link +( + webhook_id UUID NOT NULL, + entity_mapping_id UUID NOT NULL, + jslt_filter TEXT, + PRIMARY KEY (webhook_id, entity_mapping_id), + CONSTRAINT fk_webhook_connector FOREIGN KEY (webhook_id) REFERENCES webhook_connector (id) ON DELETE CASCADE, + CONSTRAINT fk_entity_mapping FOREIGN KEY (entity_mapping_id) REFERENCES entity_dynamic_mapping (id) ON DELETE CASCADE + +); + +CREATE INDEX idx_webhook_template_mapping_webhook ON webhook_mapping_link (webhook_id); + + +CREATE INDEX idx_webhook_template_mapping_entity_mapping ON webhook_mapping_link (entity_mapping_id); + +COMMENT ON TABLE webhook_mapping_link IS 'Links webhook connectors to dynamic mappings used for inbound webhook ingestion.'; +COMMENT ON COLUMN webhook_mapping_link.webhook_id IS 'FK to webhook_connector.id.'; +COMMENT ON COLUMN webhook_mapping_link.entity_mapping_id IS 'FK to entity_dynamic_mapping.id.'; +COMMENT ON COLUMN webhook_mapping_link.jslt_filter IS 'JSLT filter expression used during ingestion.'; diff --git a/src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java b/src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java new file mode 100644 index 00000000..fbe2c8be --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java @@ -0,0 +1,41 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +/** + * Unit tests for {@link WebhookConnector} invariants. + */ +@DisplayName("WebhookConnector Tests") +class WebhookConnectorTest { + + @Test + @DisplayName("Should default mappings to empty list and disable connector when mappings are null") + void shouldDefaultMappingsToEmptyListAndDisableWhenMappingsAreNull() { + WebhookConnector connector = new WebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", false, null, + new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + + assertThat(connector.mappings()).isEmpty(); + assertThat(connector.enabled()).isFalse(); + } + + @Test + @DisplayName("Should disable connector when mappings are empty") + void shouldDisableWhenMappingsAreEmpty() { + WebhookConnector connector = new WebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", false, List.of(), + new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + + assertThat(connector.mappings()).isEmpty(); + assertThat(connector.enabled()).isFalse(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java new file mode 100644 index 00000000..fbfa4cb9 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java @@ -0,0 +1,334 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +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.BeforeEach; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingService; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingValidationService; + +@DisplayName("DynamicMappingService Tests") +@ExtendWith(MockitoExtension.class) +class DynamicMappingServiceTest { + + @Mock + private EntityDynamicMappingPort entityDynamicMappingPort; + + @Mock + private WebhookMappingLinkPort webhookTemplateMappingPort; + + @Mock + private EntityDynamicMappingValidationService entityDynamicMappingValidationService; + + private EntityDynamicMappingService service; + + private static final String MAPPING_IDENTIFIER = "github_deployment_status mapping"; + + @BeforeEach + void setUp() { + service = new EntityDynamicMappingService(entityDynamicMappingPort, webhookTemplateMappingPort, + entityDynamicMappingValidationService); + } + + @Nested + @DisplayName("createEntityDynamicMapping") + class CreateEntityDynamicMappingTests { + + @Test + @DisplayName("Should validate uniqueness, validate mapping then save") + void shouldValidateThenSave() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(false); + when(entityDynamicMappingPort.save(mapping)).thenReturn(mapping); + + EntityDynamicMapping result = service.createEntityDynamicMapping(mapping); + + assertThat(result).isEqualTo(mapping); + verify(entityDynamicMappingValidationService).validateMapping(mapping); + verify(entityDynamicMappingPort).save(mapping); + } + + @Test + @DisplayName("Should throw conflict and not save when identifier already exists") + void shouldThrowWhenIdentifierAlreadyExists() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(true); + + assertThatThrownBy(() -> service.createEntityDynamicMapping(mapping)) + .isInstanceOf(EntityDynamicMappingAlreadyExistsException.class) + .hasMessageContaining(MAPPING_IDENTIFIER); + + verify(entityDynamicMappingValidationService, never()).validateMapping(any()); + verify(entityDynamicMappingPort, never()).save(any()); + } + } + + @Nested + @DisplayName("getEntityDynamicMapping") + class GetEntityDynamicMappingTests { + + @Test + @DisplayName("Should return mapping when it exists") + void shouldReturnMappingWhenExists() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(mapping)); + + EntityDynamicMapping result = service.getEntityDynamicMapping(MAPPING_IDENTIFIER); + + assertThat(result).isEqualTo(mapping); + } + + @Test + @DisplayName("Should throw EntityDynamicMappingNotFoundException when not found") + void shouldThrowWhenMappingNotFound() { + when(entityDynamicMappingPort.findByIdentifier("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEntityDynamicMapping("unknown")) + .isInstanceOf(EntityDynamicMappingNotFoundException.class) + .hasMessageContaining("unknown"); + } + } + + @Nested + @DisplayName("getAllEntityDynamicMapping") + class GetAllEntityDynamicMappingTests { + + @Test + @DisplayName("Should return paginated mappings from repository") + void shouldReturnPaginatedMappings() { + var pageable = PageRequest.of(0, 10); + var page = new PageImpl<>(List.of(buildMapping()), pageable, 1); + when(entityDynamicMappingPort.findAll(pageable)).thenReturn(page); + + var result = service.getAllEntityDynamicMapping(pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("Should return empty page when no mappings exist") + void shouldReturnEmptyPage() { + var pageable = PageRequest.of(0, 10); + when(entityDynamicMappingPort.findAll(pageable)) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + + var result = service.getAllEntityDynamicMapping(pageable); + + assertThat(result.getContent()).isEmpty(); + } + } + + @Nested + @DisplayName("updateEntityDynamicMapping") + class UpdateEntityDynamicMappingTests { + + @Test + @DisplayName("Should preserve id and identifier from existing mapping") + void shouldPreserveIdAndIdentifier() { + EntityDynamicMapping existing = buildMapping(); + EntityDynamicMapping incoming = new EntityDynamicMapping(null, "ignored-id", + "new-entityTemplateIdentifier", ".newFilter", "New Name", "New Desc", ".newId", + ".newTitle", Map.of("prop", ".val"), Map.of()); + + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(entityDynamicMappingPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + EntityDynamicMapping result = service.updateEntityDynamicMapping(MAPPING_IDENTIFIER, + incoming); + + assertThat(result.id()).isEqualTo(existing.id()); + assertThat(result.identifier()).isEqualTo(existing.identifier()); + } + + @Test + @DisplayName("Should apply updated fields from incoming mapping") + void shouldApplyIncomingFields() { + EntityDynamicMapping existing = buildMapping(); + EntityDynamicMapping incoming = new EntityDynamicMapping(null, "ignored", + "new-entityTemplateIdentifier", ".newFilter", "New Name", "New Desc", ".newId", + ".newTitle", Map.of("k", ".v"), Map.of("rel", ".r")); + + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(entityDynamicMappingPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + EntityDynamicMapping result = service.updateEntityDynamicMapping(MAPPING_IDENTIFIER, + incoming); + + assertThat(result.entityTemplateIdentifier()).isEqualTo("new-entityTemplateIdentifier"); + assertThat(result.filter()).isEqualTo(".newFilter"); + assertThat(result.name()).isEqualTo("New Name"); + assertThat(result.description()).isEqualTo("New Desc"); + assertThat(result.properties()).containsKey("k"); + } + + @Test + @DisplayName("Should validate mapping before saving") + void shouldValidateMappingBeforeSaving() { + EntityDynamicMapping existing = buildMapping(); + EntityDynamicMapping incoming = buildMapping(); + + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(entityDynamicMappingPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateEntityDynamicMapping(MAPPING_IDENTIFIER, incoming); + + verify(entityDynamicMappingValidationService).validateMapping(incoming); + } + + @Test + @DisplayName("Should throw when mapping to update does not exist") + void shouldThrowWhenMappingNotFound() { + EntityDynamicMapping incoming = buildMapping(); + when(entityDynamicMappingPort.findByIdentifier("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateEntityDynamicMapping("unknown", incoming)) + .isInstanceOf(EntityDynamicMappingNotFoundException.class) + .hasMessageContaining("unknown"); + + verify(entityDynamicMappingPort, never()).save(any()); + } + + @Test + @DisplayName("Should save the merged mapping with correct fields") + void shouldSaveMergedMapping() { + EntityDynamicMapping existing = buildMapping(); + EntityDynamicMapping incoming = new EntityDynamicMapping(null, "ignored", + "updated-entityTemplateIdentifier", ".updated", "Updated Name", "Updated Desc", ".uid", + ".utitle", Map.of(), Map.of()); + + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(entityDynamicMappingPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateEntityDynamicMapping(MAPPING_IDENTIFIER, incoming); + + var captor = ArgumentCaptor.forClass(EntityDynamicMapping.class); + verify(entityDynamicMappingPort).save(captor.capture()); + var saved = captor.getValue(); + + assertThat(saved.id()).isEqualTo(existing.id()); + assertThat(saved.identifier()).isEqualTo(existing.identifier()); + assertThat(saved.entityTemplateIdentifier()).isEqualTo("updated-entityTemplateIdentifier"); + assertThat(saved.filter()).isEqualTo(".updated"); + } + } + + @Nested + @DisplayName("deleteEntityDynamicMapping") + class DeleteEntityDynamicMappingTests { + + @Test + @DisplayName("Should delete when mapping exists and is not in use") + void shouldDeleteWhenMappingExistsAndNotInUse() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(true); + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(mapping)); + when(webhookTemplateMappingPort.existsByEntityMappingId(mapping.id())).thenReturn(false); + + service.deleteEntityDynamicMapping(MAPPING_IDENTIFIER); + + verify(entityDynamicMappingPort).deleteByIdentifier(MAPPING_IDENTIFIER); + } + + @Test + @DisplayName("Should throw when mapping does not exist") + void shouldThrowWhenMappingNotFound() { + when(entityDynamicMappingPort.existsByIdentifier("unknown")).thenReturn(false); + + assertThatThrownBy(() -> service.deleteEntityDynamicMapping("unknown")) + .isInstanceOf(EntityDynamicMappingNotFoundException.class) + .hasMessageContaining("unknown"); + + verify(entityDynamicMappingPort, never()).deleteByIdentifier(any()); + } + + @Test + @DisplayName("Should throw EntityDynamicMappingAlreadyInUseException when mapping is used by a webhook") + void shouldThrowWhenMappingIsInUse() { + EntityDynamicMapping mapping = buildMapping(); + WebhookConnector webhook = buildWebhookConnector("my-webhook"); + WebhookTemplateMapping templateMapping = new WebhookTemplateMapping(UUID.randomUUID(), + webhook, null, null, null); + + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(true); + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(mapping)); + when(webhookTemplateMappingPort.existsByEntityMappingId(mapping.id())).thenReturn(true); + when(webhookTemplateMappingPort.findByEntityMappingId(mapping.id())) + .thenReturn(List.of(templateMapping)); + + assertThatThrownBy(() -> service.deleteEntityDynamicMapping(MAPPING_IDENTIFIER)) + .isInstanceOf(EntityDynamicMappingAlreadyInUseException.class) + .hasMessageContaining("my-webhook"); + + verify(entityDynamicMappingPort, never()).deleteByIdentifier(any()); + } + + @Test + @DisplayName("Should include all referencing webhook identifiers in exception") + void shouldIncludeAllReferencingWebhooksInException() { + EntityDynamicMapping mapping = buildMapping(); + WebhookTemplateMapping ref1 = new WebhookTemplateMapping(UUID.randomUUID(), + buildWebhookConnector("webhook-a"), null, null, null); + WebhookTemplateMapping ref2 = new WebhookTemplateMapping(UUID.randomUUID(), + buildWebhookConnector("webhook-b"), null, null, null); + + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(true); + when(entityDynamicMappingPort.findByIdentifier(MAPPING_IDENTIFIER)) + .thenReturn(Optional.of(mapping)); + when(webhookTemplateMappingPort.existsByEntityMappingId(mapping.id())).thenReturn(true); + when(webhookTemplateMappingPort.findByEntityMappingId(mapping.id())) + .thenReturn(List.of(ref1, ref2)); + + assertThatThrownBy(() -> service.deleteEntityDynamicMapping(MAPPING_IDENTIFIER)) + .isInstanceOf(EntityDynamicMappingAlreadyInUseException.class) + .hasMessageContaining("webhook-a").hasMessageContaining("webhook-b"); + } + } + + private EntityDynamicMapping buildMapping() { + return new EntityDynamicMapping(UUID.randomUUID(), MAPPING_IDENTIFIER, + "github_deployment_status", ".deployment_status != null", "github deployment status name", + "github deployment status description", ".id", ".name", Map.of(), Map.of()); + } + + private WebhookConnector buildWebhookConnector(String identifier) { + return new WebhookConnector(UUID.randomUUID(), identifier, identifier + " name", "desc", false, + List.of(), new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java new file mode 100644 index 00000000..4e36f50d --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java @@ -0,0 +1,447 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoPropertiesException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoRelationsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingValidationService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; +import com.decathlon.idp_core.domain.service.relation.RelationValidationService; + +/** + * Unit tests for {@link EntityDynamicMappingValidationService}. + */ +@DisplayName("EntityDynamicMappingValidationService Tests") +@ExtendWith(MockitoExtension.class) +class EntityDynamicMappingValidationServiceTest { + + @Mock + private EntityTemplateService entityTemplateService; + + @Mock + private EntityDynamicMapperValidator entityDynamicMapperValidator; + + @Mock + private PropertyValidationService propertyValidationService; + + @Mock + private RelationValidationService relationValidationService; + + private EntityDynamicMappingValidationService service; + + @BeforeEach + void setUp() { + service = new EntityDynamicMappingValidationService(entityTemplateService, + entityDynamicMapperValidator, propertyValidationService, relationValidationService); + } + + private EntityDynamicMapping buildMapping(String templateIdentifier, + String entityDynamicMappingIdentifier, Map properties, + Map relations) { + return new EntityDynamicMapping(null, entityDynamicMappingIdentifier, templateIdentifier, + ".eventType == \"DEPLOYED\"", "name", "description", ".id", ".name", properties, relations); + } + + private EntityTemplate buildEntityTemplate(List properties, + List relations) { + return new EntityTemplate(UUID.randomUUID(), "deployment", "Deployment", + "A deployment entityTemplateIdentifier", properties, relations); + } + + private PropertyDefinition buildProperty(String name, boolean required) { + return new PropertyDefinition(UUID.randomUUID(), name, name + " description", + PropertyType.STRING, required, null); + } + + private RelationDefinition buildRelation(String name, boolean required) { + return new RelationDefinition(UUID.randomUUID(), name, "service", required, false); + } + + @Nested + @DisplayName("validateWebhookMapping - happy paths") + class ValidateWebhookMappingHappyPathTests { + + @Test + @DisplayName("Should pass with valid mapping having matching properties") + void shouldPassWithValidMappingMatchingProperties() { + PropertyDefinition property = buildProperty("environment", false); + EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("environment", ".env"), Map.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateMappings(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should pass with empty properties mapping and empty entityTemplateIdentifier properties") + void shouldPassWithEmptyPropertiesAndEmptyTemplateProperties() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateMappings(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should pass with null relations (no relations in mapping)") + void shouldPassWithNullRelations() { + PropertyDefinition property = buildProperty("env", false); + EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("env", ".env"), null); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateMappings(List.of(mapping))); + } + + @Test + @DisplayName("Should pass with empty relations in mapping and no required relations in entityTemplateIdentifier") + void shouldPassWithEmptyRelationsAndNoRequiredRelations() { + RelationDefinition relation = buildRelation("service", false); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateMappings(List.of(mapping))); + } + + @Test + @DisplayName("Should validate each mapping in the list") + void shouldValidateEachMappingInList() { + PropertyDefinition property1 = buildProperty("env", false); + EntityTemplate template1 = buildEntityTemplate(List.of(property1), List.of()); + EntityDynamicMapping mapping1 = buildMapping("deployment", "deployment_mapping", + Map.of("env", ".env"), Map.of()); + + PropertyDefinition property2 = buildProperty("version", false); + EntityTemplate template2 = buildEntityTemplate(List.of(property2), List.of()); + EntityDynamicMapping mapping2 = new EntityDynamicMapping(null, "service_mapping", "service", + ".type == \"SERVICE\"", "service mapping", "service mapping description", ".id", ".name", + Map.of("version", ".ver"), Map.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template1); + when(entityTemplateService.getEntityTemplateByIdentifier("service")).thenReturn(template2); + + assertThatNoException() + .isThrownBy(() -> service.validateMappings(List.of(mapping1, mapping2))); + + verify(entityDynamicMapperValidator).validate(mapping1); + verify(entityDynamicMapperValidator).validate(mapping2); + } + + @Test + @DisplayName("Should pass when mapping has valid relations matching entityTemplateIdentifier") + void shouldPassWithValidRelations() { + RelationDefinition relation = buildRelation("owner", true); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of("owner", ".owner")); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateMappings(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - entityTemplateIdentifier existence") + class ValidateTemplateExistenceTests { + + @Test + @DisplayName("Should throw EntityTemplateNotFoundException when entityTemplateIdentifier does not exist") + void shouldThrowWhenTemplateDoesNotExist() { + EntityDynamicMapping mapping = buildMapping("unknown-entityTemplateIdentifier", + "unknown_mapping", Map.of(), Map.of()); + List mappings = List.of(mapping); + + // Existence is now enforced by getEntityTemplateByIdentifier, which throws + // EntityTemplateNotFoundException when the template is missing. + doThrow(new EntityTemplateNotFoundException("identifier", "unknown-entityTemplateIdentifier")) + .when(entityTemplateService) + .getEntityTemplateByIdentifier("unknown-entityTemplateIdentifier"); + + assertThatThrownBy(() -> service.validateMappings(mappings)) + .isInstanceOf(EntityTemplateNotFoundException.class); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - properties validation") + class ValidatePropertiesTests { + + @Test + @DisplayName("Should throw PropertyNameNotFoundEntityTemplatePropertiesException when mapping has properties but entityTemplateIdentifier has none") + void shouldThrowWhenMappingHasPropertiesButTemplateHasNone() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("env", ".env"), Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new PropertyNameNotFoundEntityTemplatePropertiesException( + "Property name env not found in entity template properties")) + .when(propertyValidationService) + .validateMappingPropertiesAgainstTemplate(eq(template), anyList()); + + assertThatThrownBy(() -> service.validateMappings(mappings)) + .isInstanceOf(PropertyNameNotFoundEntityTemplatePropertiesException.class) + .hasMessageContaining("env"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should throw PropertyNameNotFoundEntityTemplatePropertiesException when property not in entityTemplateIdentifier") + void shouldThrowWhenPropertyNotFoundInTemplate() { + PropertyDefinition property = buildProperty("environment", false); + EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("unknown-prop", ".x"), Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new PropertyNameNotFoundEntityTemplatePropertiesException( + "Property name unknown-prop not found in entity template properties")) + .when(propertyValidationService) + .validateMappingPropertiesAgainstTemplate(eq(template), anyList()); + + assertThatThrownBy(() -> service.validateMappings(mappings)) + .isInstanceOf(PropertyNameNotFoundEntityTemplatePropertiesException.class) + .hasMessageContaining("unknown-prop"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should throw EntityDynamicMappingHasNoPropertiesException when required property is missing from mapping") + void shouldThrowWhenRequiredPropertyMissingFromMapping() { + PropertyDefinition requiredProp = buildProperty("env", true); + EntityTemplate template = buildEntityTemplate(List.of(requiredProp), List.of()); + // mapping does not include the required property "env" + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow( + new EntityDynamicMappingHasNoPropertiesException("Missing required properties: [env]")) + .when(propertyValidationService) + .validateMappingPropertiesAgainstTemplate(template, List.of()); + + assertThatThrownBy(() -> service.validateMappings(mappings)) + .isInstanceOf(EntityDynamicMappingHasNoPropertiesException.class) + .hasMessageContaining("env"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should pass when all required properties are mapped") + void shouldPassWhenAllRequiredPropertiesMapped() { + PropertyDefinition requiredProp = buildProperty("env", true); + EntityTemplate template = buildEntityTemplate(List.of(requiredProp), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("env", ".environment"), Map.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateMappings(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should throw when multiple required properties are missing from mapping") + void shouldThrowWhenMultipleRequiredPropertiesMissing() { + PropertyDefinition prop1 = buildProperty("env", true); + PropertyDefinition prop2 = buildProperty("version", true); + EntityTemplate template = buildEntityTemplate(List.of(prop1, prop2), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new EntityDynamicMappingHasNoPropertiesException( + "Missing required properties: [env, version]")).when(propertyValidationService) + .validateMappingPropertiesAgainstTemplate(template, List.of()); + + assertThatThrownBy(() -> service.validateMappings(mappings)) + .isInstanceOf(EntityDynamicMappingHasNoPropertiesException.class) + .hasMessageContaining("env").hasMessageContaining("version"); + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - relations validation") + class ValidateRelationsTests { + + @Test + @DisplayName("Should throw RelationNameNotFoundEntityTemplateRelationsException when relation not in entityTemplateIdentifier") + void shouldThrowWhenRelationNotFoundInTemplate() { + RelationDefinition relation = buildRelation("owner", false); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of("unknown-relation", ".x")); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new RelationNameNotFoundEntityTemplateRelationsException( + "Relation name unknown-relation not found in entity template relations")) + .when(relationValidationService) + .validateMappingRelationsAgainstTemplate(template, List.of("unknown-relation")); + + assertThatThrownBy(() -> service.validateMappings(mappings)) + .isInstanceOf(RelationNameNotFoundEntityTemplateRelationsException.class) + .hasMessageContaining("unknown-relation"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should throw EntityDynamicMappingHasNoRelationsException when required relation is missing from mapping") + void shouldThrowWhenRequiredRelationMissingFromMapping() { + RelationDefinition requiredRelation = buildRelation("owner", true); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(requiredRelation)); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); + List mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow( + new EntityDynamicMappingHasNoRelationsException("Missing required relations: [owner]")) + .when(relationValidationService) + .validateMappingRelationsAgainstTemplate(template, List.of()); + + assertThatThrownBy(() -> service.validateMappings(mappings)) + .isInstanceOf(EntityDynamicMappingHasNoRelationsException.class) + .hasMessageContaining("owner"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should pass when required relation is mapped") + void shouldPassWhenRequiredRelationMapped() { + RelationDefinition requiredRelation = buildRelation("owner", true); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(requiredRelation)); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of("owner", ".ownerId")); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateMappings(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should throw when multiple required relations are missing from mapping") + void shouldThrowWhenMultipleRequiredRelationsMissing() { + RelationDefinition rel1 = buildRelation("owner", true); + RelationDefinition rel2 = buildRelation("team", true); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(rel1, rel2)); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); + var mappings = List.of(mapping); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new EntityDynamicMappingHasNoRelationsException( + "Missing required relations: [owner, team]")).when(relationValidationService) + .validateMappingRelationsAgainstTemplate(template, List.of()); + + assertThatThrownBy(() -> service.validateMappings(mappings)) + .isInstanceOf(EntityDynamicMappingHasNoRelationsException.class) + .hasMessageContaining("owner").hasMessageContaining("team"); + } + + @Test + @DisplayName("Should skip relation validation when relations map is null") + void shouldSkipRelationValidationWhenNull() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + null); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + assertThatNoException().isThrownBy(() -> service.validateMappings(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - mapper validator delegation") + class MapperValidatorDelegationTests { + + @Test + @DisplayName("Should delegate to entityDynamicMapperValidator after all domain checks pass") + void shouldDelegateToMapperValidator() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + + service.validateMappings(List.of(mapping)); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should NOT call entityDynamicMapperValidator when domain check throws") + void shouldNotCallMapperValidatorWhenDomainCheckFails() { + EntityDynamicMapping mapping = buildMapping("bad-entityTemplateIdentifier", + "deployment_mapping", Map.of(), Map.of()); + var mappings = List.of(mapping); + + doThrow(new EntityTemplateNotFoundException("identifier", "bad-entityTemplateIdentifier")) + .when(entityTemplateService) + .getEntityTemplateByIdentifier("bad-entityTemplateIdentifier"); + + assertThatThrownBy(() -> service.validateMappings(mappings)) + .isInstanceOf(EntityTemplateNotFoundException.class); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java new file mode 100644 index 00000000..3fc998a2 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java @@ -0,0 +1,435 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.doThrow; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; + +@DisplayName("WebhookConnectorService Tests") +@ExtendWith(MockitoExtension.class) +class WebhookConnectorServiceTest { + + @Mock + private WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + + @Mock + private WebhookConnectorValidationService webhookConnectorValidationService; + + @Mock + private EntityDynamicMappingPort entityDynamicMappingPort; + + private WebhookConnectorService service; + + @BeforeEach + void setUp() { + service = new WebhookConnectorService(webhookConnectorRepositoryPort, + webhookConnectorValidationService, entityDynamicMappingPort); + } + + @Nested + @DisplayName("getWebhookConnector") + class GetWebhookConnectorTests { + + @Test + @DisplayName("Should return connector when it exists") + void shouldReturnConnectorWhenExists() { + WebhookConnector existing = buildWebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", true); + when(webhookConnectorRepositoryPort.findByIdentifier("github-dora")) + .thenReturn(Optional.of(existing)); + + WebhookConnector result = service.getWebhookConnector("github-dora"); + + assertThat(result).isEqualTo(existing); + } + + @Test + @DisplayName("Should throw WebhookConnectorNotFoundException when not found") + void shouldThrowWhenConnectorNotFound() { + when(webhookConnectorRepositoryPort.findByIdentifier("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getWebhookConnector("unknown")) + .isInstanceOf(WebhookConnectorNotFoundException.class).hasMessageContaining("unknown"); + } + } + + @Nested + @DisplayName("createWebhookConnector") + class CreateWebhookConnectorTests { + + @Test + @DisplayName("Should validate then save and return the connector") + void shouldValidateAndSave() { + WebhookConnector toCreate = buildWebhookConnector(null, "github-dora", "GitHub DORA", "desc", + false); + WebhookConnector saved = buildWebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", false); + when(webhookConnectorRepositoryPort.save(any())).thenReturn(saved); + + WebhookConnector result = service.createWebhookConnector(toCreate); + + verify(webhookConnectorValidationService).validateWebhookConnectorForCreation(toCreate); + verify(webhookConnectorRepositoryPort).save(toCreate); + assertThat(result.id()).isNotNull(); + assertThat(result.identifier()).isEqualTo("github-dora"); + } + + @Test + @DisplayName("Should NOT save when validation throws") + void shouldNotSaveWhenValidationFails() { + WebhookConnector toCreate = buildWebhookConnector(null, "github-dora", "GitHub DORA", "desc", + true); + doThrow(new RuntimeException("validation error")).when(webhookConnectorValidationService) + .validateWebhookConnectorForCreation(toCreate); + + assertThatThrownBy(() -> service.createWebhookConnector(toCreate)) + .hasMessageContaining("validation error"); + + verify(webhookConnectorRepositoryPort, never()).save(any()); + } + + @Test + @DisplayName("Should force enabled to false when mappings are empty") + void shouldDisableConnectorWhenMappingsAreEmpty() { + // User tries to create an enabled connector with no mappings + WebhookConnector toCreate = buildWebhookConnector(null, "github-dora", "GitHub DORA", "desc", + true); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.createWebhookConnector(toCreate); + + var captor = ArgumentCaptor.forClass(WebhookConnector.class); + verify(webhookConnectorRepositoryPort).save(captor.capture()); + var saved = captor.getValue(); + + // Even though toCreate had enabled=true, it should be saved with enabled=false + assertThat(saved.enabled()).isFalse(); + assertThat(saved.mappings()).isEmpty(); + } + + @Test + @DisplayName("Should keep enabled as true when mappings are present") + void shouldKeepEnabledWhenMappingsPresent() { + EntityDynamicMapping mapping = new EntityDynamicMapping(UUID.randomUUID(), + "deployment-mapping", "deployment", "true", "deployment name", "deployment description", + ".id", ".name", Map.of(), Map.of()); + WebhookConnector toCreate = buildWebhookConnectorWithMappings(null, "github-dora", + "GitHub DORA", "desc", true, List.of(mapping)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.createWebhookConnector(toCreate); + + var captor = ArgumentCaptor.forClass(WebhookConnector.class); + verify(webhookConnectorRepositoryPort).save(captor.capture()); + var saved = captor.getValue(); + + // With mappings present, enabled should remain true + assertThat(saved.enabled()).isTrue(); + assertThat(saved.mappings()).hasSize(1); + } + } + + @Nested + @DisplayName("updateWebhookConnector") + class UpdateWebhookConnectorTests { + + private static final UUID EXISTING_ID = UUID.randomUUID(); + private static final String IDENTIFIER = "github-dora"; + + @Test + @DisplayName("Should preserve id and identifier from the stored connector") + void shouldPreserveIdAndIdentifier() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old name", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, "ignored-from-body", "New name", + "New desc", false); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + WebhookConnector result = service.updateWebhookConnector(IDENTIFIER, incoming); + + assertThat(result.id()).isEqualTo(EXISTING_ID); + assertThat(result.identifier()).isEqualTo(IDENTIFIER); + } + + @Test + @DisplayName("Should apply updated fields from the incoming connector") + void shouldApplyIncomingFields() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old name", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New name", "New desc", + false); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + WebhookConnector result = service.updateWebhookConnector(IDENTIFIER, incoming); + + assertThat(result.name()).isEqualTo("New name"); + assertThat(result.description()).isEqualTo("New desc"); + assertThat(result.enabled()).isFalse(); + } + + @Test + @DisplayName("Should delegate validation before saving") + void shouldDelegateValidationBeforeSave() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old name", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New name", "New desc", + false); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateWebhookConnector(IDENTIFIER, incoming); + + InOrder order = org.mockito.Mockito.inOrder(webhookConnectorValidationService, + webhookConnectorRepositoryPort); + order.verify(webhookConnectorValidationService).validateWebhookConnectorForUpdate(incoming); + order.verify(webhookConnectorRepositoryPort).save(any()); + } + + @Test + @DisplayName("Should save the merged connector with correct fields") + void shouldSaveMergedConnector() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old name", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New name", "New desc", + false); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateWebhookConnector(IDENTIFIER, incoming); + + var captor = ArgumentCaptor.forClass(WebhookConnector.class); + verify(webhookConnectorRepositoryPort).save(captor.capture()); + var saved = captor.getValue(); + + assertThat(saved.id()).isEqualTo(EXISTING_ID); + assertThat(saved.identifier()).isEqualTo(IDENTIFIER); + assertThat(saved.name()).isEqualTo("New name"); + assertThat(saved.description()).isEqualTo("New desc"); + assertThat(saved.enabled()).isFalse(); + } + + @Test + @DisplayName("Should throw WebhookConnectorNotFoundException when connector is missing") + void shouldThrowWhenConnectorMissing() { + var incoming = buildWebhookConnector(null, IDENTIFIER, "New name", "New desc", true); + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateWebhookConnector(IDENTIFIER, incoming)) + .isInstanceOf(WebhookConnectorNotFoundException.class).hasMessageContaining(IDENTIFIER); + + verify(webhookConnectorValidationService, never()).validateWebhookConnectorForUpdate(any()); + verify(webhookConnectorRepositoryPort, never()).save(any()); + } + + @Test + @DisplayName("Should force enabled to false when update removes all mappings") + void shouldDisableConnectorWhenUpdatingWithEmptyMappings() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old name", + "Old desc", true); + // User tries to update with enabled=true but empty mappings + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New name", "New desc", + true); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateWebhookConnector(IDENTIFIER, incoming); + + var captor = ArgumentCaptor.forClass(WebhookConnector.class); + verify(webhookConnectorRepositoryPort).save(captor.capture()); + var saved = captor.getValue(); + + // Should be forced to false because mappings are empty + assertThat(saved.enabled()).isFalse(); + assertThat(saved.mappings()).isEmpty(); + } + + @Test + @DisplayName("Should keep enabled value when update has mappings") + void shouldKeepEnabledWhenUpdateHasMappings() { + EntityDynamicMapping mapping = new EntityDynamicMapping(UUID.randomUUID(), + "deployment-mapping", "deployment", "true", "deployment name", "deployment description", + ".id", ".name", Map.of(), Map.of()); + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old name", + "Old desc", false); + WebhookConnector incoming = buildWebhookConnectorWithMappings(null, IDENTIFIER, "New name", + "New desc", true, List.of(mapping)); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateWebhookConnector(IDENTIFIER, incoming); + + var captor = ArgumentCaptor.forClass(WebhookConnector.class); + verify(webhookConnectorRepositoryPort).save(captor.capture()); + var saved = captor.getValue(); + + // Should remain true because mappings are present + assertThat(saved.enabled()).isTrue(); + assertThat(saved.mappings()).hasSize(1); + } + } + + @Nested + @DisplayName("deleteWebhookConnector") + class DeleteWebhookConnectorTests { + + @Test + @DisplayName("Should validate existence then delete") + void shouldValidateAndDelete() { + service.deleteWebhookConnector("github-dora"); + + var order = org.mockito.Mockito.inOrder(webhookConnectorValidationService, + webhookConnectorRepositoryPort); + order.verify(webhookConnectorValidationService).validateIdentifierExists("github-dora"); + order.verify(webhookConnectorRepositoryPort).deleteByIdentifier("github-dora"); + } + + @Test + @DisplayName("Should NOT delete when validation throws") + void shouldNotDeleteWhenValidationFails() { + doThrow(new WebhookConnectorNotFoundException("github-dora not found")) + .when(webhookConnectorValidationService).validateIdentifierExists("github-dora"); + + assertThatThrownBy(() -> service.deleteWebhookConnector("github-dora")) + .isInstanceOf(WebhookConnectorNotFoundException.class); + + verify(webhookConnectorRepositoryPort, never()).deleteByIdentifier(any()); + } + } + + @Nested + @DisplayName("getAllWebhookConnector") + class GetAllWebhookConnectorTests { + + @Test + @DisplayName("Should return paginated connectors from repository") + void shouldReturnPaginatedConnectors() { + PageRequest pageable = PageRequest.of(0, 10); + WebhookConnector c1 = buildWebhookConnector(UUID.randomUUID(), "connector-a", "A", "desc", + true); + WebhookConnector c2 = buildWebhookConnector(UUID.randomUUID(), "connector-b", "B", "desc", + false); + var page = new PageImpl<>(List.of(c1, c2), pageable, 2); + when(webhookConnectorRepositoryPort.findAll(pageable)).thenReturn(page); + + Page result = service.getAllWebhookConnector(pageable); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @Test + @DisplayName("Should return empty page when no connectors exist") + void shouldReturnEmptyPage() { + Pageable pageable = PageRequest.of(0, 10); + when(webhookConnectorRepositoryPort.findAll(pageable)) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + + Page result = service.getAllWebhookConnector(pageable); + + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + } + } + + private WebhookConnector buildWebhookConnector(UUID id, String identifier, String title, + String description, boolean enabled) { + WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, + Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")); + return new WebhookConnector(id, identifier, title, description, enabled, List.of(), security); + } + + private WebhookConnector buildWebhookConnectorWithMappings(UUID id, String identifier, + String title, String description, boolean enabled, List mappings) { + WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, + Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")); + return new WebhookConnector(id, identifier, title, description, enabled, mappings, security); + } + + @Nested + @DisplayName("resolveAndValidateMappings") + class ResolveAndValidateMappingsTests { + + @Test + @DisplayName("Should return empty list when identifiers are null or empty") + void shouldReturnEmptyWhenNoIdentifiers() { + assertThat(service.resolveAndValidateMappings(null)).isEmpty(); + assertThat(service.resolveAndValidateMappings(List.of())).isEmpty(); + verifyNoInteractions(entityDynamicMappingPort); + } + + @Test + @DisplayName("Should resolve each identifier to its existing mapping") + void shouldResolveExistingMappings() { + EntityDynamicMapping mapping = buildMapping("deployment-mapping"); + when(entityDynamicMappingPort.findByIdentifier("deployment-mapping")) + .thenReturn(Optional.of(mapping)); + + List result = service + .resolveAndValidateMappings(List.of("deployment-mapping")); + + assertThat(result).containsExactly(mapping); + } + + @Test + @DisplayName("Should throw EntityDynamicMappingNotFoundException when a mapping is missing") + void shouldThrowWhenMappingMissing() { + when(entityDynamicMappingPort.findByIdentifier("missing-mapping")) + .thenReturn(Optional.empty()); + + List mappings = List.of("missing-mapping"); + + assertThatThrownBy(() -> service.resolveAndValidateMappings(mappings)) + .isInstanceOf(EntityDynamicMappingNotFoundException.class) + .hasMessageContaining("missing-mapping"); + } + + private EntityDynamicMapping buildMapping(String identifier) { + return new EntityDynamicMapping(UUID.randomUUID(), identifier, "deployment", "true", + "deployment name", "deployment description", ".id", ".name", Map.of(), Map.of()); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java new file mode 100644 index 00000000..5998a926 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java @@ -0,0 +1,159 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorAlreadyExistException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingValidationService; +import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService; + +@DisplayName("WebhookConnectorValidationService Tests") +@ExtendWith(MockitoExtension.class) +class WebhookConnectorValidationServiceTest { + + @Mock + private WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + + @Mock + private WebhookSecurityValidationService webhookSecurityValidationService; + + @Mock + private EntityDynamicMappingValidationService webhookConnectorMappingValidationService; + + private WebhookConnectorValidationService service; + + @BeforeEach + void setUp() { + service = new WebhookConnectorValidationService(webhookConnectorRepositoryPort, + webhookConnectorMappingValidationService, webhookSecurityValidationService); + } + + @Nested + @DisplayName("validateWebhookConnectorForCreation") + class ValidateWebhookConnectorForCreationTests { + + @Test + @DisplayName("Should validate identifier uniqueness and security for creation") + void shouldValidateAllChecksForCreation() { + WebhookConnector connector = buildWebhookConnector("github-dora", "GitHub DORA"); + when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(false); + + service.validateWebhookConnectorForCreation(connector); + + var order = inOrder(webhookConnectorRepositoryPort, webhookSecurityValidationService); + order.verify(webhookConnectorRepositoryPort).existsByIdentifier("github-dora"); + order.verify(webhookSecurityValidationService).validateForCreation(connector.security()); + } + + @Test + @DisplayName("Should throw when identifier already exists and stop validation chain") + void shouldThrowWhenIdentifierAlreadyExists() { + WebhookConnector connector = buildWebhookConnector("github-dora", "GitHub DORA"); + when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(true); + + assertThatThrownBy(() -> service.validateWebhookConnectorForCreation(connector)) + .isInstanceOf(WebhookConnectorAlreadyExistException.class) + .hasMessageContaining("github-dora"); + + verify(webhookSecurityValidationService, never()).validateForCreation(any()); + } + } + + @Nested + @DisplayName("validateWebhookConnectorForUpdate") + class ValidateWebhookConnectorForUpdateTests { + + @Test + @DisplayName("Should validate mappings when connector has mappings") + void shouldValidateMappingsWhenPresent() { + WebhookConnector connectorToUpdate = buildWebhookConnectorWithMappings("github-dora", + "Title"); + + service.validateWebhookConnectorForUpdate(connectorToUpdate); + + verify(webhookConnectorMappingValidationService) + .validateMappings(connectorToUpdate.mappings()); + verify(webhookSecurityValidationService).validateForCreation(connectorToUpdate.security()); + } + + @Test + @DisplayName("Should skip mapping validation when connector has no mappings") + void shouldSkipMappingValidationWhenNoMappings() { + WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Title"); + + service.validateWebhookConnectorForUpdate(connectorToUpdate); + + verify(webhookConnectorMappingValidationService, never()).validateMappings(any()); + verify(webhookSecurityValidationService).validateForCreation(connectorToUpdate.security()); + } + + @Test + @DisplayName("Should allow duplicate names (no uniqueness constraint)") + void shouldAllowDuplicateNames() { + WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Duplicate Name"); + + service.validateWebhookConnectorForUpdate(connectorToUpdate); + + verify(webhookConnectorRepositoryPort, never()).existsByTitle(any()); + } + } + + @Nested + @DisplayName("validateIdentifierExists") + class ValidateIdentifierExistsTests { + + @Test + @DisplayName("Should pass when identifier exists") + void shouldPassWhenIdentifierExists() { + when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(true); + + assertThatCode(() -> service.validateIdentifierExists("github-dora")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw when identifier does not exist") + void shouldThrowWhenIdentifierDoesNotExist() { + when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(false); + + assertThatThrownBy(() -> service.validateIdentifierExists("github-dora")) + .isInstanceOf(WebhookConnectorNotFoundException.class) + .hasMessageContaining("github-dora"); + } + } + + private WebhookConnector buildWebhookConnector(String identifier, String title) { + WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, + Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")); + return new WebhookConnector(UUID.randomUUID(), identifier, title, "desc", true, List.of(), + security); + } + + private WebhookConnector buildWebhookConnectorWithMappings(String identifier, String title) { + WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, + Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")); + EntityDynamicMapping mapping = new EntityDynamicMapping(UUID.randomUUID(), "my-mapping", + "web-service", ".filter", "name", "desc", ".id", ".name", Map.of(), Map.of()); + return new WebhookConnector(UUID.randomUUID(), identifier, title, "desc", true, + List.of(mapping), security); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java new file mode 100644 index 00000000..c916884b --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java @@ -0,0 +1,126 @@ +package com.decathlon.idp_core.domain.service.webhook.security; + +import static com.decathlon.idp_core.domain.model.enums.WebhookSecurityType.HMAC_SHA256; +import static com.decathlon.idp_core.domain.model.enums.WebhookSecurityType.JWT_BEARER; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +@DisplayName("WebhookSecurityValidationService Tests") +@ExtendWith(MockitoExtension.class) +class WebhookSecurityValidationServiceTest { + + @Mock + private WebhookSecurityStrategy hmacCreationValidator; + + private WebhookSecurityValidationService service; + + @BeforeEach + void setUp() { + lenient().when(hmacCreationValidator.supports(HMAC_SHA256)).thenReturn(true); + service = new WebhookSecurityValidationService(List.of(hmacCreationValidator)); + } + + @Nested + @DisplayName("validateForCreation — null/blank guards") + class NullBlankGuards { + + @Test + @DisplayName("Should throw when security is null") + void shouldThrowWhenSecurityIsNull() { + assertThatThrownBy(() -> service.validateForCreation(null)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("mandatory"); + } + + @Test + @DisplayName("Should throw when security type is null") + void shouldThrowWhenTypeIsNull() { + Map config = Map.of("k", "v"); + assertThatThrownBy(() -> new WebhookSecurity(null, config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("type is mandatory"); + } + + @Test + @DisplayName("Should throw when config is null") + void shouldThrowWhenConfigIsNull() { + assertThatThrownBy(() -> new WebhookSecurity(HMAC_SHA256, null)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("Webhook security config is mandatory"); + } + } + + @Nested + @DisplayName("validateForCreation — known type delegation") + class KnownTypeDelegation { + + @Test + @DisplayName("Should delegate to the matching creation validator for HMAC_SHA256") + void shouldDelegateToHmacValidator() { + var config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET"); + var security = new WebhookSecurity(HMAC_SHA256, config); + + assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException(); + + verify(hmacCreationValidator).validateConfiguration(config); + } + } + + @Nested + @DisplayName("validateForCreation — NONE type") + class NoneTypeValidation { + + @Test + @DisplayName("Should pass for NONE type with empty config") + void shouldPassForNoneTypeWithEmptyConfig() { + var security = new WebhookSecurity(WebhookSecurityType.NONE, Map.of()); + + assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw for NONE type with non-empty config") + void shouldThrowForNoneTypeWithNonEmptyConfig() { + var security = new WebhookSecurity(WebhookSecurityType.NONE, Map.of("header_name", "X-Test")); + + assertThatThrownBy(() -> service.validateForCreation(security)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("must be empty when type is NONE"); + } + } + + @Nested + @DisplayName("validateForCreation — unregistered type") + class UnregisteredType { + + @Test + @DisplayName("Should throw when no validator is registered for the given type") + void shouldThrowForUnregisteredType() { + lenient().when(hmacCreationValidator.supports(JWT_BEARER)).thenReturn(false); + var security = new WebhookSecurity(JWT_BEARER, + Map.of("jwks_uri", "https://example.com/.well-known/jwks")); + + assertThatThrownBy(() -> service.validateForCreation(security)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("No validator registered"); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java new file mode 100644 index 00000000..ee111190 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java @@ -0,0 +1,435 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsString; +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.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import com.decathlon.idp_core.AbstractIntegrationTest; + +import lombok.extern.slf4j.Slf4j; + +/// Integration tests for EntityDynamicMappingController, covering all CRUD operations on entity dynamic mappings. +/// +/// Covers the full HTTP contract (status codes, response shape, validation errors) +/// for all CRUD operations on entity dynamic mappings. +@DisplayName("EntityDynamicMappingController Integration Tests") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", + "/db/test/R__4_insert_webhook_test_data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Slf4j +class EntityDynamicMappingControllerTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + private static final String MAPPING_PATH = "/api/v1/entity_dynamic_mappings"; + + /// Builds a valid entity dynamic mapping creation payload. + private String buildCreatePayload(String mappingIdentifier) { + return """ + { + "identifier": "%s", + "entity_template_identifier": "microservice", + "filter": ".action == \\"pushed\\"", + "name":"microservice name", + "description":"description", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.email", + "environment": "\\"DEV\\"", + "version": ".ref", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """.formatted(mappingIdentifier); + } + + /// Builds a valid entity dynamic mapping update payload. + private String buildUpdatePayload() { + return """ + { + "entity_template_identifier": "microservice", + "filter": ".action == \\"released\\"", + "name":"microservice name updated", + "description":"updated description", + "entity": { + "identifier": ".release.tag_name", + "name": ".release.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".release.author.email", + "environment": "\\"PROD\\"", + "version": ".release.tag_name", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """; + } + + @Nested + @DisplayName("GET /api/v1/entity-dynamic-mappings - Get mappings paginated") + @Order(1) + class GetMappingsPaginatedTests { + + @Test + @DisplayName("Should return 401 without authentication") + void getMappings_401_without_user_token() throws Exception { + mockMvc.perform(get(MAPPING_PATH).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with page containing mappings from test data") + void getMappings_200_with_data() throws Exception { + mockMvc.perform(get(MAPPING_PATH).accept(APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page.total_elements").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("microservice-mapping")); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with custom pagination") + void getMappings_200_with_custom_pagination() throws Exception { + mockMvc + .perform(get(MAPPING_PATH).param("page", "0").param("size", "5") + .param("sort", "identifier,asc").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(0)); + } + } + + @Nested + @DisplayName("POST /api/v1/entity-dynamic-mappings - Create mapping") + @Order(2) + class PostMappingTests { + + @Test + @DisplayName("Should return 401 without authentication") + void postMapping_401_without_user_token() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(buildCreatePayload("new-mapping"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should create mapping and return 201") + void postMapping_201() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildCreatePayload("new-test-mapping"))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("new-test-mapping")) + .andExpect(jsonPath("$.entity_template_identifier").value("microservice")) + .andExpect(jsonPath("$.filter").value(".action == \"pushed\"")) + .andExpect(jsonPath("$.entity.identifier").value(".repository.full_name")); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when identifier already exists") + void postMapping_409_identifier_already_exists() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildCreatePayload("microservice-mapping"))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString("microservice-mapping"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when mappingIdentifier is missing") + void postMapping_400_identifier_missing() throws Exception { + var payload = """ + { + "entity_template_identifier": "microservice", + "filter": ".action == \\"pushed\\"", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.email", + "environment": "\\"DEV\\"", + "version": ".ref", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("mapping identifier"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when entityTemplateIdentifier is missing") + void postMapping_400_template_missing() throws Exception { + var payload = """ + { + "identifier": "test-mapping", + "filter": ".action == \\"pushed\\"", + "name": "test mapping name", + "description": "description", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": {}, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value(containsString("Entity Template Identifier is mandatory"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when entityTemplateIdentifier does not exist") + void postMapping_404_template_not_found() throws Exception { + var payload = """ + { + "identifier": "test-mapping", + "entity_template_identifier": "non-existent-entityTemplateIdentifier", + "filter": ".action == \\"pushed\\"", + "name":"test mapping", + "description":"descrption", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": {}, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description") + .value(containsString("non-existent-entityTemplateIdentifier"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when required entityTemplateIdentifier properties are missing") + void postMapping_400_missing_required_properties() throws Exception { + var payload = """ + { + "identifier": "test-mapping", + "entity_template_identifier": "microservice", + "filter": ".action == \\"pushed\\"", + "name":"test mapping name", + "description":"descrption", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": { + "applicationName": ".repository.name" + }, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("missing required"))); + } + } + + @Nested + @DisplayName("GET /api/v1/entity-dynamic-mappings/{identifier} - Get by identifier") + @Order(3) + class GetMappingByIdentifierTests { + + @Test + @DisplayName("Should return 401 without authentication") + void getMapping_401_without_user_token() throws Exception { + mockMvc.perform(get(MAPPING_PATH + "/microservice-mapping").accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with mapping details from test data") + void getMapping_200() throws Exception { + mockMvc.perform(get(MAPPING_PATH + "/microservice-mapping").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("microservice-mapping")) + .andExpect(jsonPath("$.entity_template_identifier").value("microservice")) + .andExpect(jsonPath("$.filter").value(".action == \"pushed\"")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping does not exist") + void getMapping_404_not_found() throws Exception { + mockMvc.perform(get(MAPPING_PATH + "/non-existent-mapping").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + } + + @Nested + @DisplayName("PUT /api/v1/entity-dynamic-mappings/{identifier} - Update mapping") + @Order(4) + class PutMappingTests { + + @Test + @DisplayName("Should return 401 without authentication") + void putMapping_401_without_user_token() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/microservice-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildUpdatePayload())).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping does not exist") + void putMapping_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/non-existent-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildUpdatePayload())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should update mapping and return 200") + void putMapping_200() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(buildCreatePayload("mapping-to-update"))) + .andExpect(status().isCreated()); + + mockMvc + .perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/mapping-to-update") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildUpdatePayload())) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("mapping-to-update")) + .andExpect(jsonPath("$.filter").value(".action == \"released\"")) + .andExpect(jsonPath("$.entity.identifier").value(".release.tag_name")); + + mockMvc.perform(get(MAPPING_PATH + "/mapping-to-update").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.filter").value(".action == \"released\"")); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when entityTemplateIdentifier is missing in update") + void putMapping_400_template_missing() throws Exception { + var payload = """ + { + "filter": ".action == \\"released\\"", + "entity": { + "identifier": ".release.tag_name", + "name": ".release.name", + "properties": {}, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/microservice-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")); + } + } + + @Nested + @DisplayName("DELETE /api/v1/entity-dynamic-mappings/{identifier} - Delete mapping") + @Order(5) + class DeleteMappingTests { + + @Test + @DisplayName("Should return 401 without authentication") + void deleteMapping_401_without_user_token() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/microservice-mapping") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should delete mapping and return 204") + void deleteMapping_204() throws Exception { + // First create a mapping to delete + mockMvc.perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(buildCreatePayload("mapping-to-delete"))) + .andExpect(status().isCreated()); + + // Delete the mapping + mockMvc.perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/mapping-to-delete") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + // Verify the mapping no longer exists + mockMvc.perform(get(MAPPING_PATH + "/mapping-to-delete").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping does not exist") + void deleteMapping_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/non-existent-mapping") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when mapping is in use by a webhook") + void deleteMapping_409_in_use() throws Exception { + // The microservice-mapping is used by github-dora-connector from test data + mockMvc + .perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/microservice-mapping") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString("in use"))); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java index 8abc1006..133b1a51 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -13,6 +13,7 @@ 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.context.jdbc.Sql; import org.springframework.test.web.servlet.MockMvc; import com.decathlon.idp_core.AbstractIntegrationTest; @@ -31,6 +32,11 @@ /// - Filter "monitors": only a→b returned; c is unreachable via "monitors" edges /// - 404 for unknown entity /// - 401 without authentication +// R__3 is idempotent (ON CONFLICT DO NOTHING) so it is safe to re-run it +// before this test class even when Flyway already applied it at startup. +// This guarantees the graph entities are always present regardless of which +// other test class ran before this one and may have deleted them. +@Sql(scripts = "/db/test/R__3_Insert_graph_entities_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @DisplayName("GET /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph") public class EntityGraphControllerTest extends AbstractIntegrationTest { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java new file mode 100644 index 00000000..f3bc978c --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java @@ -0,0 +1,1027 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.*; +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.result.MockMvcResultMatchers.*; + +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import com.decathlon.idp_core.AbstractIntegrationTest; +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.InboundWebhookEntityMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookMappingLinkPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; + +import lombok.extern.slf4j.Slf4j; + +/// Integration tests for InboundWebhookManagementController, covering all CRUD operations on inbound webhook connectors. +/// +/// Covers the full HTTP contract (status codes, response shape, validation errors) +/// for all CRUD operations on inbound webhook connectors. +@DisplayName("InboundWebhookManagementController Integration Tests") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", + "/db/test/R__4_insert_webhook_test_data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Slf4j +class InboundWebhookManagementControllerTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private WebhookMappingLinkPersistenceMapper webhookTemplateMappingPersistenceMapper; + + private static final String WEBHOOK_PATH = "/api/v1/inbound_webhooks"; + private static final String ENTITY_DYNAMIC_MAPPING_PATH = "/api/v1/entity_dynamic_mappings"; + private static final String JSON_PATH = "integration_test/json/webhook/v1/"; + + /// Creates an entity dynamic mapping via API required for webhook connector + /// creation. + private void createEntityDynamicMapping(String mappingIdentifier) throws Exception { + var payload = """ + { + "identifier": "%s", + "entity_template_identifier": "microservice", + "filter": ".action == \\"pushed\\"", + "name":"test mapping name", + "description":"descrption", + "entity": { + "identifier": ".repository.full_name", + "name": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.email", + "environment": "\\"DEV\\"", + "version": ".ref", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """.formatted(mappingIdentifier); + + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_DYNAMIC_MAPPING_PATH) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()); + } + + private void createWebhookConnector(String identifier, String title) throws Exception { + // Create a unique entity dynamic mapping for this webhook connector + String uniqueMappingIdentifier = identifier + "-mapping"; + createEntityDynamicMapping(uniqueMappingIdentifier); + + var payload = """ + { + "identifier": "%s", + "name": "%s", + "description": "test connector", + "enabled": true, + "mapping_identifiers": ["%s"], + "security": { + "type": "NONE", + "config": {} + } + } + """.formatted(identifier, title, uniqueMappingIdentifier); + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()); + } + + private String buildPutPayload(String title, boolean enabled, String mappingIdentifier) { + return """ + { + "name": "%s", + "description": "updated description", + "enabled": %s, + "mapping_identifiers": ["%s"], + "security": { + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Token", + "secret_alias": "MY_TOKEN" + } + } + } + """.formatted(title, enabled, mappingIdentifier); + } + + @Nested + @DisplayName("GET /api/v1/inbound_webhooks") + @Order(1) + class GetWebhooksPaginatedTests { + + @Test + @DisplayName("Should return 401 without authentication") + void getWebhooks_401_without_user_token() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with page containing connectors from test data") + void getWebhooks_200_with_data() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH).accept(APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page.total_elements").value(3)) + .andExpect(jsonPath("$.content[0].identifier").value("github-dora-connector")) + .andExpect(jsonPath("$.content[1].identifier").value("public-connector")) + .andExpect(jsonPath("$.content[2].identifier").value("token-connector")); + } + } + + @Nested + @DisplayName("POST /api/v1/inbound_webhooks - Create connector") + @Order(2) + class PostWebhookTests { + + @Test + @DisplayName("Should return 401 without authentication") + void postWebhook_401_without_user_token() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(JSON_PATH + "postWebhook_201.json"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should create connector and return 201") + void postWebhook_201() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(JSON_PATH + "postWebhook_201.json"))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("github-dora-connector-test")) + .andExpect(jsonPath("$.name").value("GitHub DORA Connector test")) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")); + + assertWebhookTemplateMapping("github-dora-connector-test"); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when identifier already exists") + void postWebhook_409_identifier_already_exists() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + JSON_PATH + "postWebhook_409_identifier_already_exists.json"))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description") + .value(containsString(ValidationMessages.WEBHOOK_CONNECTOR_ALREADY_EXIST))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when identifier is missing") + void postWebhook_400_identifier_missing() throws Exception { + var result = postBadRequestAndAssertEquals(WEBHOOK_PATH, + JSON_PATH + "postWebhook_400_identifier_missing.json", + ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY); + assertNotNull(result); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when identifier is blank") + void postWebhook_400_identifier_blank() throws Exception { + var result = postBadRequestAndAssertEquals(WEBHOOK_PATH, + JSON_PATH + "postWebhook_400_identifier_blank.json", + ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY); + assertNotNull(result); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when security type is unknown") + void postWebhook_400_invalid_security_type() throws Exception { + var result = postBadRequestAndAssertContains(WEBHOOK_PATH, + JSON_PATH + "postWebhook_400_invalid_security_type.json", "UNKNOWN_TYPE"); + assertNotNull(result); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping identifier does not exist") + void postWebhook_404_mapping_not_found() throws Exception { + var payload = """ + { + "identifier": "webhook-with-unknown-mapping", + "name": "Webhook With Unknown Mapping", + "description": "Should fail due to non-existent mapping", + "enabled": true, + "mapping_identifiers": ["non-existent-mapping"], + "security": { + "type": "NONE", + "config": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").value(containsString("non-existent-mapping"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when security config is missing required fields (HMAC_SHA256)") + void postWebhook_400_missing_security_config_fields() throws Exception { + var payload = """ + { + "identifier": "missing-security-fields", + "name": "Missing Security Fields", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + + @Test + @WithMockUser + @DisplayName("Should create connector with NONE security when security field is omitted") + void postWebhook_201_security_defaults_to_none_when_omitted() throws Exception { + var payload = """ + { + "identifier": "no-security-webhook", + "name": "No Security Webhook", + "enabled": true, + "mapping_identifiers": [] + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE")); + } + + @Test + @WithMockUser + @DisplayName("Should create connector with STATIC_TOKEN security and return 201") + void postWebhook_201_static_token_security() throws Exception { + var payload = """ + { + "identifier": "static-token-webhook", + "name": "Static Token Webhook", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Auth-Token", + "secret_alias": "TOKEN_SECRET" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("static-token-webhook")) + .andExpect(jsonPath("$.security.type").value("STATIC_TOKEN")); + } + + @Test + @WithMockUser + @DisplayName("Should create connector with BASIC_AUTH security and return 201") + void postWebhook_201_basic_auth_security() throws Exception { + var payload = """ + { + "identifier": "basic-auth-webhook", + "name": "Basic Auth Webhook", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "BASIC_AUTH", + "config": { + "username": "admin", + "secret_alias": "BASIC_PASS" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("basic-auth-webhook")) + .andExpect(jsonPath("$.security.type").value("BASIC_AUTH")); + } + + @Test + @WithMockUser + @DisplayName("Should force enabled to false when no mappings provided") + void postWebhook_201_enabled_forced_false_when_no_mapping() throws Exception { + var payload = """ + { + "identifier": "no-mapping-webhook", + "name": "No Mapping Webhook", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "NONE", + "config": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("no-mapping-webhook")) + // Even though enabled=true was sent, it must be forced to false (no mappings) + .andExpect(jsonPath("$.enabled").value(false)); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when name exceeds 255 characters") + void postWebhook_400_title_too_long() throws Exception { + var tooLongTitle = "A".repeat(256); + var payload = """ + { + "identifier": "long-name-webhook", + "name": "%s", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "NONE", + "config": {} + } + } + """.formatted(tooLongTitle); + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when name is blank") + void postWebhook_400_title_blank() throws Exception { + var payload = """ + { + "identifier": "blank-name-webhook", + "name": " ", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "NONE", + "config": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when security config is missing required fields (BASIC_AUTH)") + void postWebhook_400_basic_auth_missing_fields() throws Exception { + var payload = """ + { + "identifier": "basic-auth-missing-fields", + "name": "Basic Auth Missing Fields", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "BASIC_AUTH", + "config": { + "username": "admin" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + } + + @Nested + @DisplayName("GET /api/v1/inbound_webhooks/{identifier} - Get by identifier") + @Order(3) + class GetWebhookByIdentifierTests { + + @Test + @WithMockUser + @DisplayName("Should return 200 with connector details from test data") + void getWebhook_200() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("github-dora-connector")) + .andExpect(jsonPath("$.name").value("GitHub Connector")) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")) + .andExpect(jsonPath("$.mappings[0].entity_template_identifier").value("microservice")); + } + + @Test + @WithMockUser + @DisplayName("Should return full mapping shape including entity fields") + void getWebhook_200_full_mapping_shape() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.mappings").isArray()) + .andExpect(jsonPath("$.mappings[0].identifier").exists()) + .andExpect(jsonPath("$.mappings[0].entity_template_identifier").value("microservice")) + .andExpect(jsonPath("$.mappings[0].filter").value(".action == \"pushed\"")) + .andExpect(jsonPath("$.mappings[0].entity.identifier").exists()) + .andExpect(jsonPath("$.mappings[0].entity.name").exists()) + .andExpect(jsonPath("$.mappings[0].entity.properties").exists()) + .andExpect(jsonPath("$.mappings[0].entity.relations").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return security type and config in response") + void getWebhook_200_security_shape() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.security").exists()) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")) + .andExpect(jsonPath("$.security.config").exists()) + .andExpect(jsonPath("$.security.config.header_name").value("X-Hub-Signature-256")); + } + + @Test + @WithMockUser + @DisplayName("Should return enabled and description fields") + void getWebhook_200_base_fields() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/public-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("public-connector")) + .andExpect(jsonPath("$.name").value("Public Connector")) + .andExpect(jsonPath("$.description").exists()) + .andExpect(jsonPath("$.enabled").isBoolean()); + } + + @Test + @WithMockUser + @DisplayName("Should return STATIC_TOKEN security type") + void getWebhook_200_static_token_security() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/token-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.security.type").value("STATIC_TOKEN")) + .andExpect(jsonPath("$.security.config.header_name").value("X-Auth-Token")); + } + + @Test + @WithMockUser + @DisplayName("Should return NONE security type with empty config") + void getWebhook_200_none_security() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/public-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.security.type").value("NONE")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when connector does not exist") + void getWebhook_404_not_found() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/non-existent-connector").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @DisplayName("Should return 401 without authentication") + void getWebhook_401_without_user_token() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("PUT /api/v1/inbound_webhooks/{identifier} - Update connector") + @Order(4) + class PutWebhookTests { + + @Test + @DisplayName("Should return 401 without authentication") + void putWebhook_401_without_user_token() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/github-dora-connector") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(JSON_PATH + "putWebhook_200.json"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should update connector and return 200") + void putWebhook_200() throws Exception { + createWebhookConnector("connector-put-200", "Connector Put Initial Title"); + + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-200") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildPutPayload("Connector Put Updated Title", false, + "connector-put-200-mapping"))) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("connector-put-200")) + .andExpect(jsonPath("$.name").value("Connector Put Updated Title")) + .andExpect(jsonPath("$.enabled").value(false)) + .andExpect(jsonPath("$.security.type").value("STATIC_TOKEN")); + + mockMvc.perform(get(WEBHOOK_PATH + "/connector-put-200").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Connector Put Updated Title")) + .andExpect(jsonPath("$.enabled").value(false)); + + assertWebhookTemplateMapping("connector-put-200"); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when connector does not exist") + void putWebhook_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/non-existent-connector") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildPutPayload("Updated Title", false, "microservice-mapping"))) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should force enabled to false when updating with empty mappings") + void putWebhook_200_enabled_forced_false_when_no_mapping() throws Exception { + createWebhookConnector("connector-put-no-mapping", "Connector No Mapping Title"); + + var payload = """ + { + "name": "Connector No Mapping Title", + "description": "updated without mappings", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "NONE", + "config": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-no-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("connector-put-no-mapping")) + // enabled=true sent but no mappings → must be forced to false + .andExpect(jsonPath("$.enabled").value(false)); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when PUT security config is missing required fields") + void putWebhook_400_missing_security_config_fields() throws Exception { + createWebhookConnector("connector-put-bad-security", "Connector Bad Security"); + + var payload = """ + { + "name": "Connector Bad Security", + "enabled": true, + "mapping_identifiers": ["connector-put-bad-security-mapping"], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Sig" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-bad-security") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + } + + @Nested + @DisplayName("DELETE /api/v1/inbound_webhooks/{identifier} - Delete connector") + @Order(5) + class DeleteWebhookTests { + + @Test + @DisplayName("Should return 401 without authentication") + void deleteWebhook_401_without_user_token() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/github-dora-connector") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should delete connector and return 204") + void deleteWebhook_204() throws Exception { + createWebhookConnector("connector-delete-204", "Connector To Delete"); + + mockMvc.perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/connector-delete-204") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + // Verify the webhook no longer exists + mockMvc.perform(get(WEBHOOK_PATH + "/connector-delete-204").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when connector does not exist") + void deleteWebhook_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/non-existent-connector") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + } + + /// Asserts that the webhook connector has at least one mapping via API. + private void assertWebhookTemplateMapping(String identifier) throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/" + identifier).accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.mappings").isArray()) + .andExpect(jsonPath("$.mappings").isNotEmpty()) + .andExpect(jsonPath("$.mappings[0].filter").value(".action == \"pushed\"")); + } + + @Nested + @DisplayName("Webhook support components coverage") + @Order(7) + class WebhookSupportComponentsCoverageTests { + + @Test + @DisplayName("InboundWebhookEntityMappingDtoOut should defensively copy maps") + void inboundWebhookEntityMappingDtoOut_shouldDefensivelyCopyMaps() { + var properties = Map.of("applicationName", ".repository.name"); + var relations = Map.of("owner", ".sender.login"); + + var dto = new InboundWebhookEntityMappingDtoOut(".repository.full_name", ".repository.name", + properties, relations); + + assertEquals(".repository.full_name", dto.identifier()); + assertEquals(".repository.name", dto.title()); + assertEquals(properties, dto.properties()); + assertEquals(relations, dto.relations()); + var copiedProperties = dto.properties(); + var copiedRelations = dto.relations(); + assertThrows(UnsupportedOperationException.class, + () -> copiedProperties.put("environment", "DEV")); + assertThrows(UnsupportedOperationException.class, + () -> copiedRelations.put("team", ".team.slug")); + } + + @Test + @DisplayName("WebhookConnectorConfigurationException should expose its message") + void webhookConnectorConfigurationException_shouldExposeMessage() { + var exception = new WebhookConnectorConfigurationException( + "Webhook connector name is mandatory"); + + assertInstanceOf(RuntimeException.class, exception); + assertEquals("Webhook connector name is mandatory", exception.getMessage()); + } + + @Test + @DisplayName("WebhookTemplateMappingPersistenceMapper should build link entity from explicit ids") + void webhookTemplateMappingPersistenceMapper_shouldBuildLinkEntityFromExplicitIds() { + UUID webhookId = UUID.fromString("770e8400-e29b-41d4-a716-446655440001"); + UUID entityMappingId = UUID.fromString("880e8400-e29b-41d4-a716-446655440001"); + String jsltFilter = ".action == \"pushed\""; + + WebhookMappingLinkJpaEntity jpa = webhookTemplateMappingPersistenceMapper.toJpa(webhookId, + entityMappingId, jsltFilter); + + assertEquals(webhookId, jpa.getWebhookId()); + assertEquals(entityMappingId, jpa.getEntityMappingId()); + assertEquals(jsltFilter, jpa.getJsltFilter()); + } + } + + @Nested + @DisplayName("Security config validation - all types") + @Order(8) + class SecurityConfigTests { + + @Test + @WithMockUser + @DisplayName("NONE — Should create connector with empty config") + void security_none_empty_config_201() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-none", "None Security", "NONE", "{}"))) + .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE")); + } + + @Test + @WithMockUser + @DisplayName("NONE — Should create connector without config field") + void security_none_no_config_201() throws Exception { + var payload = """ + { + "identifier": "sec-none-no-config", + "name": "None No Config", + "enabled": false, + "mapping_identifiers": [], + "security": { "type": "NONE", "config":{} } + } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()).andExpect(jsonPath("$.security.type").value("NONE")); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 — Should create with valid header_name and secret_alias") + void security_hmac_valid_201() throws Exception { + var config = """ + { "header_name": "X-Hub-Signature-256", "secret_alias": "MY_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-hmac-valid", "HMAC Valid", "HMAC_SHA256", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")) + .andExpect(jsonPath("$.security.config.header_name").value("X-Hub-Signature-256")); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 — Should return 400 when header_name is missing") + void security_hmac_missing_header_name_400() throws Exception { + var config = """ + { "secret_alias": "MY_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-hmac-no-header", "HMAC No Header", "HMAC_SHA256", + config))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("header_name"))); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 — Should return 400 when secret_alias is missing") + void security_hmac_missing_secret_alias_400() throws Exception { + var config = """ + { "header_name": "X-Hub-Signature-256" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-hmac-no-secret", "HMAC No Secret", "HMAC_SHA256", + config))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 — Should return 400 when secret_alias has invalid format (lowercase)") + void security_hmac_invalid_secret_alias_format_400() throws Exception { + var config = """ + { "header_name": "X-Sig", "secret_alias": "invalid-lowercase" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-hmac-bad-alias", "HMAC Bad Alias", "HMAC_SHA256", + config))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("UPPER_SNAKE_CASE"))); + } + + @Test + @WithMockUser + @DisplayName("HMAC_SHA256 — Should accept secret_alias as environment reference ${MY_SECRET}") + void security_hmac_env_reference_201() throws Exception { + var config = """ + { "header_name": "X-Sig", "secret_alias": "${MY_SECRET}" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + buildSecurityPayload("sec-hmac-env-ref", "HMAC Env Ref", "HMAC_SHA256", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")); + } + + // ----------------------------------------------------------------------- + // STATIC_TOKEN + // ----------------------------------------------------------------------- + + @Test + @WithMockUser + @DisplayName("STATIC_TOKEN — Should create with valid header_name and secret_alias") + void security_static_token_valid_201() throws Exception { + var config = """ + { "header_name": "X-Auth-Token", "secret_alias": "TOKEN_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + buildSecurityPayload("sec-token-valid", "Token Valid", "STATIC_TOKEN", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("STATIC_TOKEN")) + .andExpect(jsonPath("$.security.config.header_name").value("X-Auth-Token")); + } + + @Test + @WithMockUser + @DisplayName("STATIC_TOKEN — Should return 400 when header_name is missing") + void security_static_token_missing_header_400() throws Exception { + var config = """ + { "secret_alias": "TOKEN_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-token-no-header", "Token No Header", + "STATIC_TOKEN", config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("header_name"))); + } + + @Test + @WithMockUser + @DisplayName("STATIC_TOKEN — Should return 400 when secret_alias is missing") + void security_static_token_missing_secret_400() throws Exception { + var config = """ + { "header_name": "X-Auth-Token" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-token-no-secret", "Token No Secret", + "STATIC_TOKEN", config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + + // ----------------------------------------------------------------------- + // BASIC_AUTH + // ----------------------------------------------------------------------- + + @Test + @WithMockUser + @DisplayName("BASIC_AUTH — Should create with valid username and secret_alias") + void security_basic_auth_valid_201() throws Exception { + var config = """ + { "username": "admin", "secret_alias": "BASIC_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + buildSecurityPayload("sec-basic-valid", "Basic Valid", "BASIC_AUTH", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("BASIC_AUTH")); + } + + @Test + @WithMockUser + @DisplayName("BASIC_AUTH — Should return 400 when username is missing") + void security_basic_auth_missing_username_400() throws Exception { + var config = """ + { "secret_alias": "BASIC_SECRET" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + buildSecurityPayload("sec-basic-no-user", "Basic No User", "BASIC_AUTH", config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("username"))); + } + + @Test + @WithMockUser + @DisplayName("BASIC_AUTH — Should return 400 when secret_alias is missing") + void security_basic_auth_missing_secret_400() throws Exception { + var config = """ + { "username": "admin" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-basic-no-secret", "Basic No Secret", "BASIC_AUTH", + config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + + // ----------------------------------------------------------------------- + // JWT_BEARER + // ----------------------------------------------------------------------- + + @Test + @WithMockUser + @DisplayName("JWT_BEARER — Should create with valid jwks_uri") + void security_jwt_bearer_valid_201() throws Exception { + var config = """ + { "jwks_uri": "https://auth.example.com/.well-known/jwks.json" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-jwt-valid", "JWT Valid", "JWT_BEARER", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("JWT_BEARER")); + } + + @Test + @WithMockUser + @DisplayName("JWT_BEARER — Should create with jwks_uri as environment reference") + void security_jwt_bearer_env_reference_201() throws Exception { + var config = """ + { "jwks_uri": "${JWKS_URI}" } + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-jwt-env", "JWT Env", "JWT_BEARER", config))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.security.type").value("JWT_BEARER")); + } + + @Test + @WithMockUser + @DisplayName("JWT_BEARER — Should return 400 when jwks_uri is missing") + void security_jwt_bearer_missing_jwks_uri_400() throws Exception { + var config = """ + {} + """; + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildSecurityPayload("sec-jwt-no-uri", "JWT No URI", "JWT_BEARER", config))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value(containsString("jwks_uri"))); + } + } + + /// Builds a minimal POST payload with a given security type and config JSON + /// string. + private String buildSecurityPayload(String identifier, String title, String securityType, + String configJson) { + return """ + { + "identifier": "%s", + "name": "%s", + "enabled": false, + "mapping_identifiers": [], + "security": { + "type": "%s", + "config": %s + } + } + """.formatted(identifier, title, securityType, configJson); + } +} 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 8dff1a38..c0aa46ad 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 @@ -1,8 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.api.handler; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -29,11 +27,14 @@ 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.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoPropertiesException; 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_template.PropertyNameNotFoundEntityTemplatePropertiesException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; /// Comprehensive unit tests for [ApiExceptionHandler]. @@ -160,94 +161,226 @@ void shouldHandleEntityValidationException() { 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 - @DisplayName("Validation Exception Handling") - class ValidationExceptionTests { - - /// Tests the handling of [ConstraintViolationException] with a single - /// validation violation. - /// - /// **This test verifies that:** - /// - ConstraintViolationException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Single violation message is correctly extracted and returned - /// - Error response format matches expected structure - @Test - @DisplayName("Should handle ConstraintViolationException with single violation") - void shouldHandleConstraintViolationExceptionSingleViolation() { - // Given - ConstraintViolation violation = createMockConstraintViolation( - "Field must not be null"); - Set> violations = Set.of(violation); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", - violations); - - // When - ResponseEntity response = exceptionHandler - .handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Field must not be null", body.getErrorDescription()); + @Nested + @DisplayName("Validation Exception Handling") + class ValidationExceptionTests { + + @Test + @DisplayName("Should handle EntityDynamicMappingConfigurationException with 400 status") + void shouldHandleEntityDynamicMappingConfigurationException() { + String details = "Syntax Error in 'properties.deployment_id': Parse error"; + EntityDynamicMappingConfigurationException exception = new EntityDynamicMappingConfigurationException( + details); + + ResponseEntity response = exceptionHandler + .handleEntityDynamicMappingConfigurationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(details, body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle PropertyNameNotFoundEntityTemplatePropertiesException with 400 status") + void shouldHandlePropertyNameNotFoundEntityTemplatePropertiesException() { + String details = "Property name additionalProp3 not found in entity entityTemplateIdentifier properties"; + PropertyNameNotFoundEntityTemplatePropertiesException exception = new PropertyNameNotFoundEntityTemplatePropertiesException( + details); + + ResponseEntity response = exceptionHandler + .handlePropertyNameNotFoundEntityTemplatePropertiesException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(details, body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle RelationNameNotFoundEntityTemplateRelationsException with 400 status") + void shouldHandleRelationNameNotFoundEntityTemplateRelationsException() { + // Given + String details = "Relation name github_repository not found in entity entityTemplateIdentifier relations"; + RelationNameNotFoundEntityTemplateRelationsException exception = new RelationNameNotFoundEntityTemplateRelationsException( + details); + + // When + ResponseEntity response = exceptionHandler + .handleRelationNameNotFoundEntityTemplateRelationsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(details, body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle EntityDynamicMappingHasNoPropertiesException with 400 status") + void shouldHandleEntityDynamicMappingHasNoPropertiesException() { + String details = "The mapping defines properties but the target entityTemplateIdentifier has no property definitions"; + EntityDynamicMappingHasNoPropertiesException exception = new EntityDynamicMappingHasNoPropertiesException( + details); + + ResponseEntity response = exceptionHandler + .handleEntityDynamicMappingHasNoPropertiesException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(details, body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle WebhookSecurityConfigurationException with 400 status") + void shouldHandleWebhookSecurityConfigurationException() { + String details = "Webhook security type is mandatory"; + WebhookSecurityConfigurationException exception = new WebhookSecurityConfigurationException( + details); + + ResponseEntity response = exceptionHandler + .handleWebhookSecurityConfigurationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(details, body.getErrorDescription()); + } + + /// Tests the handling of [ConstraintViolationException] with a single + /// validation violation. + /// + /// **This test verifies that:** + /// - ConstraintViolationException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Single violation message is correctly extracted and returned + /// - Error response format matches expected structure + @Test + @DisplayName("Should handle ConstraintViolationException with single violation") + void shouldHandleConstraintViolationExceptionSingleViolation() { + // Given + ConstraintViolation violation = createMockConstraintViolation( + "Field must not be null"); + Set> violations = Set.of(violation); + ConstraintViolationException exception = new ConstraintViolationException( + "Validation failed", violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Field must not be null", body.getErrorDescription()); + } + + /// Tests the handling of [ConstraintViolationException] with multiple + /// validation violations. + /// + /// **This test verifies that:** + /// - ConstraintViolationException with multiple violations is properly handled + /// - HTTP 400 Bad Request status is returned + /// - All violation messages are concatenated with comma separation + /// - Error response contains all validation error messages + @Test + @DisplayName("Should handle ConstraintViolationException with multiple violations") + void shouldHandleConstraintViolationExceptionMultipleViolations() { + // Given + ConstraintViolation violation1 = createMockConstraintViolation( + "Field1 must not be null"); + ConstraintViolation violation2 = createMockConstraintViolation( + "Field2 must not be blank"); + Set> violations = Set.of(violation1, violation2); + ConstraintViolationException exception = new ConstraintViolationException( + "Validation failed", violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 must not be null")); + assertTrue(errorDescription.contains("Field2 must not be blank")); + assertTrue(errorDescription.contains(", ")); + } + + /// Tests the handling of [MethodArgumentNotValidException] with field + /// validation errors. + /// + /// **This test verifies that:** + /// - MethodArgumentNotValidException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Field error messages from binding result are extracted and concatenated + /// - All field validation errors are included in the response with comma + /// separation + /// + /// @throws Exception if reflection fails during test setup + @Test + @DisplayName("Should handle MethodArgumentNotValidException with field errors") + void shouldHandleMethodArgumentNotValidException() throws Exception { + // Given + Object target = new Object(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, + "testObject"); + bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); + bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); + + // Create a proper MethodParameter mock with required methods + MethodParameter methodParameter = mock(MethodParameter.class); + when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException( + methodParameter, bindingResult); + + // When + ResponseEntity response = exceptionHandler + .handleMethodArgumentNotValidException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 is required")); + assertTrue(errorDescription.contains("Field2 must be valid")); + assertTrue(errorDescription.contains(", ")); + } + + // Helper method for mocking + public void testMethod() { + // Empty method for testing purposes + } + + @SuppressWarnings("unchecked") + private ConstraintViolation createMockConstraintViolation(String message) { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn(message); + return violation; + } } /// Tests the handling of [ConstraintViolationException] with multiple diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java new file mode 100644 index 00000000..8ca13d13 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java @@ -0,0 +1,91 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.webhook; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook.InboundWebhookMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingMapper; + +@DisplayName("InboundWebhookMapper Tests") +class InboundWebhookMapperTest { + + private final InboundWebhookMapper mapper = new InboundWebhookMapper( + new EntityDynamicMappingMapper()); + + private static EntityDynamicMapping resolvedMapping() { + return new EntityDynamicMapping(UUID.randomUUID(), "deployment-mapping", "deployment", + ".eventType == \"DEPLOYED\"", "deployment mapping", "deployment mapping description", ".id", + ".name", Map.of("environment", ".env"), Map.of("service", ".service")); + } + + @Test + @DisplayName("Should use path identifier for update mapping") + void shouldUsePathIdentifierForUpdateMapping() { + var request = new InboundWebhookUpdateDtoIn("GitHub DORA", "Collect deployment events", true, + List.of("deployment-mapping"), + new InboundWebhookSecurityContractDtoIn("HMAC_SHA256", Map.of("header_name", + "X-Hub-Signature-256", "secret_alias", "MY_SECRET", "prefix", "sha256="))); + + WebhookConnector domain = mapper.toDomainForUpdate("identifier_from_path", request, + List.of(resolvedMapping())); + + assertThat(domain.id()).isNull(); + assertThat(domain.identifier()).isEqualTo("identifier_from_path"); + assertThat(domain.name()).isEqualTo("GitHub DORA"); + assertThat(domain.mappings()).hasSize(1); + assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.HMAC_SHA256); + assertThat(domain.security().config()).containsEntry("prefix", "sha256="); + } + + @Test + @DisplayName("Should throw for unknown security type") + void shouldThrowForUnknownSecurityType() { + var request = new InboundWebhookCreateDtoIn("my-connector", "Custom Security", + "Uses custom security", true, List.of("deployment-mapping"), + new InboundWebhookSecurityContractDtoIn("CUSTOM_UNKNOWN_TYPE", + Map.of("customKey", "customValue"))); + + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> mapper.toDomain(request, List.of(resolvedMapping()))) + .isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("CUSTOM_UNKNOWN_TYPE"); + } + + @Test + @DisplayName("Should map NONE security type explicitly") + void shouldMapNoneSecurityTypeExplicitly() { + var request = new InboundWebhookCreateDtoIn("my-connector", "No Auth", + "Webhook without authentication", true, List.of("deployment-mapping"), + new InboundWebhookSecurityContractDtoIn("NONE", Map.of())); + + var domain = mapper.toDomain(request, List.of(resolvedMapping())); + + assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE); + assertThat(domain.security().config()).isEmpty(); + } + + @Test + @DisplayName("Should default to NONE when security section is missing") + void shouldDefaultToNoneWhenSecurityIsMissing() { + var request = new InboundWebhookCreateDtoIn("my-connector", "No Auth", + "Webhook without authentication", true, List.of("deployment-mapping"), null); + + var domain = mapper.toDomain(request, List.of(resolvedMapping())); + + assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE); + assertThat(domain.security().config()).isEmpty(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java new file mode 100644 index 00000000..c36f57f3 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java @@ -0,0 +1,181 @@ +package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +@DisplayName("JsltEntityMappingValidator") +class JsltEntityMappingValidatorTest { + + private JsltEntityMappingValidator validator; + + @BeforeEach + void setUp() { + validator = new JsltEntityMappingValidator(); + } + + // --------------------------------------------------------------------------- + // validate + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("validate") + class ValidateTests { + + @Test + @DisplayName("Should pass when all JSLT expressions are valid") + void shouldPassWhenAllExpressionsValid() { + var mapping = buildMapping(".action == \"pushed\"", ".repository.full_name", + ".repository.name", Map.of("applicationName", ".repository.name"), + Map.of("owner", ".sender.login")); + + assertThatCode(() -> validator.validate(mapping)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should pass when properties and relations are empty (false branch)") + void shouldPassWhenPropertiesAndRelationsEmpty() { + var mapping = buildMapping(".action", ".repository.full_name", ".repository.name", Map.of(), + Map.of()); + + assertThatCode(() -> validator.validate(mapping)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw when the filter expression is an invalid JSLT") + void shouldThrowWhenFilterExpressionInvalid() { + var mapping = buildMapping("[", ".repository.full_name", ".repository.name", Map.of(), + Map.of()); + + assertThatThrownBy(() -> validator.validate(mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("Validation failed with").hasMessageContaining("filter"); + } + + @Test + @DisplayName("Should report a required-field error when a property expression is blank") + void shouldReportRequiredErrorWhenPropertyExpressionBlank() { + var properties = new HashMap(); + properties.put("applicationName", " "); + + var mapping = buildMapping(".action", ".repository.full_name", ".repository.name", properties, + Map.of()); + + assertThatThrownBy(() -> validator.validate(mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("properties.applicationName") + .hasMessageContaining("is required and must contain a JSLT expression"); + } + + @Test + @DisplayName("Should report an error when a relation expression is invalid JSLT") + void shouldReportErrorWhenRelationExpressionInvalid() { + var mapping = buildMapping(".action", ".repository.full_name", ".repository.name", Map.of(), + Map.of("owner", "[")); + + assertThatThrownBy(() -> validator.validate(mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("relations.owner"); + } + + @Test + @DisplayName("Should aggregate multiple errors into a single exception message") + void shouldAggregateMultipleErrors() { + var properties = new HashMap(); + properties.put("applicationName", " "); + + var mapping = buildMapping("[", ".repository.full_name", ".repository.name", properties, + Map.of("owner", "[")); + + assertThatThrownBy(() -> validator.validate(mapping)) + .isInstanceOf(EntityDynamicMappingConfigurationException.class) + .hasMessageContaining("Validation failed with 3 errors"); + } + } + + // --------------------------------------------------------------------------- + // formatJsltErrorMessage (private — exercised through reflection) + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("formatJsltErrorMessage") + class FormatJsltErrorMessageTests { + + @Test + @DisplayName("Should return generic message when raw message is null") + void shouldReturnGenericMessageWhenNull() { + assertThat(formatJsltErrorMessage(null)).isEqualTo("JSLT syntax error."); + } + + @Test + @DisplayName("Should return generic message when raw message is blank") + void shouldReturnGenericMessageWhenBlank() { + assertThat(formatJsltErrorMessage(" ")).isEqualTo("JSLT syntax error."); + } + + @Test + @DisplayName("Should strip the 'Parse error:' prefix and normalize whitespace") + void shouldStripParseErrorPrefixAndNormalize() { + assertThat(formatJsltErrorMessage("Parse error: something went wrong")) + .isEqualTo("something went wrong"); + } + + @Test + @DisplayName("Should format message with line, column and token when all present") + void shouldFormatWithLineColumnAndToken() { + var raw = "Parse error: at line 3, column 7 Encountered \"}\" but expected something"; + + assertThat(formatJsltErrorMessage(raw)) + .isEqualTo("JSLT syntax error at line 3, column 7 (unexpected token: })."); + } + + @Test + @DisplayName("Should format message with line and column only when token absent") + void shouldFormatWithLineColumnOnly() { + var raw = "Unexpected failure at line 5, column 2 in the expression"; + + assertThat(formatJsltErrorMessage(raw)).isEqualTo("JSLT syntax error at line 5, column 2."); + } + + @Test + @DisplayName("Should fall back to normalized message when no location is present") + void shouldFallBackToNormalizedMessage() { + assertThat(formatJsltErrorMessage("Totally unexpected message")) + .isEqualTo("Totally unexpected message"); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private EntityDynamicMapping buildMapping(String filter, String entityIdentifier, + String entityTitle, Map properties, Map relations) { + return new EntityDynamicMapping(UUID.randomUUID(), "my-mapping", "microservice", filter, + "My Mapping", "description", entityIdentifier, entityTitle, properties, relations); + } + + /// Invokes the private `formatJsltErrorMessage` method through reflection so + /// every formatting branch can be exercised deterministically. + private String formatJsltErrorMessage(String rawMessage) { + try { + Method method = JsltEntityMappingValidator.class.getDeclaredMethod("formatJsltErrorMessage", + String.class); + method.setAccessible(true); + return (String) method.invoke(validator, rawMessage); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to invoke formatJsltErrorMessage", e); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptorTest.java new file mode 100644 index 00000000..b0f6bd98 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptorTest.java @@ -0,0 +1,278 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +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.BeforeEach; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; + +@DisplayName("EntityDynamicMappingAdaptor Tests") +@ExtendWith(MockitoExtension.class) +class EntityDynamicMappingAdaptorTest { + + @Mock + private JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository; + + @Mock + private EntityDynamicMappingPersistenceMapper entityDynamicMappingPersistenceMapper; + + @Mock + private EntityTemplateRepositoryPort entityTemplateRepositoryPort; + + private EntityDynamicMappingAdaptor adaptor; + + @BeforeEach + void setUp() { + adaptor = new EntityDynamicMappingAdaptor(jpaEntityDynamicMappingRepository, + entityDynamicMappingPersistenceMapper, entityTemplateRepositoryPort); + } + + // --------------------------------------------------------------------------- + // existsByIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("existsByIdentifier") + class ExistsByIdentifierTests { + + @Test + @DisplayName("Should return true when mapping exists") + void shouldReturnTrueWhenExists() { + when(jpaEntityDynamicMappingRepository.existsByIdentifier("my-mapping")).thenReturn(true); + assertThat(adaptor.existsByIdentifier("my-mapping")).isTrue(); + } + + @Test + @DisplayName("Should return false when mapping does not exist") + void shouldReturnFalseWhenNotExists() { + when(jpaEntityDynamicMappingRepository.existsByIdentifier("unknown")).thenReturn(false); + assertThat(adaptor.existsByIdentifier("unknown")).isFalse(); + } + } + + // --------------------------------------------------------------------------- + // existsByTemplateIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("existsByTemplateIdentifier") + class ExistsByTemplateIdentifierTests { + + @Test + @DisplayName("Should return true when mappings exist for template") + void shouldReturnTrueWhenExists() { + when(jpaEntityDynamicMappingRepository.existsByTemplateIdentifier("web-service")) + .thenReturn(true); + assertThat(adaptor.existsByEntityTemplateIdentifier("web-service")).isTrue(); + } + + @Test + @DisplayName("Should return false when no mappings for template") + void shouldReturnFalseWhenNotExists() { + when(jpaEntityDynamicMappingRepository.existsByTemplateIdentifier("unknown")) + .thenReturn(false); + assertThat(adaptor.existsByEntityTemplateIdentifier("unknown")).isFalse(); + } + } + + // --------------------------------------------------------------------------- + // findByIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("findByIdentifier") + class FindByIdentifierTests { + + @Test + @DisplayName("Should return mapped domain when entity found") + void shouldReturnDomainWhenFound() { + var jpa = buildJpaEntity("my-mapping"); + var domain = buildDomainMapping("my-mapping"); + when(jpaEntityDynamicMappingRepository.findByIdentifier("my-mapping")) + .thenReturn(Optional.of(jpa)); + when(entityDynamicMappingPersistenceMapper.toDomain(jpa)).thenReturn(domain); + + Optional result = adaptor.findByIdentifier("my-mapping"); + + assertThat(result).isPresent().contains(domain); + } + + @Test + @DisplayName("Should return empty optional when entity not found") + void shouldReturnEmptyWhenNotFound() { + when(jpaEntityDynamicMappingRepository.findByIdentifier("unknown")) + .thenReturn(Optional.empty()); + + Optional result = adaptor.findByIdentifier("unknown"); + + assertThat(result).isEmpty(); + } + } + + // --------------------------------------------------------------------------- + // findByTemplateIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("findByTemplateIdentifier") + class FindByTemplateIdentifierTests { + + @Test + @DisplayName("Should return all domain mappings for a given template identifier") + void shouldReturnMappingsForTemplate() { + var jpa1 = buildJpaEntity("mapping-1"); + var jpa2 = buildJpaEntity("mapping-2"); + var domain1 = buildDomainMapping("mapping-1"); + var domain2 = buildDomainMapping("mapping-2"); + + when(jpaEntityDynamicMappingRepository.findByTemplateIdentifier("web-service")) + .thenReturn(List.of(jpa1, jpa2)); + when(entityDynamicMappingPersistenceMapper.toDomain(jpa1)).thenReturn(domain1); + when(entityDynamicMappingPersistenceMapper.toDomain(jpa2)).thenReturn(domain2); + + List result = adaptor.findByEntityTemplateIdentifier("web-service"); + + assertThat(result).hasSize(2).containsExactly(domain1, domain2); + } + + @Test + @DisplayName("Should return empty list when no mappings for template") + void shouldReturnEmptyListWhenNone() { + when(jpaEntityDynamicMappingRepository.findByTemplateIdentifier("unknown")) + .thenReturn(List.of()); + + List result = adaptor.findByEntityTemplateIdentifier("unknown"); + + assertThat(result).isEmpty(); + } + } + + // --------------------------------------------------------------------------- + // save + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("save") + class SaveTests { + + @Test + @DisplayName("Should convert to JPA, save, then convert back to domain") + void shouldSaveAndReturnDomain() { + EntityDynamicMapping domain = buildDomainMapping("my-mapping"); + EntityDynamicMappingJpaEntity jpa = buildJpaEntity("my-mapping"); + EntityDynamicMappingJpaEntity savedJpa = buildJpaEntity("my-mapping"); + UUID templateId = UUID.randomUUID(); + + // The adaptor resolves the template business identifier to its technical UUID + // (fail-fast) before persisting the foreign key. + when(entityTemplateRepositoryPort.findByIdentifier("web-service")) + .thenReturn(Optional.of(new EntityTemplate(templateId, "web-service", "Web Service", null, + List.of(), List.of()))); + when(entityDynamicMappingPersistenceMapper.toJpa(domain)).thenReturn(jpa); + when(jpaEntityDynamicMappingRepository.save(jpa)).thenReturn(savedJpa); + + EntityDynamicMapping result = adaptor.save(domain); + + assertThat(result.id()).isEqualTo(savedJpa.getId()); + assertThat(result.identifier()).isEqualTo(domain.identifier()); + assertThat(result.entityTemplateIdentifier()).isEqualTo(domain.entityTemplateIdentifier()); + assertThat(result.filter()).isEqualTo(domain.filter()); + assertThat(result.name()).isEqualTo(domain.name()); + assertThat(jpa.getEntityTemplateId()).isEqualTo(templateId); + verify(jpaEntityDynamicMappingRepository).save(jpa); + } + } + + // --------------------------------------------------------------------------- + // findAll (paginated) + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("findAll") + class FindAllTests { + + @Test + @DisplayName("Should return paginated domain mappings") + void shouldReturnPaginatedMappings() { + var pageable = PageRequest.of(0, 10); + var jpa = buildJpaEntity("my-mapping"); + var domain = buildDomainMapping("my-mapping"); + var jpaPage = new PageImpl<>(List.of(jpa), pageable, 1); + + when(jpaEntityDynamicMappingRepository.findAll(pageable)).thenReturn(jpaPage); + when(entityDynamicMappingPersistenceMapper.toDomain(jpa)).thenReturn(domain); + + var result = adaptor.findAll(pageable); + + assertThat(result.getContent()).hasSize(1).containsExactly(domain); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("Should return empty page when no mappings exist") + void shouldReturnEmptyPage() { + var pageable = PageRequest.of(0, 10); + when(jpaEntityDynamicMappingRepository.findAll(pageable)) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + + var result = adaptor.findAll(pageable); + + assertThat(result.getContent()).isEmpty(); + } + } + + // --------------------------------------------------------------------------- + // deleteByIdentifier + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("deleteByIdentifier") + class DeleteByIdentifierTests { + + @Test + @DisplayName("Should delegate delete to JPA repository") + void shouldDelegateDeleteToRepository() { + adaptor.deleteByIdentifier("my-mapping"); + verify(jpaEntityDynamicMappingRepository).deleteByIdentifier("my-mapping"); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private EntityDynamicMappingJpaEntity buildJpaEntity(String identifier) { + // Constructor field order (see @AllArgsConstructor on + // EntityDynamicMappingJpaEntity): + // id, identifier, templateId (UUID), template (nav, null in tests), filter, + // name, + // description, entityIdentifier, entityName, properties, relations + return new EntityDynamicMappingJpaEntity(UUID.randomUUID(), identifier, UUID.randomUUID(), null, + ".filter", "name", "desc", ".id", ".title", "{}", "{}"); + } + + private EntityDynamicMapping buildDomainMapping(String identifier) { + return new EntityDynamicMapping(UUID.randomUUID(), identifier, "web-service", ".filter", "name", + "desc", ".id", ".title", Map.of(), Map.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptorTest.java new file mode 100644 index 00000000..915a95c9 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptorTest.java @@ -0,0 +1,141 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookMappingLinkPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository; + +@DisplayName("WebhookTemplateMappingAdaptor Tests") +@ExtendWith(MockitoExtension.class) +class WebhookTemplateMappingAdaptorTest { + + @Mock + private WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository; + + @Mock + private WebhookMappingLinkPersistenceMapper webhookTemplateMappingPersistenceMapper; + + private WebhookTemplateMappingAdaptor adaptor; + + @BeforeEach + void setUp() { + adaptor = new WebhookTemplateMappingAdaptor(jpaWebhookTemplateMappingRepository, + webhookTemplateMappingPersistenceMapper); + } + + // --------------------------------------------------------------------------- + // existsByEntityMappingId + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("existsByEntityMappingId") + class ExistsByEntityMappingIdTests { + + @Test + @DisplayName("Should return true when mapping exists for entity mapping id") + void shouldReturnTrueWhenExists() { + UUID mappingId = UUID.randomUUID(); + when(jpaWebhookTemplateMappingRepository.existsByEntityMappingId(mappingId)).thenReturn(true); + assertThat(adaptor.existsByEntityMappingId(mappingId)).isTrue(); + } + + @Test + @DisplayName("Should return false when no mapping exists for entity mapping id") + void shouldReturnFalseWhenNotExists() { + UUID mappingId = UUID.randomUUID(); + when(jpaWebhookTemplateMappingRepository.existsByEntityMappingId(mappingId)) + .thenReturn(false); + assertThat(adaptor.existsByEntityMappingId(mappingId)).isFalse(); + } + } + + // --------------------------------------------------------------------------- + // findByEntityMappingId + // --------------------------------------------------------------------------- + + @Nested + @DisplayName("findByEntityMappingId") + class FindByEntityMappingIdTests { + + @Test + @DisplayName("Should return mapped domain objects for a given entity mapping id") + void shouldReturnMappedDomainObjects() { + UUID entityMappingId = UUID.randomUUID(); + var jpa = buildJpaEntity(); + var domain = buildDomain("my-webhook"); + + when(jpaWebhookTemplateMappingRepository.findByEntityMappingId(entityMappingId)) + .thenReturn(List.of(jpa)); + when(webhookTemplateMappingPersistenceMapper.toDomain(jpa)).thenReturn(domain); + + var result = adaptor.findByEntityMappingId(entityMappingId); + + assertThat(result).hasSize(1).containsExactly(domain); + } + + @Test + @DisplayName("Should return empty list when no mappings for entity mapping id") + void shouldReturnEmptyListWhenNone() { + UUID entityMappingId = UUID.randomUUID(); + when(jpaWebhookTemplateMappingRepository.findByEntityMappingId(entityMappingId)) + .thenReturn(List.of()); + + var result = adaptor.findByEntityMappingId(entityMappingId); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should return multiple domain objects for the same entity mapping id") + void shouldReturnMultipleMappingsForSameMappingId() { + UUID entityMappingId = UUID.randomUUID(); + WebhookMappingLinkJpaEntity jpa1 = buildJpaEntity(); + WebhookMappingLinkJpaEntity jpa2 = buildJpaEntity(); + WebhookTemplateMapping domain1 = buildDomain("webhook-a"); + WebhookTemplateMapping domain2 = buildDomain("webhook-b"); + + when(jpaWebhookTemplateMappingRepository.findByEntityMappingId(entityMappingId)) + .thenReturn(List.of(jpa1, jpa2)); + when(webhookTemplateMappingPersistenceMapper.toDomain(jpa1)).thenReturn(domain1); + when(webhookTemplateMappingPersistenceMapper.toDomain(jpa2)).thenReturn(domain2); + + List result = adaptor.findByEntityMappingId(entityMappingId); + + assertThat(result).hasSize(2).containsExactlyInAnyOrder(domain1, domain2); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private WebhookMappingLinkJpaEntity buildJpaEntity() { + return WebhookMappingLinkJpaEntity.builder().webhookId(UUID.randomUUID()) + .entityMappingId(UUID.randomUUID()).jsltFilter(".action == \"push\"").build(); + } + + private WebhookTemplateMapping buildDomain(String webhookIdentifier) { + WebhookConnector connector = new WebhookConnector(UUID.randomUUID(), webhookIdentifier, + webhookIdentifier + " Title", "desc", true, List.of(), + new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + return new WebhookTemplateMapping(UUID.randomUUID(), connector, null, null, null); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java new file mode 100644 index 00000000..a95ed4eb --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java @@ -0,0 +1,30 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +@DisplayName("Runtime Security: Basic Auth Validator") +class BasicAuthSecurityValidatorTest { + + private BasicAuthSecurityValidator validator; + + @BeforeEach + void setUp() { + validator = new BasicAuthSecurityValidator(); + } + + @Test + @DisplayName("supports() -> True for BASIC_AUTH only") + void shouldReturnTrueWhenTypeIsBasicAuth() { + assertThat(validator.supports(WebhookSecurityType.BASIC_AUTH)).isTrue(); + assertThat(validator.supports(WebhookSecurityType.HMAC_SHA256)).isFalse(); + assertThat(validator.supports(WebhookSecurityType.STATIC_TOKEN)).isFalse(); + assertThat(validator.supports(WebhookSecurityType.JWT_BEARER)).isFalse(); + } + +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java new file mode 100644 index 00000000..42b631f7 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java @@ -0,0 +1,74 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; + +@DisplayName("HmacSignatureValidator Tests") +class HmacSignatureValidatorTest { + + private static final String SECRET = "super-secret-key"; + private static final String BODY = "{\"action\":\"closed\"}"; + + private HmacSignatureValidator validator; + + @BeforeEach + void setUp() { + validator = new HmacSignatureValidator(); + } + + @Test + @DisplayName("Should compute a valid hex-encoded HMAC-SHA256 digest") + void shouldComputeValidHmacSha256() throws Exception { + var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + var expected = HexFormat.of().formatHex(mac.doFinal(BODY.getBytes(StandardCharsets.UTF_8))); + + var actual = validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), SECRET); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("Should produce different digests for different payloads") + void shouldProduceDifferentDigestsForDifferentPayloads() { + var digest1 = validator.computeHexSha256("payload1".getBytes(StandardCharsets.UTF_8), SECRET); + var digest2 = validator.computeHexSha256("payload2".getBytes(StandardCharsets.UTF_8), SECRET); + + assertThat(digest1).isNotEqualTo(digest2); + } + + @Test + @DisplayName("Should produce different digests for different secrets") + void shouldProduceDifferentDigestsForDifferentSecrets() { + assertThat(validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), "secret-a")) + .isNotEqualTo( + validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), "secret-b")); + } + + @Test + @DisplayName("Should throw WebhookAuthenticationException on internal crypto error") + void shouldThrowOnCryptoError() { + assertThat(validator.computeHexSha256(new byte[0], SECRET)).isNotBlank(); + } + + @Test + @DisplayName("Should throw WebhookAuthenticationException when secret is empty string") + void shouldThrowWhenSecretIsEmpty() { + byte[] bodyBytes = BODY.getBytes(StandardCharsets.UTF_8); + assertThatThrownBy(() -> validator.computeHexSha256(bodyBytes, "")) + .isInstanceOf(WebhookAuthenticationException.class) + .hasMessageContaining("Unable to compute HMAC signature"); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java new file mode 100644 index 00000000..fe7f7d47 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java @@ -0,0 +1,45 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +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.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +@DisplayName("JwtBearerSecurityValidator Tests") +@ExtendWith(MockitoExtension.class) +class JwtBearerSecurityValidatorTest { + + private final JwtBearerSecurityValidator validator = new JwtBearerSecurityValidator(); + + @Test + @DisplayName("Should support JWT_BEARER only") + void shouldSupportJwtBearer() { + assertThat(validator.supports(WebhookSecurityType.JWT_BEARER)).isTrue(); + assertThat(validator.supports(WebhookSecurityType.STATIC_TOKEN)).isFalse(); + assertThat(validator.supports(WebhookSecurityType.HMAC_SHA256)).isFalse(); + assertThat(validator.supports(WebhookSecurityType.BASIC_AUTH)).isFalse(); + } + + @Nested + @DisplayName("validateConfiguration — missing config keys") + class MissingConfigKeys { + + @Test + @DisplayName("Should throw when jwks_uri is missing from config") + void shouldThrowWhenJwksUriMissing() { + Map config = Map.of("other_key", "value"); + + assertThatThrownBy(() -> validator.validateConfiguration(config)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("jwks_uri"); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java new file mode 100644 index 00000000..33106e4b --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java @@ -0,0 +1,57 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +@DisplayName("StaticTokenSecurityValidator Tests") +class StaticTokenSecurityValidatorTest { + + private StaticTokenSecurityValidator validator; + + @BeforeEach + void setUp() { + validator = new StaticTokenSecurityValidator(); + } + + @Test + @DisplayName("Should support STATIC_TOKEN only") + void shouldSupportStaticToken() { + assertThat(validator.supports(WebhookSecurityType.STATIC_TOKEN)).isTrue(); + assertThat(validator.supports(WebhookSecurityType.HMAC_SHA256)).isFalse(); + assertThat(validator.supports(WebhookSecurityType.BASIC_AUTH)).isFalse(); + assertThat(validator.supports(WebhookSecurityType.JWT_BEARER)).isFalse(); + } + + @Nested + @DisplayName("validateConfiguration — missing config keys") + class MissingConfigKeys { + + @Test + @DisplayName("Should throw when header_name is missing from config") + void shouldThrowWhenHeaderNameMissing() { + Map config = Map.of("secret_alias", "MY_ALIAS"); + + assertThatThrownBy(() -> validator.validateConfiguration(config)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("header_name"); + } + + @Test + @DisplayName("Should throw when secret_alias is missing from config") + void shouldThrowWhenSecretAliasMissing() { + Map config = Map.of("header_name", "X-Token"); + assertThatThrownBy(() -> validator.validateConfiguration(config)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("secret_alias"); + } + } +} 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 6dd4f384..dd9de87e 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,5 +1,10 @@ -- Sample data for IDP Core domain models - Enhanced with 10 templates +-- Clear existing data (for repeatable migrations) +-- Order matters: respect foreign key constraints +DELETE FROM webhook_mapping_link; +DELETE FROM entity_dynamic_mapping; +DELETE FROM webhook_connector; -- Clear existing data (for repeatable migrations). -- Deletion order respects FK constraints: child tables first, then parents. DELETE FROM entity_properties; diff --git a/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql b/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql index ec996fba..bd93c9c8 100644 --- a/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql +++ b/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql @@ -11,67 +11,60 @@ -- 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) +-- +-- All statements use ON CONFLICT DO NOTHING to make this script idempotent: +-- safe to run via Flyway on startup AND via @Sql before the graph test class. -- ----------------------------------------------------------------------- -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES +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'); + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service') +ON CONFLICT DO NOTHING; -- Relations owned by graph-svc-a: "uses" → b, "monitors" → b -INSERT INTO relation (id, name, target_template_identifier) -VALUES +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'); + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service') +ON CONFLICT DO NOTHING; -- 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'); +INSERT INTO relation (id, name, target_template_identifier) VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service') +ON CONFLICT DO NOTHING; -- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) -VALUES +INSERT INTO relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) VALUES ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[uses]-> b ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c', 'aa000001-0000-0000-0000-000000000003'); -- b -[uses]-> c + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c', 'aa000001-0000-0000-0000-000000000003') -- b -[uses]-> c +ON CONFLICT DO NOTHING; -- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES +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 + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001') -- b owns "uses" relation +ON CONFLICT DO NOTHING; -- ----------------------------------------------------------------------- -- 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 +INSERT INTO property (id, name, value) VALUES ('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'); + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0') +ON CONFLICT DO NOTHING; -INSERT INTO entity_properties (entity_id, property_id) -VALUES +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 + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006') -- c.version +ON CONFLICT DO NOTHING; diff --git a/src/test/resources/db/test/R__4_insert_webhook_test_data.sql b/src/test/resources/db/test/R__4_insert_webhook_test_data.sql new file mode 100644 index 00000000..c367e2b5 --- /dev/null +++ b/src/test/resources/db/test/R__4_insert_webhook_test_data.sql @@ -0,0 +1,72 @@ +-- Test data for Webhook Connectors +-- Purpose: provide pre-configured webhook connectors for integration testing + +-- Clear existing data +DELETE +FROM webhook_mapping_link; +DELETE +FROM webhook_connector; +DELETE +FROM entity_dynamic_mapping; + +-- Webhook Connector 1: GitHub Connector (HMAC_SHA256) +INSERT INTO webhook_connector (id, identifier, name, description, enabled, security) +VALUES ('770e8400-e29b-41d4-a716-446655440001', + 'github-dora-connector', + 'GitHub Connector', + 'Receives events from GitHub with HMAC validation', + true, + '{ + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_SECRET", + "prefix": "sha256=" + } + }'::jsonb); + +-- Webhook Connector 2: Simple Token Connector (STATIC_TOKEN) +INSERT INTO webhook_connector (id, identifier, name, description, enabled, security) +VALUES ('770e8400-e29b-41d4-a716-446655440002', + 'token-connector', + 'Token Connector', + 'Simple connector with static token', + true, + '{ + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Auth-Token", + "secret_alias": "WEBHOOK_TOKEN" + } + }'::jsonb); + +-- Webhook Connector 3: Public Connector (NONE) +INSERT INTO webhook_connector (id, identifier, name, description, enabled, security) +VALUES ('770e8400-e29b-41d4-a716-446655440003', + 'public-connector', + 'Public Connector', + 'Open connector for testing', + true, + '{ + "type": "NONE", + "config": {} + }'::jsonb); + +-- Dynamic Mapping for GitHub Connector +INSERT INTO entity_dynamic_mapping (id, identifier, template_id, filter, name, description, entity_identifier, entity_name, properties, relations) +VALUES ('880e8400-e29b-41d4-a716-446655440001', + 'microservice-mapping', + '550e8400-e29b-41d4-a716-446655440071', + '.action == "pushed"', + 'Microservice Mapping', + 'Mapping for microservice entities based on GitHub push events', + '.repository.full_name', + '.repository.name', + '{"applicationName": ".repository.name", "programmingLanguage": ".repository.language"}', + '{}'); + +-- Webhook Template Mapping for GitHub Connector +INSERT INTO webhook_mapping_link (webhook_id, entity_mapping_id, jslt_filter) VALUES ( + '770e8400-e29b-41d4-a716-446655440001', + '880e8400-e29b-41d4-a716-446655440001', + '.action == "pushed"'); diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json new file mode 100644 index 00000000..07936182 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json @@ -0,0 +1,15 @@ +{ + "identifier": "github-dora-connector-test", + "name": "GitHub DORA Connector test", + "description": "Collects deployment events from GitHub", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "MY_WEBHOOK_SECRET", + "prefix": "sha256=" + } + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json new file mode 100644 index 00000000..5b76571f --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json @@ -0,0 +1,11 @@ +{ + "identifier": " ", + "name": "Blank Identifier", + "description": "Should fail", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "security": { + "type": "NONE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json new file mode 100644 index 00000000..cb9edf93 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json @@ -0,0 +1,10 @@ +{ + "name": "Missing Identifier", + "description": "Should fail", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "security": { + "type": "NONE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json new file mode 100644 index 00000000..b13bc729 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json @@ -0,0 +1,11 @@ +{ + "identifier": "invalid-jslt-connector", + "name": "Invalid JSLT Connector", + "description": "Should fail due to invalid JSLT filter", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "security": { + "type": "NONE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json new file mode 100644 index 00000000..49318bc9 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json @@ -0,0 +1,11 @@ +{ + "identifier": "invalid-security-type-connector", + "name": "Invalid Security Type", + "description": "Should fail", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "security": { + "type": "UNKNOWN_TYPE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json new file mode 100644 index 00000000..f4565fa1 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json @@ -0,0 +1,11 @@ +{ + "identifier": "no-mappings-connector", + "name": "No Mappings", + "description": "Should fail", + "enabled": true, + "mapping_identifiers": [], + "security": { + "type": "NONE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json new file mode 100644 index 00000000..c4a86924 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json @@ -0,0 +1,15 @@ +{ + "identifier": "github-dora-connector", + "name": "GitHub DORA Connector", + "description": "Collects deployment events from GitHub", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "MY_WEBHOOK_SECRET", + "prefix": "sha256=" + } + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json b/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json new file mode 100644 index 00000000..ee164c96 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json @@ -0,0 +1,13 @@ +{ + "name": "Updated Title", + "description": "Updated description", + "enabled": false, + "mapping_identifiers": ["microservice-mapping"], + "security": { + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Token", + "secret_alias": "MY_TOKEN" + } + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json b/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json new file mode 100644 index 00000000..e24d4c34 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json @@ -0,0 +1,10 @@ +{ + "name": "GitHub DORA Connector", + "description": "Duplicate title", + "enabled": true, + "mapping_identifiers": ["microservice-mapping"], + "security": { + "type": "NONE", + "config": {} + } +}