diff --git a/docs/src/concepts/index.md b/docs/src/concepts/index.md
index 1653c079..3d0de3d3 100644
--- a/docs/src/concepts/index.md
+++ b/docs/src/concepts/index.md
@@ -49,6 +49,11 @@ graph TB
Connections between entities forming a knowledge graph.
+- 🌐 **[Webhooks](webhooks.md)**
+
+ ---
+
+ Runtime-configurable connectors that authenticate external events and map payloads to your data model.
- 🔍 **[Filtering Entities](entity-filtering.md)**
---
@@ -130,3 +135,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..545ee873
--- /dev/null
+++ b/docs/src/concepts/webhooks.md
@@ -0,0 +1,216 @@
+---
+title: Webhooks
+description: Understand webhook connectors, security strategies, and dynamic mappings in IDP-Core
+---
+
+Webhooks let external systems push JSON events to IDP-Core 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 |
+| `mappings` | Array | One or more dynamic mapping rules |
+| `security` | Object | Authentication strategy and configuration |
+
+### Example
+
+```json
+{
+ "identifier": "github-repositories",
+ "title": "GitHub repositories",
+ "description": "Receives repository events from GitHub",
+ "enabled": true,
+ "mappings": [
+ {
+ "template": "github_repository",
+ "filter": ".action == \"created\" or .action == \"edited\"",
+ "entity": {
+ "identifier": ".repository.full_name | gsub(\"/\"; \"_\")",
+ "title": ".repository.name",
+ "properties": {
+ "name": ".repository.name",
+ "url": ".repository.html_url",
+ "language": ".repository.language // \"Unknown\""
+ },
+ "relations": {
+ "owner": ".repository.owner.login"
+ }
+ }
+ }
+ ],
+ "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 | Description |
+| --- | --- |
+| `template` | Target Entity Template identifier |
+| `filter` | Expression that decides whether the mapping applies |
+| `entity.identifier` | Expression that generates the entity identifier |
+| `entity.title` | Expression that generates the entity title |
+| `entity.properties` | Map of template property names to extraction expressions |
+| `entity.relations` | Map of template relation names to extraction expressions |
+
+### Validation Rules
+
+When you create or update a connector, IDP-Core 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..b2a5d612 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/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/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 02fc200b..ddbfe71b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -234,6 +234,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..c17fdcbd 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_WEBHOOK = "Cannot delete template because it is currently mapped to '%s' webhook connectors. Please remove the associated webhook 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'";
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyExistsException.java
new file mode 100644
index 00000000..ce428598
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyExistsException.java
@@ -0,0 +1,10 @@
+package com.decathlon.idp_core.domain.exception.entity_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_mapping/EntityDynamicMappingAlreadyInUseException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyInUseException.java
new file mode 100644
index 00000000..bd8d3d50
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyInUseException.java
@@ -0,0 +1,12 @@
+package com.decathlon.idp_core.domain.exception.entity_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_mapping/EntityDynamicMappingConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingConfigurationException.java
new file mode 100644
index 00000000..faa75bf6
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingConfigurationException.java
@@ -0,0 +1,13 @@
+package com.decathlon.idp_core.domain.exception.entity_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_mapping/EntityDynamicMappingNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingNotFoundException.java
new file mode 100644
index 00000000..c14206fd
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingNotFoundException.java
@@ -0,0 +1,10 @@
+package com.decathlon.idp_core.domain.exception.entity_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/EntityTemplateInUseByWebhookMappingException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateInUseByWebhookMappingException.java
new file mode 100644
index 00000000..6f675865
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateInUseByWebhookMappingException.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 EntityTemplateInUseByWebhookMappingException 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 EntityTemplateInUseByWebhookMappingException(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/exception/webhook/WebhookTemplateHasNoPropertiesException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookTemplateHasNoPropertiesException.java
new file mode 100644
index 00000000..e7b9d6dc
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookTemplateHasNoPropertiesException.java
@@ -0,0 +1,8 @@
+package com.decathlon.idp_core.domain.exception.webhook;
+
+public class WebhookTemplateHasNoPropertiesException extends RuntimeException {
+
+ public WebhookTemplateHasNoPropertiesException(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..ce974fe8
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java
@@ -0,0 +1,51 @@
+package com.decathlon.idp_core.domain.model.entity_mapping;
+
+import java.util.Map;
+import java.util.UUID;
+
+import com.decathlon.idp_core.domain.exception.entity_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 templateIdentifier,
+ String filter, String name, String description, String entityIdentifier, String entityTitle,
+ Map properties, Map relations) {
+
+ public EntityDynamicMapping {
+ if (isBlank(identifier)) {
+ throw new EntityDynamicMappingConfigurationException(
+ "Entity dynamic mapping identifier cannot be empty");
+ }
+ if (isBlank(name)) {
+ throw new EntityDynamicMappingConfigurationException(
+ "Entity dynamic mapping name cannot be empty");
+ }
+ if (isBlank(templateIdentifier)) {
+ throw new EntityDynamicMappingConfigurationException("Template identifier cannot be empty");
+ }
+
+ if (isBlank(filter)) {
+ throw new EntityDynamicMappingConfigurationException("Filter cannot be empty");
+ }
+
+ if (isBlank(entityIdentifier)) {
+ throw new EntityDynamicMappingConfigurationException("EntityIdentifier cannot be empty");
+ }
+
+ if (isBlank(entityTitle)) {
+ throw new EntityDynamicMappingConfigurationException("EntityTitle cannot be empty");
+ }
+
+ 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..d50ccaff
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java
@@ -0,0 +1,43 @@
+package com.decathlon.idp_core.domain.model.inbound_connectors.webhook;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_NAME_MAX_SIZE;
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY;
+
+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 title, 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 security type is mandatory");
+ }
+ if (mappings.isEmpty()) {
+ enabled = false;
+ }
+
+ if (isBlank(identifier)) {
+ throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY);
+ }
+ if (identifier.length() > 255) {
+ throw new WebhookConnectorConfigurationException("Webhook title is too long");
+ }
+
+ if (isBlank(title)) {
+ throw new WebhookConnectorConfigurationException("Webhook title is mandatory");
+ }
+ if (title.length() > 255) {
+ throw new WebhookConnectorConfigurationException(TEMPLATE_NAME_MAX_SIZE);
+ }
+ }
+
+ 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..706c9f09
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java
@@ -0,0 +1,20 @@
+package com.decathlon.idp_core.domain.model.inbound_connectors.webhook;
+
+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 security type is mandatory");
+ }
+ if (config == null) {
+ throw new WebhookSecurityConfigurationException(
+ "Webhook security config section is 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..dffff6f8
--- /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 template.
+///
+/// Per the webhook_template_mapping schema:
+/// - Links a webhook connector to an entity template 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 template
+/// @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..8a46efaf
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java
@@ -0,0 +1,27 @@
+package com.decathlon.idp_core.domain.port;
+
+import java.util.List;
+import java.util.Optional;
+
+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 findByTemplateIdentifier(String templateIdentifier);
+
+ Boolean existsByTemplateIdentifier(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/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/WebhookSecurityStrategy.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java
new file mode 100644
index 00000000..3e151508
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java
@@ -0,0 +1,29 @@
+package com.decathlon.idp_core.domain.port;
+
+import java.util.Map;
+
+/// 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 (e.g., "BASIC_AUTH",
+ /// "HMAC_SHA256")
+ /// @return true if this strategy handles this security type
+ boolean supports(String 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/port/WebhookTemplateMappingPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.java
new file mode 100644
index 00000000..7536578f
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.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 WebhookTemplateMappingPort {
+
+ List findByTemplateId(UUID templateId);
+ boolean existsByEntityMappingId(UUID id);
+ List findByEntityMappingId(UUID id);
+
+}
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..436d0fd6 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,22 +1,18 @@
package com.decathlon.idp_core.domain.service.entity_template;
+import java.util.List;
+import java.util.Map;
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.exception.webhook.WebhookTemplateHasNoPropertiesException;
import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition;
+import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition;
import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort;
+import com.decathlon.idp_core.domain.service.webhook.WebhookTemplateMappingService;
import lombok.RequiredArgsConstructor;
@@ -34,6 +30,7 @@ public class EntityTemplateValidationService {
private final EntityTemplateRepositoryPort entityTemplateRepositoryPort;
private final PropertyDefinitionValidationService propertyDefinitionValidationService;
private final RelationDefinitionValidationService relationDefinitionValidationService;
+ private final WebhookTemplateMappingService webhookTemplateMappingService;
/// Validates all business rules before creating a new entity template.
///
@@ -139,6 +136,9 @@ public void validateForDeletion(String identifier) {
throw new EntityTemplateNotFoundException("identifier", "null");
}
validateTemplateExists(identifier);
+ EntityTemplate template = entityTemplateRepositoryPort.findByIdentifier(identifier)
+ .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", identifier));
+ webhookTemplateMappingService.validateTemplateNotInUseMapping(template.id());
}
/// Checks that the entity template exists.
@@ -191,6 +191,29 @@ private void validateTemplateProperties(EntityTemplate entityTemplate) {
}
}
+ public void validatePropertiesExistInTemplate(Map mappingProperties,
+ List templateProperties) {
+
+ if (!mappingProperties.isEmpty() && templateProperties.isEmpty()) {
+ throw new WebhookTemplateHasNoPropertiesException(
+ "The mapping defines properties but the target template has no property definitions");
+ }
+
+ mappingProperties.keySet()
+ .forEach(propertyName -> validatePropertyNameAlreadyExistInTemplate(templateProperties,
+ propertyName));
+ }
+
+ public void validateRelationNameAlreadyExistInTemplate(
+ Map webhookMappingRelations, EntityTemplate entityTemplate) {
+ if (webhookMappingRelations == null || webhookMappingRelations.isEmpty()) {
+ return;
+ }
+ webhookMappingRelations.keySet()
+ .forEach(relationName -> validateRelationNameAlreadyExistInTemplate(
+ entityTemplate.relationsDefinitions(), relationName));
+ }
+
/// Validates all relation definitions within the template for structural and
/// referential integrity.
///
@@ -213,4 +236,32 @@ private void validateTemplateRelations(EntityTemplate entityTemplate) {
.validateTargetTemplatesExist(entityTemplate.relationsDefinitions());
}
+ public void validatePropertyNameAlreadyExistInTemplate(List properties,
+ String propertyName) {
+ if (!isPropertyNameIsOwnedByEntityTemplate(properties, propertyName)) {
+ throw new PropertyNameNotFoundEntityTemplatePropertiesException(
+ String.format("Property name %s not found in entity template properties", propertyName));
+ }
+ }
+
+ public void validateRelationNameAlreadyExistInTemplate(List relations,
+ String relationName) {
+ if (!isRelationIsOwnedByEntityTemplate(relations, relationName)) {
+ throw new RelationNameNotFoundEntityTemplateRelationsException(
+ String.format("Relation name %s not found in entity template relations", relationName));
+ }
+ }
+
+ private boolean isPropertyNameIsOwnedByEntityTemplate(List properties,
+ String propertyName) {
+ return properties != null && properties.stream()
+ .anyMatch(entityTemplateProperty -> entityTemplateProperty.name().equals(propertyName));
+ }
+
+ private boolean isRelationIsOwnedByEntityTemplate(List relations,
+ String relationName) {
+ return relations != null && relations.stream()
+ .anyMatch(entityTemplateRelation -> entityTemplateRelation.name().equals(relationName));
+ }
+
}
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/webhook/DynamicMappingService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingService.java
new file mode 100644
index 00000000..f4c42975
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingService.java
@@ -0,0 +1,111 @@
+package com.decathlon.idp_core.domain.service.webhook;
+
+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_mapping.EntityDynamicMappingAlreadyExistsException;
+import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingAlreadyInUseException;
+import com.decathlon.idp_core.domain.exception.entity_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.WebhookTemplateMappingPort;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Validated
+@RequiredArgsConstructor
+public class DynamicMappingService {
+
+ private final EntityDynamicMappingPort entityDynamicMappingPort;
+ private final WebhookTemplateMappingPort 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.templateIdentifier(),
+ entityDynamicMapping.filter(), entityDynamicMapping.name(),
+ entityDynamicMapping.description(), entityDynamicMapping.entityIdentifier(),
+ entityDynamicMapping.entityTitle(), entityDynamicMapping.properties(),
+ entityDynamicMapping.relations());
+
+ return entityDynamicMappingPort.save(mergedMapping);
+ }
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java
new file mode 100644
index 00000000..c1bb356b
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java
@@ -0,0 +1,104 @@
+package com.decathlon.idp_core.domain.service.webhook;
+
+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.exception.webhook.WebhookTemplateHasNoPropertiesException;
+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.port.EntityDynamicMapperValidator;
+import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService;
+import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService;
+
+import lombok.RequiredArgsConstructor;
+
+/// Validates webhook dynamic mappings against their target entity template.
+/// This service ensures the mapping references an existing template, that all
+/// mapped properties and relations exist in that template, and that required
+/// template elements are provided before the mapping is accepted.
+@Service
+@Validated
+@RequiredArgsConstructor
+public class EntityDynamicMappingValidationService {
+ private final EntityTemplateService entityTemplateService;
+ private final EntityDynamicMapperValidator entityDynamicMapperValidator;
+ private final EntityTemplateValidationService entityTemplateValidationService;
+
+ /// Validates all mappings attached to a webhook connector.
+ ///
+ /// @param mappings the mappings to validate
+ /// @throws WebhookTemplateHasNoPropertiesException when one or more mappings
+ /// are invalid
+ public void validateWebhookMapping(List mappings) {
+ mappings.forEach(this::validateMapping);
+ }
+
+ /// Validates a single [EntityDynamicMapping]:
+ /// - The referenced EntityTemplate must exist.
+ /// - Each key in `properties` must match a property defined in the template.
+ /// - Required properties and relations from the target template are present.
+ /// - The mapping expression syntax is valid.
+ ///
+ /// @param entityDynamicMapping the mapping to validate
+ protected void validateMapping(EntityDynamicMapping entityDynamicMapping) {
+ String templateIdentifier = entityDynamicMapping.templateIdentifier();
+ entityTemplateValidationService.validateTemplateExists(templateIdentifier);
+ EntityTemplate entityTemplate = entityTemplateService
+ .getEntityTemplateByIdentifier(templateIdentifier);
+ entityTemplateValidationService.validatePropertiesExistInTemplate(
+ entityDynamicMapping.properties(), entityTemplate.propertiesDefinitions());
+ validateRequiredPropertiesAreMapped(entityDynamicMapping.properties(),
+ entityTemplate.propertiesDefinitions());
+ entityTemplateValidationService.validateRelationNameAlreadyExistInTemplate(
+ entityDynamicMapping.relations(), entityTemplate);
+ validateRequiredRelationDefinitionsAreMapped(entityDynamicMapping.relations(),
+ entityTemplate.relationsDefinitions());
+ entityDynamicMapperValidator.validate(entityDynamicMapping);
+ }
+
+ /// Validates that all required relation definitions in the target template
+ /// are provided by the mapping.
+ ///
+ /// @param mappingRelations relations declared by the mapping
+ /// @param templateRelations relation definitions declared by the template
+ /// @throws WebhookTemplateHasNoPropertiesException when one or more required
+ /// relations are missing in the mapping
+ private void validateRequiredRelationDefinitionsAreMapped(Map mappingRelations,
+ List templateRelations) {
+ List missingRelations = templateRelations.stream().filter(RelationDefinition::required)
+ .map(RelationDefinition::name).filter(requiredRelation -> mappingRelations == null
+ || !mappingRelations.containsKey(requiredRelation))
+ .toList();
+ if (!missingRelations.isEmpty()) {
+ throw new WebhookTemplateHasNoPropertiesException(
+ String.format("The mapping is missing required template relations: %s",
+ String.join(", ", missingRelations)));
+ }
+ }
+
+ /// Validates that all required property definitions in the target template
+ /// are provided by the mapping.
+ ///
+ /// @param mappingProperties properties declared by the mapping
+ /// @param templateProperties property definitions declared by the template
+ /// @throws WebhookTemplateHasNoPropertiesException when one or more required
+ /// properties are missing in the mapping
+ private void validateRequiredPropertiesAreMapped(Map mappingProperties,
+ List templateProperties) {
+ List missingProperties = templateProperties.stream()
+ .filter(PropertyDefinition::required).map(PropertyDefinition::name)
+ .filter(requiredName -> !mappingProperties.containsKey(requiredName)).toList();
+
+ if (!missingProperties.isEmpty()) {
+ throw new WebhookTemplateHasNoPropertiesException(
+ String.format("The mapping is missing required template properties: %s",
+ String.join(", ", missingProperties)));
+ }
+
+ }
+}
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..31a07565
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java
@@ -0,0 +1,90 @@
+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_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;
+
+ /// Resolves a list of entity dynamic mapping identifiers into their existing
+ /// domain models.
+ ///
+ /// Each identifier is validated against the persisted dynamic mappings. This
+ /// guarantees a webhook connector can only reference mappings that were
+ /// previously created through the `/api/v1/entity-dynamic-mappings` endpoint.
+ ///
+ /// @param mappingIdentifiers the referenced mapping identifiers (may be null or
+ /// empty)
+ /// @return the resolved mappings, in the same order as the provided identifiers
+ /// @throws EntityDynamicMappingNotFoundException when an identifier does not
+ /// match any existing mapping
+ 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);
+ return webhookConnectorRepositoryPort.save(connector);
+ }
+
+ @Transactional
+ public WebhookConnector updateWebhookConnector(String identifier,
+ @Valid WebhookConnector connectorToUpdate) {
+ WebhookConnector webhookConnectorInDb = getWebhookConnector(identifier);
+ webhookConnectorValidationService.validateWebhookConnectorForUpdate(webhookConnectorInDb,
+ connectorToUpdate);
+
+ WebhookConnector mergedConnector = new WebhookConnector(webhookConnectorInDb.id(),
+ webhookConnectorInDb.identifier(), connectorToUpdate.title(),
+ connectorToUpdate.description(), connectorToUpdate.enabled(), 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..a71890d9
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java
@@ -0,0 +1,77 @@
+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.exception.webhook.WebhookConnectorTitleAlreadyExistsException;
+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.port.WebhookTemplateMappingPort;
+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 WebhookTemplateMappingPort webhookTemplateMappingPort;
+ private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService;
+ private final WebhookSecurityValidationService webhookSecurityValidationService;
+
+ public void validateWebhookConnectorForCreation(WebhookConnector webhookConnector) {
+ validateIdentifierUniqueness(webhookConnector.identifier());
+ validateTitleUniqueness(webhookConnector.title());
+ webhookSecurityValidationService.validateForCreation(webhookConnector.security());
+
+ }
+
+ public void validateWebhookConnectorForUpdate(WebhookConnector existingConnector,
+ WebhookConnector webhookConnectorToUpdate) {
+ if (!existingConnector.title().equals(webhookConnectorToUpdate.title())) {
+ validateTitleUniqueness(webhookConnectorToUpdate.title());
+ }
+ validateMappingsIfPresent(webhookConnectorToUpdate);
+ webhookSecurityValidationService.validateForCreation(webhookConnectorToUpdate.security());
+ }
+
+ private void validateMappingsIfPresent(WebhookConnector webhookConnector) {
+ if (!webhookConnector.mappings().isEmpty()) {
+ webhookConnectorMappingValidationService.validateWebhookMapping(webhookConnector.mappings());
+ }
+ }
+
+ public void validateTitleUniqueness(String webhookTitle) {
+ if (webhookConnectorRepositoryPort.existsByTitle(webhookTitle)) {
+ throw new WebhookConnectorTitleAlreadyExistsException(webhookTitle);
+ }
+
+ }
+
+ /// 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/WebhookTemplateMappingService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookTemplateMappingService.java
new file mode 100644
index 00000000..5c22a9bc
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookTemplateMappingService.java
@@ -0,0 +1,51 @@
+package com.decathlon.idp_core.domain.service.webhook;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_MAPPED_WEBHOOK;
+
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateInUseByWebhookMappingException;
+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.WebhookTemplateMappingPort;
+
+import lombok.RequiredArgsConstructor;
+
+/// Domain service for webhook template mapping operations.
+///
+/// Validates template usage in webhook mappings before deletion.
+@Service
+@Validated
+@RequiredArgsConstructor
+public class WebhookTemplateMappingService {
+
+ private final WebhookTemplateMappingPort webhookTemplateMappingPort;
+
+ /// Retrieves all mappings for a given entity template.
+ ///
+ /// @param templateId template technical UUID
+ /// @return list of associated webhook template mappings
+ public List findByTemplateId(UUID templateId) {
+ return webhookTemplateMappingPort.findByTemplateId(templateId);
+ }
+
+ /// Validates that a template is not in use by any webhook mapping.
+ ///
+ /// @param entityTemplateId the entity template UUID to check
+ /// @throws EntityTemplateInUseByWebhookMappingException if template is already
+ /// in use
+ public void validateTemplateNotInUseMapping(UUID entityTemplateId) {
+ List mappings = findByTemplateId(entityTemplateId);
+ if (!mappings.isEmpty()) {
+ List webhookIds = mappings.stream().map(WebhookTemplateMapping::webhookConnector)
+ .filter(webhook -> webhook != null && webhook.id() != null)
+ .map(WebhookConnector::identifier).distinct().toList();
+ throw new EntityTemplateInUseByWebhookMappingException(
+ TEMPLATE_ALREADY_MAPPED_WEBHOOK.formatted(webhookIds));
+ }
+ }
+}
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..538ff3a2
--- /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().name())).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 117d88a8..0706dadb 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
@@ -40,9 +40,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";
@@ -66,11 +63,42 @@ 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";
+
/// API response description constants
public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully";
public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)";
@@ -96,6 +124,16 @@ 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_UPDATED = "Webhook connector updated successfully";
+ 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..ba18e075
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java
@@ -0,0 +1,113 @@
+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.webhook.DynamicMappingService;
+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.connector.DynamicMappingMapper;
+
+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 DynamicMappingMapper dynamicMappingMapper;
+ private final DynamicMappingService dynamicMappingService;
+
+ ///
+ ///
+ /// @param inboundWebhookMappingDtoIn entity dynamic mapping creation payload
+ /// @return created entity dynamic mapping response
+ @Operation(summary = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION)
+ @ApiResponse(responseCode = CREATED_CODE, description = "Entity dynamic mapping created")
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid request payload")
+ @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 deleteTemplate(@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 getWebhookConnectorByIdentifier(
+ @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/InboundWebhookManagementController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java
new file mode 100644
index 00000000..62e11cbe
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java
@@ -0,0 +1,114 @@
+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.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 InboundWebhookManagementController {
+
+ 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 = "201", description = "Webhook connector created")
+ @ApiResponse(responseCode = "400", description = "Invalid request payload")
+ @ApiResponse(responseCode = "409", description = "Identifier already exists")
+ @PostMapping
+ @ResponseStatus(CREATED)
+ public InboundWebhookDtoOut createInboundWebhook(
+ @Valid @RequestBody InboundWebhookCreateDtoIn request) {// remove jakarta
+ 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 getTemplatesPaginated(
+ @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 deleteTemplate(@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 title already exists", content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @PutMapping("/{identifier}")
+ @ResponseStatus(OK)
+ public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier,
+ @Valid @RequestBody InboundWebhookCreateDtoIn 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..c27b196e
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java
@@ -0,0 +1,23 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+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 is mandatory") String identifier,
+ @NotBlank(message = "Webhook mapping template is mandatory") String template,
+ @NotBlank(message = "Webhook mapping filter is mandatory") String filter,
+ @NotBlank(message = "Webhook title is mandatory") String name, String description,
+ @NotNull(message = "Webhook mapping entity section is mandatory") @Valid InboundWebhookEntityMappingDtoIn entity) {
+
+ /// Returns a CommonFields view for compatibility with the mapper.
+ public EntityDynamicMappingDtoInCommonFields commonFields() {
+ return new EntityDynamicMappingDtoInCommonFields(template, 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..803976c0
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java
@@ -0,0 +1,17 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+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 = "Webhook mapping template is mandatory") String template,
+ @NotBlank(message = "Webhook mapping filter is mandatory") String filter,
+ @NotBlank(message = "Webhook title is mandatory") String name, String description,
+ @NotNull(message = "Webhook mapping entity section is mandatory") @Valid InboundWebhookEntityMappingDtoIn 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..ad4bc375
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java
@@ -0,0 +1,22 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+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 = "Webhook mapping template is mandatory") String template,
+ @NotBlank(message = "Webhook mapping filter is mandatory") String filter,
+ @NotBlank(message = "Webhook title is mandatory") String name, String description,
+ @NotNull(message = "Webhook mapping entity section is mandatory") @Valid InboundWebhookEntityMappingDtoIn entity) {
+
+ /// Returns a CommonFields view for compatibility with the mapper.
+ public EntityDynamicMappingDtoInCommonFields commonFields() {
+ return new EntityDynamicMappingDtoInCommonFields(template, filter, name, description, entity);
+ }
+}
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..08d82b6b
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java
@@ -0,0 +1,29 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY;
+
+import java.util.List;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+
+/// Request payload used to create an inbound webhook connector configuration.
+///
+/// Mappings are no longer embedded in the connector payload. They are created
+/// independently through the `/api/v1/entity-dynamic-mappings` endpoint and
+/// referenced here by their identifiers. Each referenced mapping existence is
+/// validated in the domain layer before the connector is persisted.
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record InboundWebhookCreateDtoIn(
+ @NotBlank(message = WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY) String identifier,
+ @NotBlank(message = "Webhook title is mandatory") String title, 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/InboundWebhookEntityMappingDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java
new file mode 100644
index 00000000..80e29421
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java
@@ -0,0 +1,19 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import java.util.Map;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+/// Entity projection section for an inbound webhook mapping.
+public record InboundWebhookEntityMappingDtoIn(
+ @NotBlank(message = "Webhook entity identifier expression is mandatory") String identifier,
+ @NotBlank(message = "Webhook entity title expression is mandatory") String title,
+ @NotNull(message = "Webhook entity properties section is mandatory") Map properties,
+ @NotNull(message = "Webhook entity relations section is mandatory") Map relations) {
+
+ public InboundWebhookEntityMappingDtoIn {
+ 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/InboundWebhookSecurityContractDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java
new file mode 100644
index 00000000..17dc2816
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java
@@ -0,0 +1,16 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+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 security type is mandatory") String type,
+ @NotNull(message = "Webhook security config section is 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/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..9d938072
--- /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 template, String filter,
+ String name, String description, InboundWebhookEntityMappingDtoOut entity) {
+ /// Entity projection details exposed in webhook mapping responses.
+ public static 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/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..9d2e7903
--- /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 title, 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 e02c4858..0f5d5068 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,14 @@
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_mapping.EntityDynamicMappingAlreadyExistsException;
+import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingAlreadyInUseException;
+import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingConfigurationException;
+import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingNotFoundException;
+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;
@@ -64,19 +60,6 @@ public class ApiExceptionHandler {
private ApiExceptionHandler() {
}
- /// Handles domain exception when entity templates are not found.
- ///
- /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404
- /// status
- /// with business-meaningful error message for API consumers.
- @ExceptionHandler(EntityTemplateNotFoundException.class)
- public ResponseEntity handleTemplateNotFoundException(
- EntityTemplateNotFoundException ex) {
- log.warn("Template not found: {}", ex.getMessage());
- ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage());
- 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
@@ -309,6 +292,47 @@ 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());
+ String errorMessage = "Invalid webhook mapping configuration: " + ex.getMessage();
+ return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage);
+ }
+
+ @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(WebhookTemplateHasNoPropertiesException.class)
+ public ResponseEntity handleWebhookTemplateHasNoPropertiesException(
+ WebhookTemplateHasNoPropertiesException ex) {
+ log.warn("Webhook mapping invalid for template without properties: {}", 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
@@ -449,6 +473,115 @@ 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(EntityTemplateInUseByWebhookMappingException.class)
+ public ResponseEntity handleTemplateAlreadyMappedInWebhookConfiguration(
+ EntityTemplateInUseByWebhookMappingException ex) {
+ log.warn("Entity template 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 title duplication conflicts.
+ @ExceptionHandler(WebhookConnectorTitleAlreadyExistsException.class)
+ public ResponseEntity handleWebhookConnectorTitleAlreadyExistsException(
+ WebhookConnectorTitleAlreadyExistsException ex) {
+ log.warn("Webhook connector title conflict: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
+ /// Handles domain exception when entity templates are not found.
+ ///
+ /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404
+ /// status
+ /// with business-meaningful error message for API consumers.
+ @ExceptionHandler(EntityTemplateNotFoundException.class)
+ public ResponseEntity handleTemplateNotFoundException(
+ EntityTemplateNotFoundException ex) {
+ log.warn("Template not found: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage());
+ return ResponseEntity.status(NOT_FOUND).body(errorResponse);
+ }
+
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/DynamicMappingMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/DynamicMappingMapper.java
new file mode 100644
index 00000000..443cf872
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/DynamicMappingMapper.java
@@ -0,0 +1,65 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector;
+
+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.EntityDynamicMappingUpdateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut;
+
+@Component
+public class DynamicMappingMapper {
+
+ public EntityDynamicMapping toDomain(EntityDynamicMappingCreateDtoIn mapping) {
+ // Map each DTO field explicitly to its matching domain field. The
+ // EntityDynamicMapping
+ // constructor order is (id, identifier, templateIdentifier, filter,
+ // entityIdentifier,
+ // entityTitle, properties, relations); keeping this alignment prevents the
+ // template
+ // identifier and the filter expression from being swapped.
+ var fields = mapping.commonFields();
+ return new EntityDynamicMapping(null, // id (assigned by persistence layer)
+ mapping.identifier(), // identifier
+ fields.template(), // templateIdentifier
+ fields.filter(), // filter
+ fields.name(), // titre
+ fields.description(), fields.entity().identifier(), // entityIdentifier
+ fields.entity().title(), // entityTitle
+ safeMap(fields.entity().properties()), // properties
+ safeMap(fields.entity().relations())); // relations
+ }
+
+ public EntityDynamicMappingDtoOut fromEntityMappingToDto(EntityDynamicMapping mapping) {
+ return new EntityDynamicMappingDtoOut(mapping.identifier(), mapping.templateIdentifier(),
+ mapping.filter(), mapping.name(), mapping.description(),
+ new EntityDynamicMappingDtoOut.InboundWebhookEntityMappingDtoOut(mapping.entityIdentifier(),
+ mapping.entityTitle(), 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.template(), // templateIdentifier
+ fields.filter(), // filter
+ fields.name(), // titre
+ fields.description(), fields.entity().identifier(), // entityIdentifier
+ fields.entity().title(), // entityTitle
+ 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/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..219093ce
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java
@@ -0,0 +1,94 @@
+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.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.connector.DynamicMappingMapper;
+
+import lombok.AllArgsConstructor;
+
+/// Maps inbound webhook API DTOs to domain models and back.
+@Component
+@AllArgsConstructor
+public class InboundWebhookMapper {
+
+ private final DynamicMappingMapper 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.title(), 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, InboundWebhookCreateDtoIn dto,
+ List resolvedMappings) {
+ return new WebhookConnector(null, identifier, dto.title(), 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.title(), 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/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..50998d07
--- /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_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, "entityTitle", mapping.entityTitle());
+
+ 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..ddd93849
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java
@@ -0,0 +1,66 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_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;
+
+ @Override
+ public List findByTemplateIdentifier(String identifier) {
+ return jpaEntityDynamicMappingRepository.findByTemplateIdentifier(identifier).stream()
+ .map(entityDynamicMappingPersistenceMapper::toDomain).toList();
+ }
+
+ @Override
+ public Boolean existsByTemplateIdentifier(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) {
+ EntityDynamicMappingJpaEntity entityToPersist = entityDynamicMappingPersistenceMapper
+ .toJpa(entityDynamicMapping);
+ EntityDynamicMappingJpaEntity persistedEntity = jpaEntityDynamicMappingRepository
+ .save(entityToPersist);
+ return entityDynamicMappingPersistenceMapper.toDomain(persistedEntity);
+ }
+
+ @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/PostgresWebhookConnectorAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java
new file mode 100644
index 00000000..2abb2608
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java
@@ -0,0 +1,142 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence;
+
+import java.util.List;
+import java.util.Objects;
+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_mapping.EntityDynamicMappingNotFoundException;
+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.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.webhook.WebhookConnectorJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookTemplateMappingJpaEntity;
+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.JpaWebhookTemplateMappingRepository;
+
+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 JpaWebhookTemplateMappingRepository 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) {
+ return jpaWebhookConnectorRepository.findAll(pageable).map(this::loadConnectorWithMappings);
+ }
+
+ @Override
+ public boolean existsByIdentifier(String identifier) {
+ return jpaWebhookConnectorRepository.existsByIdentifier(identifier);
+ }
+
+ @Override
+ public boolean existsByTitle(String title) {
+ return jpaWebhookConnectorRepository.existsByTitle(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.title(),
+ connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings,
+ connectorWithoutMappings.security());
+ }
+
+ /// Loads all dynamic mappings associated with a webhook connector.
+ private List loadMappingsForWebhook(UUID webhookId) {
+ return jpaWebhookTemplateMappingRepository
+ .findByWebhookId(webhookId).stream().map(wtm -> jpaEntityDynamicMappingRepository
+ .findById(wtm.getEntityMappingId()).map(mappingMapper::toDomain).orElse(null))
+ .filter(Objects::nonNull).toList();
+ }
+
+ /// Persists the webhook's template 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 WebhookTemplateMappingJpaEntity persistAndCreateTemplateMapping(UUID webhookId,
+ EntityDynamicMapping mapping) {
+ EntityTemplate entityTemplate = entityTemplateRepositoryPort
+ .findByIdentifier(mapping.templateIdentifier()).orElseThrow(
+ () -> new EntityTemplateNotFoundException("identifier", mapping.templateIdentifier()));
+
+ EntityDynamicMapping entityDynamicMapping = entityDynamicMappingPort
+ .findByIdentifier(mapping.identifier())
+ .orElseThrow(() -> new EntityDynamicMappingNotFoundException(mapping.identifier()));
+
+ return WebhookTemplateMappingJpaEntity.builder().webhookId(webhookId)
+ .templateId(entityTemplate.id()).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..96d436a9
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java
@@ -0,0 +1,44 @@
+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.WebhookTemplateMappingPort;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookTemplateMappingPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookTemplateMappingRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/// Persistence adapter for webhook-template mapping read operations.
+@Component
+@RequiredArgsConstructor
+public class WebhookTemplateMappingAdaptor implements WebhookTemplateMappingPort {
+
+ private final JpaWebhookTemplateMappingRepository jpaWebhookTemplateMappingRepository;
+ private final WebhookTemplateMappingPersistenceMapper webhookTemplateMappingPersistenceMapper;
+
+ /// Finds mappings by template technical id.
+ ///
+ /// @param templateId entity template UUID
+ /// @return mapped domain associations
+ @Override
+ public List findByTemplateId(UUID templateId) {
+ return jpaWebhookTemplateMappingRepository.findByTemplateId(templateId).stream()
+ .map(webhookTemplateMappingPersistenceMapper::toDomain).toList();
+ }
+
+ @Override
+ public boolean existsByEntityMappingId(UUID id) {
+ return jpaWebhookTemplateMappingRepository.existsByEntityMappingId(id);
+ }
+
+ @Override
+ public List findByEntityMappingId(UUID id) {
+ return jpaWebhookTemplateMappingRepository.findByEntityMappingId(id).stream()
+ .map(webhookTemplateMappingPersistenceMapper::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..a61be6a4
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java
@@ -0,0 +1,27 @@
+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_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")
+ EntityDynamicMapping toDomain(EntityDynamicMappingJpaEntity jpa);
+
+ @Mapping(target = "properties", qualifiedByName = "mapToJsonString")
+ @Mapping(target = "relations", qualifiedByName = "mapToJsonString")
+ 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..a2549fc6
--- /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, title, 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/WebhookTemplateMappingPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookTemplateMappingPersistenceMapper.java
new file mode 100644
index 00000000..f3a2a582
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookTemplateMappingPersistenceMapper.java
@@ -0,0 +1,62 @@
+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.WebhookTemplateMappingJpaEntity;
+
+/// Persistence mapper for [WebhookTemplateMapping].
+///
+/// Maps the association entity between webhook connector, entity template 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 WebhookTemplateMappingPersistenceMapper {
+
+ /// Maps JPA association data to the domain model.
+ ///
+ /// @param jpa persisted association entity
+ /// @return mapped domain model
+ @Mapping(target = "id", source = "id")
+ @Mapping(target = "webhookConnector", source = "webhookConnector")
+ @Mapping(target = "entityTemplate", source = "entityTemplate")
+ @Mapping(target = "entityDynamicMapping", source = "entityMapping")
+ @Mapping(target = "jsltFilter", source = "jsltFilter")
+ WebhookTemplateMapping toDomain(WebhookTemplateMappingJpaEntity 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 = "id", source = "id")
+ @Mapping(target = "webhookId", source = "webhookConnector.id")
+ @Mapping(target = "templateId", source = "entityTemplate.id")
+ @Mapping(target = "entityMappingId", source = "entityDynamicMapping.id")
+ @Mapping(target = "jsltFilter", source = "jsltFilter")
+ @Mapping(target = "webhookConnector", ignore = true)
+ @Mapping(target = "entityTemplate", ignore = true)
+ @Mapping(target = "entityMapping", ignore = true)
+ WebhookTemplateMappingJpaEntity toJpa(WebhookTemplateMapping domain);
+
+ /// Builds a link row with explicit foreign keys.
+ ///
+ /// @param webhookId webhook connector technical id
+ /// @param templateId target entity template technical id
+ /// @param entityMappingId dynamic mapping technical id
+ /// @param jsltFilter JSLT filter expression
+ /// @return link entity ready for persistence
+ default WebhookTemplateMappingJpaEntity toJpa(UUID webhookId, UUID templateId,
+ UUID entityMappingId, String jsltFilter) {
+ return WebhookTemplateMappingJpaEntity.builder().webhookId(webhookId).templateId(templateId)
+ .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