diff --git a/docs/src/concepts/index.md b/docs/src/concepts/index.md
index 1653c079..f4c91d88 100644
--- a/docs/src/concepts/index.md
+++ b/docs/src/concepts/index.md
@@ -29,27 +29,31 @@ graph TB
---
- Blueprints that define the structure of your entitiesβlike database schemas defined at runtime.
+ Blueprints that define the structure of your entitiesβlike database schemas defined at runtime.
- ποΈ **[Entities](entities.md)**
---
- Instances of templates with actual dataβyour software catalog items.
+ Instances of templates with actual dataβyour software catalog items.
- π **[Properties](properties.md)**
---
- Data fields with types, validation rules, and constraints.
+ Data fields with types, validation rules, and constraints.
- π **[Relations](relations.md)**
---
- Connections between entities forming a knowledge graph.
+ Connections between entities forming a knowledge graph.
-- π **[Filtering Entities](entity-filtering.md)**
+- π **[Webhooks](webhooks.md)**
+
+ ---
+
+ Runtime-configurable connectors to push your data from external systems within your IDP. You can map any source in your data model in minutes!- π **[Filtering Entities](entity-filtering.md)**
---
@@ -105,7 +109,7 @@ erDiagram
## Quick Reference
| Concept | What It Is | Example |
-| ------------------- | --------------------------------------------------- | --------------------------------------------------------- |
+|---------------------|-----------------------------------------------------|-----------------------------------------------------------|
| **Entity Template** | Blueprint/schema | `service`, `team`, `repository` |
| **Entity** | Instance based on a template that contains the data | `payment-service`, `platform-team`, `idp-core-repository` |
| **Property** | Data field | `name`, `status`, `url` |
@@ -130,3 +134,4 @@ Dive deeper into each concept:
- **[Entity Templates](entity-templates.md)** - Learn how to design your data model
- **[Properties](properties.md)** - Understand property types and validation
- **[Relations](relations.md)** - Connect your entities into a graph
+- **[Webhooks](webhooks.md)** - Configure inbound integrations and security strategies
diff --git a/docs/src/concepts/webhooks.md b/docs/src/concepts/webhooks.md
new file mode 100644
index 00000000..e30ea8ed
--- /dev/null
+++ b/docs/src/concepts/webhooks.md
@@ -0,0 +1,226 @@
+---
+title: Webhooks
+description: Understand webhook connectors, security strategies, and dynamic mappings in IDP-Core
+---
+
+Webhooks let external systems push JSON events to the Internal Developer Platform through a generic HTTP endpoint. You configure a webhook connector at runtime, choose a security strategy, and define mappings that translate incoming payloads into entity data with JSLT expressions.
+
+## Overview
+
+A webhook connector combines three concerns:
+
+- **Connector metadata** - Identifier, title, description, and enabled flag
+- **Security** - How IDP-Core authenticates incoming requests
+- **Mappings** - How the payload maps to an Entity Template
+
+```mermaid
+flowchart LR
+ S[External system] --> E[POST /webhooks/{configurationId}]
+ E --> H[InboundWebhookHandler]
+ H --> D[Security dispatcher]
+ D --> C[WebhookConnector]
+ C --> M[Dynamic mappings]
+ M --> T[Entity Template]
+```
+
+## Webhook Connector
+
+A webhook connector is the runtime configuration stored by IDP-Core for one inbound integration.
+
+| Field | Type | Description |
+| --------------------- | ------- | ------------------------------------------------------ |
+| `identifier` | String | Stable key used in the webhook URL and management APIs |
+| `title` | String | Human-readable name |
+| `description` | String | Optional explanation of the connector purpose |
+| `enabled` | Boolean | Enables or disables request processing |
+| `mapping_identifiers` | Array | One or more dynamic mapping identifier |
+| `security` | Object | Authentication strategy and configuration |
+
+### Webhook Connector Example
+
+```json
+{
+ "identifier": "github-repositories",
+ "title": "GitHub repositories",
+ "description": "Receives repository events from GitHub",
+ "enabled": true,
+ "mapping_identifiers": [],
+ "security": {
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Hub-Signature-256",
+ "secret_alias": "GITHUB_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
+ }
+}
+```
+
+## Dynamic Mappings
+
+Each connector contains at least one dynamic mapping. A mapping targets one Entity Template and describes how to derive entity fields from the incoming JSON payload with a JSLT filter and entity projections.
+
+| Field | Type | Description |
+| ------------- | ------ | --------------------------------------------------------------------------- |
+| `template` | String | Identifier of the target Entity Template |
+| `identifier` | String | Stable and unique key for this specific mapping |
+| `name` | String | Human-readable name of the mapping |
+| `description` | String | Optional explanation of the mapping purpose |
+| `filter` | String | JSLT boolean expression to evaluate if the payload should be processed |
+| `entity` | Object | JSLT projections defining how to map the payload to the entity's attributes |
+
+### Dynamic Mapping Example
+
+```json
+{
+ "template": "github_repository",
+ "identifier": "mapping-github",
+ "name": "mapping github",
+ "description": "mapping github description",
+ "filter": ".repository != null",
+ "entity": {
+ "identifier": "replace(.repository.name, \" \", \"-\")",
+ "title": ".repository.name",
+ "properties": {
+ "name": ".repository.name",
+ "url": ".repository.html_url",
+ "stars": "\"\" + .repository.stargazers_count",
+ "is_public": "if (.repository.private) \"false\" else \"true\""
+ },
+ "relations": {}
+ }
+}
+```
+
+### Validation Rules
+
+When you create or update a connector, the IDP validates each mapping against the target Entity Template.
+
+It checks that:
+
+- The referenced template exists
+- Every mapped property exists in the template
+- Every required property is mapped
+- Every mapped relation exists in the template
+- Every required relation is mapped
+
+This validation keeps the connector configuration aligned with the current data model.
+
+## Security Strategies
+
+Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests again at runtime.
+
+| Type | Required configuration keys | Runtime behavior |
+| -------------- | --------------------------------------- | -------------------------------------------------------------------------------------- |
+| `HMAC_SHA256` | `header_name`, `secret_alias`, `prefix` | Computes the SHA-256 HMAC of the raw body and compares it with the request header |
+| `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment |
+| `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret |
+| `JWT_BEARER` | `jwks_uri` | Validates the bearer token against a JWKS endpoint |
+| `NONE` | none | Skips authentication |
+
+> [!IMPORTANT]
+> Security configuration keys accept `snake_case` and `camelCase` variants for the supported fields.
+> [!WARNING]
+> `secret_alias` must reference an environment variable alias in `UPPER_SNAKE_CASE`. It does not store the raw secret value in the connector configuration.
+
+### Example Security Configurations
+
+=== "HMAC_SHA256"
+
+```json
+{
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Hub-Signature-256",
+ "secret_alias": "GITHUB_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
+}
+```
+
+=== "STATIC_TOKEN"
+
+```json
+{
+ "type": "STATIC_TOKEN",
+ "config": {
+ "header_name": "X-Webhook-Token",
+ "secret_alias": "WEBHOOK_SHARED_TOKEN"
+ }
+}
+```
+
+=== "BASIC_AUTH"
+
+```json
+{
+ "type": "BASIC_AUTH",
+ "config": {
+ "username": "webhook-user",
+ "secret_alias": "WEBHOOK_PASSWORD"
+ }
+}
+```
+
+=== "JWT_BEARER"
+
+```json
+{
+ "type": "JWT_BEARER",
+ "config": {
+ "jwks_uri": "https://issuer.example.com/.well-known/jwks.json"
+ }
+}
+```
+
+## Runtime Flow
+
+The webhook runtime uses a single generic endpoint:
+
+```text
+POST /webhooks/{configurationId}
+```
+
+The request flow is:
+
+1. IDP-Core receives the request on the generic webhook endpoint.
+2. The `configurationId` resolves the stored `WebhookConnector`.
+3. If the connector is disabled, IDP-Core ignores the event.
+4. The security dispatcher selects the matching strategy for the connector security type.
+5. The strategy validates the headers and, when needed, the raw request body.
+6. After authentication, the event is accepted for downstream processing.
+
+> [!IMPORTANT]
+> The connector model, security validation, management APIs, and mapping validation are implemented now.
+
+## Management API Methods
+
+You manage webhook connectors through the inbound webhook management API, which exposes standard CRUD methods.
+
+| HTTP Method | Endpoint | Purpose |
+| ----------- | ----------------------------------------- | ---------------- |
+| `POST` | `/api/v1/inbound_webhooks` | Create connector |
+| `GET` | `/api/v1/inbound_webhooks` | List connectors |
+| `GET` | `/api/v1/inbound_webhooks/{identifier}` | Get connector |
+| `PUT` | `/api/v1/inbound_webhooks/{identifier}` | Update connector |
+| `DELETE` | `/api/v1/inbound_webhooks/{identifier}` | Delete connector |
+
+This separation keeps configuration management under versioned API routes while the event ingestion endpoint stays simple for external systems.
+
+## When to Use Webhooks
+
+Use webhooks when an external system can push JSON events over HTTP and you want to:
+
+- Ingest updates without redeploying IDP-Core
+- Reuse one generic endpoint for multiple providers
+- Apply connector-specific authentication rules
+- Map external payloads to your own Entity Templates at runtime
+
+---
+
+## Next Steps
+
+- **[Entity Templates](entity-templates.md)** - Define the target structures that mappings reference
+- **[Entities](entities.md)** - Understand the records produced by successful ingestion
+- **[Relations](relations.md)** - Model links that webhook mappings can populate
+- **[Data Integration](../features/data-integration.md)** - Explore the broader ingestion roadmap
diff --git a/docs/src/features/data-integration.md b/docs/src/features/data-integration.md
index 131b2c7d..4018fa2d 100644
--- a/docs/src/features/data-integration.md
+++ b/docs/src/features/data-integration.md
@@ -14,7 +14,7 @@ The Internal Developer Platform provides flexible data integration, allowing you
Data integration in the Internal Developer Platform follows a three-step pattern:
1. **Configure a connector** - Set up a Webhook, Kafka consumer, or Pub/Sub subscription
-2. **Define mappings** - Use JQ expressions to transform incoming data
+2. **Define mappings** - Use JSLT expressions to transform incoming data
3. **Ingest data** - Data flows automatically, creating and updating entities
```mermaid
@@ -55,6 +55,19 @@ flowchart LR
Webhooks allow external systems to push data to IDP-Core via HTTP POST requests.
+### Methods
+
+| Method | Endpoint | Purpose |
+| ------ | -------- | ------- |
+| `POST` | `/webhooks/{configurationId}` | Receive an inbound event for the connector identified in the URL |
+| `POST` | `/api/v1/inbound_webhooks` | Create a webhook connector configuration |
+| `GET` | `/api/v1/inbound_webhooks` | List webhook connector configurations |
+| `GET` | `/api/v1/inbound_webhooks/{identifier}` | Read one webhook connector configuration |
+| `PUT` | `/api/v1/inbound_webhooks/{identifier}` | Update one webhook connector configuration |
+| `DELETE` | `/api/v1/inbound_webhooks/{identifier}` | Delete one webhook connector configuration |
+
+ These HTTP routes map to the `InboundWebhookManagementController` methods for connector management.
+
### Webhook Configuration
```json
@@ -83,33 +96,37 @@ Webhooks allow external systems to push data to IDP-Core via HTTP POST requests.
}
],
"security": {
- "signature_header_name": "X-Sonar-Webhook-HMAC-SHA256",
- "signature_value": "your-secret-token"
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Sonar-Webhook-HMAC-SHA256",
+ "secret_alias": "SONAR_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
}
}
```
### Configuration Fields
-| Field | Description |
-| ------------- | ---------------------------- |
-| `identifier` | Unique key for this webhook |
-| `title` | Human-readable name |
-| `description` | Purpose of the webhook |
-| `enabled` | Toggle ingestion on/off |
-| `mappings` | Array of mapping rules |
-| `security` | Authentication configuration |
+| Field | Description |
+|---------------|-----------------------------------------------------------------|
+| `identifier` | Unique key for this webhook |
+| `title` | Human-readable name |
+| `description` | Purpose of the webhook |
+| `enabled` | Toggle ingestion on/off |
+| `mappings` | Array of mapping rules |
+| `security` | Authentication configuration using a `type` + `config` contract |
### Mapping Structure
-| Field | Description |
-| ------------------- | ------------------------------------------- |
-| `template` | Target Entity Template identifier |
-| `filter` | JQ expression to filter incoming payloads |
-| `entity.identifier` | JQ expression to generate entity identifier |
-| `entity.title` | JQ expression for entity title |
-| `entity.properties` | Map of property names to JQ expressions |
-| `entity.relations` | Map of relation names to JQ expressions |
+| Field | Description |
+|---------------------|-----------------------------------------------|
+| `template` | Target Entity Template identifier |
+| `filter` | JSLT expression to filter incoming payloads |
+| `entity.identifier` | JSLT expression to generate entity identifier |
+| `entity.title` | JSLT expression for entity title |
+| `entity.properties` | Map of property names to JSLT expressions |
+| `entity.relations` | Map of relation names to JSLT expressions |
---
@@ -165,9 +182,9 @@ spring:
---
-## JQ Mapping Reference
+## JSLT Mapping Reference
-The Internal Developer Platform will use [JQ](https://jqlang.github.io/jq/) for data transformation. It will access to the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Please refer to the JQ documentation for detailed usage.
+The Internal Developer Platform uses [JSLT](https://github.com/schibsted/jslt) for data transformation. It accesses the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Refer to the JSLT documentation for detailed usage.
---
@@ -201,8 +218,12 @@ Configure a webhook to receive GitHub repository events:
}
],
"security": {
- "signature_header_name": "X-Hub-Signature-256",
- "signature_value": "sha256=your-webhook-secret"
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Hub-Signature-256",
+ "secret_alias": "GITHUB_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
}
}
```
@@ -218,8 +239,11 @@ Webhooks support signature-based authentication:
```json
{
"security": {
- "signature_header_name": "X-Webhook-Signature",
- "signature_value": "expected-secret-or-hmac"
+ "type": "STATIC_TOKEN",
+ "config": {
+ "header_name": "X-Webhook-Signature",
+ "secret_alias": "WEBHOOK_SHARED_TOKEN"
+ }
}
}
```
diff --git a/docs/src/features/index.md b/docs/src/features/index.md
index 32deb947..42a7a559 100644
--- a/docs/src/features/index.md
+++ b/docs/src/features/index.md
@@ -13,7 +13,7 @@ The Internal Developer Platform provides a comprehensive set of features to buil
---
- Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JQ expressions.
+ Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JSLT expressions.
**Status:** π Planned
diff --git a/docs/src/features/self-service-actions.md b/docs/src/features/self-service-actions.md
index 5330bb6a..518a65ba 100644
--- a/docs/src/features/self-service-actions.md
+++ b/docs/src/features/self-service-actions.md
@@ -348,7 +348,7 @@ Use template expressions to inject dynamic values:
```bash
{{ .entity.identifier }} // Entity ID
-{{ .entity.title }} // Entity title
+{{ .entity.name }} // Entity name
{{ .entity.properties.owner }} // Entity property
{{ .entity.relations.team }} // Entity relation
```
diff --git a/docs/src/index.md b/docs/src/index.md
index 3e7bb9ff..e19bd660 100644
--- a/docs/src/index.md
+++ b/docs/src/index.md
@@ -131,7 +131,7 @@ Define your own **Entity Templates** that mirror your organization's specific ne
### Multi-Source Data Ingestion
-Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JQ expressions.
+Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JSLT expressions.
### Scorecards & Metrics
diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml
index e5f70058..53f7fea1 100644
--- a/docs/src/static/swagger.yaml
+++ b/docs/src/static/swagger.yaml
@@ -14,7 +14,7 @@ tags:
- name: Entities Management
description: Operations related to entity management
- name: Entities Templates Management
- description: Operations related to entity template management
+ description: Operations related to entity entityTemplateIdentifier management
paths:
'/api/v1/entity-templates/{identifier}':
get:
@@ -45,7 +45,7 @@ paths:
put:
tags:
- Entities Templates Management
- summary: Update an existing template by template identifier
+ summary: Update an existing entityTemplateIdentifier by entityTemplateIdentifier identifier
description: >-
Update the details of an existing template identified by its unique
string identifier
@@ -78,8 +78,8 @@ paths:
delete:
tags:
- Entities Templates Management
- summary: Delete template by identifier
- description: Remove a template from the system using its unique identifier
+ summary: Delete entityTemplateIdentifier by identifier
+ description: Remove a entityTemplateIdentifier from the system using its unique identifier
operationId: deleteTemplate
parameters:
- name: identifier
@@ -100,7 +100,7 @@ paths:
get:
tags:
- Entities Management
- summary: Get entity by entity template and identifier
+ summary: Get entity by entity entityTemplateIdentifier and identifier
description: >-
Retrieve a specific entity using its string identifier and its template
identifier
@@ -286,8 +286,8 @@ paths:
post:
tags:
- Entities Templates Management
- summary: Create a new template
- description: Create a new template in the system with the provided information
+ summary: Create a new entityTemplateIdentifier
+ description: Create a new entityTemplateIdentifier in the system with the provided information
operationId: createTemplate
requestBody:
content:
@@ -303,7 +303,7 @@ paths:
schema:
$ref: '#/components/schemas/EntityTemplateDtoOut'
'400':
- description: Invalid template data provided
+ description: Invalid entityTemplateIdentifier data provided
content:
'*/*':
schema:
@@ -312,7 +312,7 @@ paths:
get:
tags:
- Entities Management
- summary: Get entities by template identifier
+ summary: Get entities by entityTemplateIdentifier identifier
description: Retrieve a paginated list of entities with optional sorting
operationId: getEntities
parameters:
@@ -416,7 +416,7 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
- description: Entity already exists in this template
+ description: Entity already exists in this entityTemplateIdentifier
content:
'*/*':
schema:
@@ -550,7 +550,7 @@ components:
schemas:
EntityTemplateUpdateDtoIn:
type: object
- description: Input DTO for updating an entity template
+ description: Input DTO for updating an entity entityTemplateIdentifier
properties:
name:
type: string
@@ -562,15 +562,15 @@ components:
description:
type: string
description: Entity Template description
- example: A comprehensive service template
+ example: A comprehensive service entityTemplateIdentifier
properties_definitions:
type: array
- description: List of property definitions for this template
+ description: List of property definitions for this entityTemplateIdentifier
items:
$ref: '#/components/schemas/PropertyDefinitionDtoIn'
relations_definitions:
type: array
- description: List of relation definitions for this template
+ description: List of relation definitions for this entityTemplateIdentifier
items:
$ref: '#/components/schemas/RelationDefinitionDtoIn'
required:
@@ -663,7 +663,7 @@ components:
minLength: 1
target_template_identifier:
type: string
- description: Identifier of the target template
+ description: Identifier of the target entityTemplateIdentifier
example: service
minLength: 1
required:
@@ -681,7 +681,7 @@ components:
- target_template_identifier
EntityTemplateDtoOut:
type: object
- description: Output for entity template
+ description: Output for entity entityTemplateIdentifier
properties:
identifier:
type: string
@@ -694,15 +694,15 @@ components:
description:
type: string
description: Entity Template description
- example: A comprehensive service template
+ example: A comprehensive service entityTemplateIdentifier
properties_definitions:
type: array
- description: List of property definitions for this template
+ description: List of property definitions for this entityTemplateIdentifier
items:
$ref: '#/components/schemas/PropertyDefinitionDtoOut'
relations_definitions:
type: array
- description: List of relation definitions for this template
+ description: List of relation definitions for this entityTemplateIdentifier
items:
$ref: '#/components/schemas/RelationDefinitionDtoOut'
PropertyDefinitionDtoOut:
@@ -786,8 +786,8 @@ components:
example: dependencies
target_template_identifier:
type: string
- description: Identifier of the target template
- example: component-template
+ description: Identifier of the target entityTemplateIdentifier
+ example: component-entityTemplateIdentifier
required:
type: boolean
description: Whether this relation is required
@@ -833,7 +833,7 @@ components:
properties:
name:
type: string
- description: Name of the relation (must match a template relation definition)
+ description: Name of the relation (must match a entityTemplateIdentifier relation definition)
example: depends-on
minLength: 1
target_entity_identifiers:
@@ -880,7 +880,7 @@ components:
type: string
EntityTemplateCreateDtoIn:
type: object
- description: Input DTO for creating an entity template
+ description: Input DTO for creating an entity entityTemplateIdentifier
properties:
identifier:
type: string
@@ -897,15 +897,15 @@ components:
description:
type: string
description: Entity Template description
- example: A comprehensive service template
+ example: A comprehensive service entityTemplateIdentifier
properties_definitions:
type: array
- description: List of property definitions for this template
+ description: List of property definitions for this entityTemplateIdentifier
items:
$ref: '#/components/schemas/PropertyDefinitionDtoIn'
relations_definitions:
type: array
- description: List of relation definitions for this template
+ description: List of relation definitions for this entityTemplateIdentifier
items:
$ref: '#/components/schemas/RelationDefinitionDtoIn'
required:
@@ -917,7 +917,7 @@ components:
properties:
identifier:
type: string
- description: Unique identifier of the entity within the template scope
+ description: Unique identifier of the entity within the entityTemplateIdentifier scope
example: my-web-service
minLength: 1
name:
@@ -1001,7 +1001,7 @@ components:
Examples: template, identifier, name, relation, property.language,
relation.api-link, relation.api-link.identifier,
relations_as_target.api-link.name
- example: template
+ example: entityTemplateIdentifier
operation:
type: string
description: >-
@@ -1122,7 +1122,7 @@ components:
description: Node id of the target entity
type:
type: string
- description: Relation name as defined in the entity template
+ description: Relation name as defined in the entity entityTemplateIdentifier
EntityGraphFlatDtoOut:
type: object
properties:
@@ -1141,7 +1141,7 @@ components:
properties:
id:
type: string
- description: 'Unique node identifier composed of templateIdentifier:identifier'
+ description: 'Unique node identifier composed of entityTemplateIdentifier:identifier'
label:
type: string
description: Human-readable entity name
@@ -1150,7 +1150,7 @@ components:
description: Template identifier this entity belongs to
identifier:
type: string
- description: Business identifier of the entity within its template
+ description: Business identifier of the entity within its entityTemplateIdentifier
data:
type: object
additionalProperties: {}
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 2c775619..7f30c7fe 100644
--- a/pom.xml
+++ b/pom.xml
@@ -246,6 +246,14 @@
3.20.0
+
+
+
+ com.schibsted.spt.data
+ jslt
+ 0.1.14
+
+
org.springframework.boot
spring-boot-starter-actuator
diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java
index e32977de..daadfd16 100644
--- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java
+++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java
@@ -15,6 +15,7 @@ public class ValidationMessages {
public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank";
public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters";
public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores";
+ public static final String TEMPLATE_ALREADY_MAPPED = "Cannot delete template because it is currently mapped to '%s' entity dynamic mappings. Please remove the associated entity dynamic mappings before deleting the template.";
// Property Definition validation messages
public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank";
@@ -62,6 +63,15 @@ public class ValidationMessages {
public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank";
public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank";
+ // Webhook connector validation messages
+ public static final String WEBHOOK_CONNECTOR_ALREADY_EXIST = "Webhook Connector already exists with the same identifier";
+ public static final String WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY = "Webhook Connector identifier is mandatory and cannot be blank";
+ public static final String WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST = "Webhook Connector already exist with the same name";
+ public static final String WEBHOOK_IDENTIFIER_NOT_FOUND = "Target webhook with identifier '%s' does not exist";
+ public static final String ENTITY_DYNAMIC_MAPPING_NOT_FOUND = "Entity dynamic mapping with identifier '%s' does not exist";
+ public static final String ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS = "Entity dynamic mapping already exists with the same identifier '%s'";
+ public static final String ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE = "Entity dynamic mapping already in use, please remove it from the associated webhook connector '%s' before deleting it";
+
// Entity creation validation messages
public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'";
public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'";
@@ -114,4 +124,19 @@ public static String minMaxConstraintViolated(String constraint) {
public static final String SEARCH_NUMERIC_OPERATOR_INVALID_VALUE = "Value '%s' is not a valid number for operator '%s'";
public static final String SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH = "Property '%s' in template '%s' is of type %s; operators GT, GTE, LT, LTE require type NUMBER";
+ // Entity Dynamic mapping validation messages
+ public static final String ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY = "Webhook mapping filter is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY = "Entity dynamic mapping identifier is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY = "Entity dynamic mapping name is mandatory";
+ public static final String WEBHOOK_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY = "Webhook mapping entityTemplateIdentifier identifier is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY = "Webhook mapping entity section is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY = "Entity name is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory";
+
+ public static final String WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY = "Webhook security type is mandatory";
+ public static final String WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY = "Webhook security config is mandatory";
+ public static final String WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH = "Webhook identifier must not exceed 255 characters";
+ public static final String WEBHOOK_CONNECTOR_NAME_MAX_LENGTH = "Webhook name must not exceed 255 characters";
+ public static final String WEBHOOK_CONNECTOR_NAME_MANDATORY = "Webhook name is mandatory";
+
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java
new file mode 100644
index 00000000..3f37a79e
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java
@@ -0,0 +1,10 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS;
+
+public class EntityDynamicMappingAlreadyExistsException extends RuntimeException {
+
+ public EntityDynamicMappingAlreadyExistsException(String identifier) {
+ super(String.format(ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS, identifier));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java
new file mode 100644
index 00000000..63cfee53
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java
@@ -0,0 +1,12 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE;
+
+import java.util.List;
+
+public class EntityDynamicMappingAlreadyInUseException extends RuntimeException {
+
+ public EntityDynamicMappingAlreadyInUseException(List identifier) {
+ super(ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE.formatted(identifier));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java
new file mode 100644
index 00000000..512c3f56
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java
@@ -0,0 +1,13 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+public class EntityDynamicMappingConfigurationException extends RuntimeException {
+
+ public EntityDynamicMappingConfigurationException(String message) {
+ super(message);
+ }
+
+ public EntityDynamicMappingConfigurationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java
new file mode 100644
index 00000000..7b5dadbc
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java
@@ -0,0 +1,10 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_NOT_FOUND;
+
+public class EntityDynamicMappingNotFoundException extends RuntimeException {
+
+ public EntityDynamicMappingNotFoundException(String identifier) {
+ super(String.format(ENTITY_DYNAMIC_MAPPING_NOT_FOUND, identifier));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/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..aadf835d
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java
@@ -0,0 +1,55 @@
+package com.decathlon.idp_core.domain.model.entity_mapping;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import java.util.Map;
+import java.util.UUID;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException;
+
+/// Domain model representing dynamic entity mapping configuration.
+///
+/// Each mapping defines how to transform inbound webhook events into entity instances,
+/// including property/relation mappings and JSLT transformation rules.
+///
+/// Note: The technical ID is managed purely at the infrastructure layer
+/// (persisted in entity_dynamic_mapping table) and is NOT part of the domain model.
+public record EntityDynamicMapping(UUID id, String identifier, String entityTemplateIdentifier,
+ String filter, String name, String description, String entityIdentifier, String entityName,
+ Map properties, Map relations) {
+
+ public EntityDynamicMapping {
+ if (isBlank(identifier)) {
+ throw new EntityDynamicMappingConfigurationException(
+ ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY);
+ }
+ if (isBlank(name)) {
+ throw new EntityDynamicMappingConfigurationException(ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY);
+ }
+ if (isBlank(entityTemplateIdentifier)) {
+ throw new EntityDynamicMappingConfigurationException(
+ WEBHOOK_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY);
+ }
+
+ if (isBlank(filter)) {
+ throw new EntityDynamicMappingConfigurationException(ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY);
+ }
+
+ if (isBlank(entityIdentifier)) {
+ throw new EntityDynamicMappingConfigurationException(
+ ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY);
+ }
+
+ if (isBlank(entityName)) {
+ throw new EntityDynamicMappingConfigurationException(
+ ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY);
+ }
+
+ properties = properties == null ? Map.of() : Map.copyOf(properties);
+ relations = relations == null ? Map.of() : Map.copyOf(relations);
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java
new file mode 100644
index 00000000..992e7dc8
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java
@@ -0,0 +1,16 @@
+package com.decathlon.idp_core.domain.model.enums;
+
+/// Discriminator for the security validation strategy of a [WebhookConnector].
+///
+/// | Strategy | headerName | secretAlias | prefix | username | jwksUri |
+/// |--------------|------------|--------------------|----------|----------|---------|
+/// | HMAC_SHA256 | Required | Required (hash key)| Optional | β | β |
+/// | JWT_BEARER | β | β | β | β | Required|
+/// | STATIC_TOKEN | Required | Required (target) | β | β | β |
+/// | BASIC_AUTH | β | Required (password)| β | Required | β |
+/// | NONE | β | β | β | β | β |
+///
+/// `NONE` means the connector intentionally accepts unauthenticated requests.
+public enum WebhookSecurityType {
+ HMAC_SHA256, JWT_BEARER, STATIC_TOKEN, BASIC_AUTH, NONE
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java
new file mode 100644
index 00000000..828a9207
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java
@@ -0,0 +1,47 @@
+package com.decathlon.idp_core.domain.model.inbound_connectors.webhook;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import java.util.List;
+import java.util.UUID;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorConfigurationException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+
+public record WebhookConnector(UUID id, String identifier, String name, String description,
+ boolean enabled, List mappings, WebhookSecurity security) {
+ public WebhookConnector {
+ mappings = mappings == null ? List.of() : List.copyOf(mappings);
+
+ if (security == null) {
+ throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY);
+ }
+
+ if (isBlank(identifier)) {
+ throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY);
+ }
+ if (identifier.length() > 255) {
+ throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH);
+ }
+
+ if (isBlank(name)) {
+ throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_NAME_MANDATORY);
+ }
+ if (name.length() > 255) {
+ throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_NAME_MAX_LENGTH);
+ }
+ }
+
+ /// Creates a copy of this connector with the specified enabled state.
+ ///
+ /// @param enabled the new enabled state
+ /// @return a new WebhookConnector instance with the updated enabled field
+ public WebhookConnector withEnabled(boolean enabled) {
+ return new WebhookConnector(id, identifier, name, description, enabled, mappings, security);
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java
new file mode 100644
index 00000000..9aba1e97
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java
@@ -0,0 +1,22 @@
+package com.decathlon.idp_core.domain.model.inbound_connectors.webhook;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY;
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY;
+
+import java.util.Map;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+
+public record WebhookSecurity(WebhookSecurityType type, Map config) {
+
+ public WebhookSecurity {
+ if (type == null) {
+ throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY);
+ }
+ if (config == null) {
+ throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY);
+ }
+ config = Map.copyOf(config);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java
new file mode 100644
index 00000000..9f8ff75e
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java
@@ -0,0 +1,22 @@
+package com.decathlon.idp_core.domain.model.inbound_connectors.webhook;
+
+import java.util.UUID;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
+
+/// Domain model representing the mapping between a webhook event and an entity entityTemplateIdentifier.
+///
+/// Per the webhook_template_mapping schema:
+/// - Links a webhook connector to an entity entityTemplateIdentifier for event ingestion
+/// - Contains the JSLT filter to apply during transformation
+/// - Includes both technical IDs (from persistence) and functional domain objects
+///
+/// @param id technical identifier of the mapping record
+/// @param webhookConnector domain model of the associated webhook connector
+/// @param entityTemplate domain model of the target entity entityTemplateIdentifier
+/// @param entityDynamicMapping domain model of the dynamic mapping configuration
+/// @param jsltFilter JSLT filter expression for event ingestion
+public record WebhookTemplateMapping(UUID id, WebhookConnector webhookConnector,
+ EntityTemplate entityTemplate, EntityDynamicMapping entityDynamicMapping, String jsltFilter) {
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java
new file mode 100644
index 00000000..7c55a86f
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java
@@ -0,0 +1,8 @@
+package com.decathlon.idp_core.domain.port;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+
+public interface EntityDynamicMapperValidator {
+
+ void validate(EntityDynamicMapping mapping);
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java
new file mode 100644
index 00000000..1e19cbf8
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java
@@ -0,0 +1,29 @@
+package com.decathlon.idp_core.domain.port;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+
+public interface EntityDynamicMappingPort {
+
+ List findByTemplateIdentifier(String templateIdentifier);
+ List findByTemplateId(UUID templateIde);
+
+ 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/EntityTemplateRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java
index 5f4f9106..0ffcebc2 100644
--- a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java
+++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java
@@ -35,4 +35,6 @@ public interface EntityTemplateRepositoryPort {
EntityTemplate save(EntityTemplate entityTemplate);
void deleteByIdentifier(String identifier);
+
+ boolean existsById(UUID id);
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java
new file mode 100644
index 00000000..fba415ae
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java
@@ -0,0 +1,23 @@
+package com.decathlon.idp_core.domain.port;
+
+import java.util.Optional;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+
+public interface WebhookConnectorRepositoryPort {
+
+ Optional findByIdentifier(String identifier);
+
+ Page findAll(Pageable pageable);
+
+ boolean existsByIdentifier(String identifier);
+
+ boolean existsByTitle(String title);
+
+ WebhookConnector save(WebhookConnector connector);
+
+ void deleteByIdentifier(String identifier);
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java
new file mode 100644
index 00000000..afa3be65
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java
@@ -0,0 +1,14 @@
+package com.decathlon.idp_core.domain.port;
+
+import java.util.List;
+import java.util.UUID;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping;
+
+public interface WebhookMappingLinkPort {
+
+ boolean existsByEntityMappingId(UUID id);
+
+ List findByEntityMappingId(UUID id);
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java
new file mode 100644
index 00000000..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/service/entity_dynamic_mapping/DynamicMappingService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/DynamicMappingService.java
new file mode 100644
index 00000000..b988f7b9
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/DynamicMappingService.java
@@ -0,0 +1,111 @@
+package com.decathlon.idp_core.domain.service.entity_dynamic_mapping;
+
+import java.util.List;
+import java.util.UUID;
+
+import jakarta.transaction.Transactional;
+import jakarta.validation.Valid;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyExistsException;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
+import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Validated
+@RequiredArgsConstructor
+public class DynamicMappingService {
+
+ private final EntityDynamicMappingPort entityDynamicMappingPort;
+ private final WebhookMappingLinkPort webhookTemplateMappingPort;
+ private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService;
+
+ @Transactional
+ public EntityDynamicMapping createEntityDynamicMapping(
+ EntityDynamicMapping entityDynamicMapping) {
+ validateIdentifierUniqueness(entityDynamicMapping.identifier());
+ webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping);
+ return entityDynamicMappingPort.save(entityDynamicMapping);
+ }
+
+ public Page getAllEntityDynamicMapping(Pageable pageable) {
+ return entityDynamicMappingPort.findAll(pageable);
+ }
+
+ @Transactional
+ public void deleteEntityDynamicMapping(String entityDynamicMappingIdentifier) {
+ validateIdentifierExists(entityDynamicMappingIdentifier);
+ UUID dynamicMappingIdentifier = entityDynamicMappingPort
+ .findByIdentifier(entityDynamicMappingIdentifier).orElseThrow(
+ () -> new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier))
+ .id();
+ validateIsNotInUse(dynamicMappingIdentifier);
+ entityDynamicMappingPort.deleteByIdentifier(entityDynamicMappingIdentifier);
+ }
+
+ private void validateIsNotInUse(UUID entityDynamicMappingId) {
+ if (webhookTemplateMappingPort.existsByEntityMappingId(entityDynamicMappingId)) {
+ List webhookTemplateMappingList = webhookTemplateMappingPort
+ .findByEntityMappingId(entityDynamicMappingId);
+ List webhookIdentifiers = webhookTemplateMappingList.stream()
+ .map(WebhookTemplateMapping::webhookConnector)
+ .filter(webhook -> webhook != null && webhook.identifier() != null)
+ .map(WebhookConnector::identifier).distinct().toList();
+ throw new EntityDynamicMappingAlreadyInUseException(webhookIdentifiers);
+ }
+ }
+
+ private void validateIdentifierExists(String entityDynamicMappingIdentifier) {
+ if (!entityDynamicMappingPort.existsByIdentifier(entityDynamicMappingIdentifier)) {
+ throw new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier);
+ }
+ }
+
+ /// Ensures no other dynamic mapping already uses the provided identifier.
+ ///
+ /// This enforces the `entity_dynamic_mapping_identifier_key` unique constraint
+ /// at the domain level, returning a meaningful conflict instead of letting the
+ /// database raise a low-level integrity violation.
+ ///
+ /// @param identifier the candidate mapping identifier
+ /// @throws EntityDynamicMappingAlreadyExistsException when the identifier is
+ /// already used
+ private void validateIdentifierUniqueness(String identifier) {
+ if (entityDynamicMappingPort.existsByIdentifier(identifier)) {
+ throw new EntityDynamicMappingAlreadyExistsException(identifier);
+ }
+ }
+
+ public EntityDynamicMapping getEntityDynamicMapping(String identifier) {
+ return entityDynamicMappingPort.findByIdentifier(identifier)
+ .orElseThrow(() -> new EntityDynamicMappingNotFoundException(identifier));
+ }
+
+ @Transactional
+ public EntityDynamicMapping updateEntityDynamicMapping(String identifier,
+ @Valid EntityDynamicMapping entityDynamicMapping) {
+ EntityDynamicMapping existingMapping = getEntityDynamicMapping(identifier);
+ webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping);
+
+ EntityDynamicMapping mergedMapping = new EntityDynamicMapping(existingMapping.id(),
+ existingMapping.identifier(), entityDynamicMapping.entityTemplateIdentifier(),
+ entityDynamicMapping.filter(), entityDynamicMapping.name(),
+ entityDynamicMapping.description(), entityDynamicMapping.entityIdentifier(),
+ entityDynamicMapping.entityName(), entityDynamicMapping.properties(),
+ entityDynamicMapping.relations());
+
+ return entityDynamicMappingPort.save(mergedMapping);
+ }
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java
new file mode 100644
index 00000000..a3ec959e
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java
@@ -0,0 +1,120 @@
+package com.decathlon.idp_core.domain.service.entity_dynamic_mapping;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import com.decathlon.idp_core.domain.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 com.decathlon.idp_core.domain.service.property.PropertyValidationService;
+
+import lombok.RequiredArgsConstructor;
+
+/// Validates webhook dynamic mappings against their target entity entityTemplateIdentifier.
+/// This service ensures the mapping references an existing entityTemplateIdentifier, that all
+/// mapped properties and relations exist in that entityTemplateIdentifier, and that required
+/// entityTemplateIdentifier 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;
+ private final PropertyValidationService propertyValidationService;
+
+ /// 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
+ /// entityTemplateIdentifier.
+ /// - Required properties and relations from the target entityTemplateIdentifier
+ /// are present.
+ /// - The mapping expression syntax is valid.
+ ///
+ /// @param entityDynamicMapping the mapping to validate
+ public void validateMapping(EntityDynamicMapping entityDynamicMapping) {
+ String templateIdentifier = entityDynamicMapping.entityTemplateIdentifier();
+ EntityTemplate entityTemplate = entityTemplateService
+ .getEntityTemplateByIdentifier(templateIdentifier);
+
+ Map properties = entityDynamicMapping.properties() != null
+ ? entityDynamicMapping.properties()
+ : Collections.emptyMap();
+ Map relations = entityDynamicMapping.relations() != null
+ ? entityDynamicMapping.relations()
+ : Collections.emptyMap();
+
+ propertyValidationService.validateAgainstTemplate(entityTemplate,
+ List.copyOf(properties.keySet()));
+ validateRequiredPropertiesAreMapped(properties, entityTemplate.propertiesDefinitions());
+
+ entityTemplateValidationService.validateRelationNameAlreadyExistInTemplate(relations,
+ entityTemplate);
+ validateRequiredRelationDefinitionsAreMapped(relations, entityTemplate.relationsDefinitions());
+
+ entityDynamicMapperValidator.validate(entityDynamicMapping);
+ }
+
+ /// Validates that all required relation definitions in the target
+ /// entityTemplateIdentifier
+ /// are provided by the mapping.
+ ///
+ /// @param mappingRelations relations declared by the mapping
+ /// @param templateRelations relation definitions declared by the
+ /// entityTemplateIdentifier
+ /// @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 entityTemplateIdentifier relations: %s",
+ String.join(", ", missingRelations)));
+ }
+ }
+
+ /// Validates that all required property definitions in the target
+ /// entityTemplateIdentifier
+ /// are provided by the mapping.
+ ///
+ /// @param mappingProperties properties declared by the mapping
+ /// @param templateProperties property definitions declared by the
+ /// entityTemplateIdentifier
+ /// @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 entityTemplateIdentifier properties: %s",
+ String.join(", ", missingProperties)));
+ }
+
+ }
+}
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..42c5273c 100644
--- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java
+++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java
@@ -1,21 +1,19 @@
package com.decathlon.idp_core.domain.service.entity_template;
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_MAPPED;
+
+import java.util.List;
+import java.util.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.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.EntityDynamicMappingPort;
import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort;
import lombok.RequiredArgsConstructor;
@@ -34,6 +32,7 @@ public class EntityTemplateValidationService {
private final EntityTemplateRepositoryPort entityTemplateRepositoryPort;
private final PropertyDefinitionValidationService propertyDefinitionValidationService;
private final RelationDefinitionValidationService relationDefinitionValidationService;
+ private final EntityDynamicMappingPort entityDynamicMappingPort;
/// Validates all business rules before creating a new entity template.
///
@@ -139,6 +138,31 @@ public void validateForDeletion(String identifier) {
throw new EntityTemplateNotFoundException("identifier", "null");
}
validateTemplateExists(identifier);
+ validateTemplateNotInUseMapping(identifier);
+ }
+
+ /// Validates that the entity template is not currently referenced by any
+ /// dynamic mapping.
+ ///
+ /// Design Note: This check relies directly on the `EntityDynamicMappingPort`
+ /// rather than
+ /// injecting `DynamicMappingService`. This is a deliberate architectural choice
+ /// to prevent
+ /// a circular dependency between our domain services (TemplateValidation ->
+ /// DynamicMapping -> TemplateValidation).
+ /// It allows the template domain to enforce its own referential integrity
+ /// before deletion
+ /// while remaining loosely coupled.
+ public void validateTemplateNotInUseMapping(String entityTemplateIdentifier) {
+ EntityTemplate template = entityTemplateRepositoryPort
+ .findByIdentifier(entityTemplateIdentifier).orElseThrow(
+ () -> new EntityTemplateNotFoundException("identifier", entityTemplateIdentifier));
+ List mappings = entityDynamicMappingPort.findByTemplateId(template.id());
+ if (!mappings.isEmpty()) {
+ List webhookIds = mappings.stream().map(EntityDynamicMapping::identifier).toList();
+ throw new EntityTemplateInUseByWebhookMappingException(
+ TEMPLATE_ALREADY_MAPPED.formatted(webhookIds));
+ }
}
/// Checks that the entity template exists.
@@ -191,6 +215,16 @@ private void validateTemplateProperties(EntityTemplate entityTemplate) {
}
}
+ 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 +247,18 @@ private void validateTemplateRelations(EntityTemplate entityTemplate) {
.validateTargetTemplatesExist(entityTemplate.relationsDefinitions());
}
+ 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 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/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java
index f1e8ff44..0dbea7f9 100644
--- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java
+++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java
@@ -1,20 +1,12 @@
package com.decathlon.idp_core.domain.service.property;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_ENUM_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_FORMAT_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REGEX_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_MISMATCH;
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -22,6 +14,7 @@
import org.springframework.stereotype.Service;
import com.decathlon.idp_core.domain.exception.entity.EntityValidationException;
+import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException;
import com.decathlon.idp_core.domain.model.entity.Property;
import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition;
@@ -76,6 +69,7 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition,
/// provided and non-blank. If a required property is missing, adds a violation.
/// If the property is present, validates its value against the definition's
/// rules and accumulates any violations found.
+ ///
/// @param template the entity template whose property definitions are used for
/// validation
/// @param definitions the list of property definitions from the template
@@ -118,6 +112,35 @@ public void validatePropertiesAgainstTemplate(final EntityTemplate template,
}
}
+ /// Validates that every provided property name is declared in the given
+ /// template's property definitions.
+ ///
+ /// This is a **name-level** check (as opposed to
+ /// [#validatePropertiesAgainstTemplate], which validates concrete runtime
+ /// values). It is used, for example, by dynamic mapping validation to ensure a
+ /// mapping only references properties that actually exist in the target
+ /// template.
+ ///
+ /// @param template the target template whose property definitions are the
+ /// source of truth
+ /// @param propertyNames the property names to check against the template
+ /// @throws PropertyNameNotFoundEntityTemplatePropertiesException when a name is
+ /// not declared in the template
+ public void validateAgainstTemplate(EntityTemplate template, List propertyNames) {
+ if (propertyNames == null || propertyNames.isEmpty()) {
+ return;
+ }
+
+ Set definedPropertyNames = template.propertiesDefinitions().stream()
+ .map(PropertyDefinition::name).collect(Collectors.toSet());
+
+ propertyNames.stream().filter(name -> !definedPropertyNames.contains(name)).findFirst()
+ .ifPresent(name -> {
+ throw new PropertyNameNotFoundEntityTemplatePropertiesException(
+ String.format("Property name %s not found in entity template properties", name));
+ });
+ }
+
private List validateStringPropertyValue(String propertyName, Object rawValue,
PropertyRules rules) {
if (!(rawValue instanceof String stringValue)) {
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java
new file mode 100644
index 00000000..e83ebc1d
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java
@@ -0,0 +1,96 @@
+package com.decathlon.idp_core.domain.service.webhook;
+
+import java.util.List;
+
+import jakarta.transaction.Transactional;
+import jakarta.validation.Valid;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
+import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Validated
+@RequiredArgsConstructor
+public class WebhookConnectorService {
+
+ private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort;
+ private final WebhookConnectorValidationService webhookConnectorValidationService;
+ private final EntityDynamicMappingPort entityDynamicMappingPort;
+
+ /// 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);
+
+ WebhookConnector connectorToSave = connector.mappings().isEmpty() && connector.enabled()
+ ? connector.withEnabled(false)
+ : connector;
+
+ return webhookConnectorRepositoryPort.save(connectorToSave);
+ }
+
+ @Transactional
+ public WebhookConnector updateWebhookConnector(String identifier,
+ @Valid WebhookConnector connectorToUpdate) {
+ WebhookConnector webhookConnectorInDb = getWebhookConnector(identifier);
+ webhookConnectorValidationService.validateWebhookConnectorForUpdate(webhookConnectorInDb,
+ connectorToUpdate);
+
+ boolean enabledValue = !connectorToUpdate.mappings().isEmpty() && connectorToUpdate.enabled();
+ WebhookConnector mergedConnector = new WebhookConnector(webhookConnectorInDb.id(),
+ webhookConnectorInDb.identifier(), connectorToUpdate.name(),
+ connectorToUpdate.description(), enabledValue, connectorToUpdate.mappings(),
+ connectorToUpdate.security());
+
+ return webhookConnectorRepositoryPort.save(mergedConnector);
+ }
+
+ @Transactional
+ public void deleteWebhookConnector(String webhookConnectorIdentifier) {
+ webhookConnectorValidationService.validateIdentifierExists(webhookConnectorIdentifier);
+ webhookConnectorRepositoryPort.deleteByIdentifier(webhookConnectorIdentifier);
+ }
+
+ public Page getAllWebhookConnector(Pageable pageable) {
+ return webhookConnectorRepositoryPort.findAll(pageable);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java
new file mode 100644
index 00000000..596a8419
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java
@@ -0,0 +1,78 @@
+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.WebhookMappingLinkPort;
+import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingValidationService;
+import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService;
+
+import lombok.RequiredArgsConstructor;
+
+/// Domain validation service for webhook connector lifecycle operations.
+/// It validates connector uniqueness rules and delegates mapping and security
+/// validation to dedicated domain services.
+@Service
+@Validated
+@RequiredArgsConstructor
+public class WebhookConnectorValidationService {
+
+ private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort;
+ private final WebhookMappingLinkPort webhookTemplateMappingPort;
+ private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService;
+ private final WebhookSecurityValidationService webhookSecurityValidationService;
+
+ public void validateWebhookConnectorForCreation(WebhookConnector webhookConnector) {
+ validateIdentifierUniqueness(webhookConnector.identifier());
+ validateTitleUniqueness(webhookConnector.name());
+ webhookSecurityValidationService.validateForCreation(webhookConnector.security());
+
+ }
+
+ public void validateWebhookConnectorForUpdate(WebhookConnector existingConnector,
+ WebhookConnector webhookConnectorToUpdate) {
+ if (!existingConnector.name().equals(webhookConnectorToUpdate.name())) {
+ validateTitleUniqueness(webhookConnectorToUpdate.name());
+ }
+ 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/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 20527874..b999979a 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java
@@ -42,9 +42,6 @@ public class SwaggerDescription {
public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates";
public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting";
- public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID";
- public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier";
-
public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier";
public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier";
@@ -68,11 +65,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)";
@@ -98,6 +126,21 @@ public class SwaggerDescription {
public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure";
public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights";
public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS = "Paginated webhook connector retrieved successfully";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_DELETED = "Webhook connector deleted successfully";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER = "Webhook connector not found with the provided identifier";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_FOUND = "Webhook connector found";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_CREATED = "Webhook connector created";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_UPDATED = "Webhook connector updated successfully";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_CONFLICT = "Webhook connector already exists in this entityTemplateIdentifier";
+ public static final String RESPONSE_INVALID_WEBHOOK_CONNECTOR_DATA = "Invalid webhook connector data provided";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_CREATED = "Entity dynamic mapping created";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_DATA = "Invalid entity dynamic mapping data provided";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS = "Paginated entity dynamic mapping retrieved successfully";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED = "Entity dynamic mapping deleted successfully";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER = "Entity dynamic mapping not found with the provided identifier";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND = "Entity dynamic mapping found";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED = "Entity dynamic mapping updated successfully";
// --- Schema (class) descriptions ---
public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template";
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java
new file mode 100644
index 00000000..7ca571d2
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java
@@ -0,0 +1,109 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.controller;
+
+import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*;
+import static org.springframework.http.HttpStatus.*;
+
+import jakarta.validation.Valid;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.web.bind.annotation.*;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.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.entity_dynamic_mapping.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;
+
+ @Operation(summary = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION)
+ @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_CREATED)
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DATA)
+ @ApiResponse(responseCode = CONFLICT_CODE, description = "Identifier already exists")
+ @PostMapping
+ @ResponseStatus(CREATED)
+ public EntityDynamicMappingDtoOut createDynamicMapping(
+ @Valid @RequestBody EntityDynamicMappingCreateDtoIn inboundWebhookMappingDtoIn) {
+ EntityDynamicMapping entityDynamicMapping = dynamicMappingService
+ .createEntityDynamicMapping(dynamicMappingMapper.toDomain(inboundWebhookMappingDtoIn));
+ return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping);
+ }
+
+ @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.EntityDynamicMappingPageResponse.class)))
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0")))
+ @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20")))
+ @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc")))
+ @GetMapping
+ @ResponseStatus(OK)
+ public Page getEntityDynamicMappingPaginated(
+ @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) {
+ return dynamicMappingService.getAllEntityDynamicMapping(pageable)
+ .map(dynamicMappingMapper::fromEntityMappingToDto);
+ }
+
+ @Operation(summary = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION)
+ @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED)
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ResponseStatus(NO_CONTENT)
+ @DeleteMapping("/{identifier}")
+ public void deleteEntityDynamicMapping(@PathVariable String identifier) {
+ dynamicMappingService.deleteEntityDynamicMapping(identifier);
+ }
+
+ @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND, content = {
+ @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))})
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @GetMapping("/{identifier}")
+ @ResponseStatus(OK)
+ public EntityDynamicMappingDtoOut getEntityDynamicMappingByIdentifier(
+ @PathVariable String identifier) {
+ EntityDynamicMapping entityDynamicMapping = dynamicMappingService
+ .getEntityDynamicMapping(identifier);
+ return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping);
+ }
+
+ @Operation(summary = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED, content = {
+ @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))})
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "", content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ApiResponse(responseCode = CONFLICT_CODE, description = "", content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @PutMapping("/{identifier}")
+ @ResponseStatus(OK)
+ public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable String identifier,
+ @Valid @RequestBody EntityDynamicMappingUpdateDtoIn entityDynamicMappingDtoIn) {
+ return dynamicMappingMapper
+ .fromEntityMappingToDto(dynamicMappingService.updateEntityDynamicMapping(identifier,
+ dynamicMappingMapper.toDomainForUpdate(identifier, entityDynamicMappingDtoIn)));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java
new file mode 100644
index 00000000..b98e591d
--- /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 = CREATED_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_CREATED)
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_WEBHOOK_CONNECTOR_DATA)
+ @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_CONFLICT)
+ @PostMapping
+ @ResponseStatus(CREATED)
+ public InboundWebhookDtoOut createInboundWebhook(
+ @Valid @RequestBody InboundWebhookCreateDtoIn request) {
+ WebhookConnector webhookConnector = webhookConnectorService
+ .createWebhookConnector(inboundWebhookMapper.toDomain(request,
+ webhookConnectorService.resolveAndValidateMappings(request.mappingIdentifiers())));
+ return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector);
+ }
+
+ @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.WebhookConnectorPageResponse.class)))
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0")))
+ @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20")))
+ @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc")))
+ @GetMapping
+ @ResponseStatus(OK)
+ public Page getWebhooksPaginated(
+ @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) {
+ return webhookConnectorService.getAllWebhookConnector(pageable)
+ .map(inboundWebhookMapper::fromWebhookConnectorToDto);
+ }
+
+ @Operation(summary = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION)
+ @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_DELETED)
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ResponseStatus(NO_CONTENT)
+ @DeleteMapping("/{identifier}")
+ public void deleteWebhookConnector(@PathVariable String identifier) {
+ webhookConnectorService.deleteWebhookConnector(identifier);
+ }
+
+ @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_FOUND, content = {
+ @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))})
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @GetMapping("/{identifier}")
+ @ResponseStatus(OK)
+ public InboundWebhookDtoOut getWebhookConnectorByIdentifier(@PathVariable String identifier) {
+ WebhookConnector webhookConnector = webhookConnectorService.getWebhookConnector(identifier);
+ return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector);
+ }
+
+ @Operation(summary = ENDPOINT_PUT_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_PUT_WEBHOOK_CONNECTOR_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_UPDATED, content = {
+ @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))})
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid request payload", content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ApiResponse(responseCode = CONFLICT_CODE, description = "Webhook connector name already exists", content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @PutMapping("/{identifier}")
+ @ResponseStatus(OK)
+ public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier,
+ @Valid @RequestBody 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..412fdc25
--- /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 static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+/// Mapping rule request for inbound webhook transformation.
+public record EntityDynamicMappingCreateDtoIn(
+
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY) String identifier,
+ @NotBlank(message = WEBHOOK_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description,
+ @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid InboundWebhookEntityMappingDtoIn entity) {
+
+ /// Returns a CommonFields view for compatibility with the mapper.
+ public EntityDynamicMappingDtoInCommonFields commonFields() {
+ return new EntityDynamicMappingDtoInCommonFields(entityTemplateIdentifier, filter, name,
+ description, entity);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java
new file mode 100644
index 00000000..7c10e55e
--- /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 entityTemplateIdentifier is mandatory") String entityTemplateIdentifier,
+ @NotBlank(message = "Webhook mapping filter is mandatory") String filter,
+ @NotBlank(message = "Webhook name 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..f1023bad
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java
@@ -0,0 +1,19 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+/// Mapping rule request for inbound webhook update.
+public record EntityDynamicMappingUpdateDtoIn(
+ @NotBlank(message = "Webhook mapping entityTemplateIdentifier is mandatory") String entityTemplateIdentifier,
+ @NotBlank(message = "Webhook mapping filter is mandatory") String filter,
+ @NotBlank(message = "Webhook name 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(entityTemplateIdentifier, 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..55808e5f
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java
@@ -0,0 +1,30 @@
+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 jakarta.validation.constraints.Size;
+
+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) @Size(max = 255, message = "Webhook identifier must not exceed 255 characters") String identifier,
+ @NotBlank(message = "Webhook name is mandatory") @Size(max = 255, message = "Webhook name must not exceed 255 characters") String name,
+ String description, boolean enabled, List mappingIdentifiers,
+ @Valid InboundWebhookSecurityContractDtoIn security) {
+
+ public InboundWebhookCreateDtoIn {
+ mappingIdentifiers = mappingIdentifiers != null ? List.copyOf(mappingIdentifiers) : List.of();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java
new file mode 100644
index 00000000..406e35e9
--- /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 name expression is mandatory") String name,
+ @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..f56b8e64
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java
@@ -0,0 +1,17 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping;
+
+import java.util.Map;
+
+/// Mapping rule returned by the inbound webhook management API.
+public record EntityDynamicMappingDtoOut(String identifier, String entityTemplateIdentifier,
+ String filter, String name, String description, InboundWebhookEntityMappingDtoOut entity) {
+ /// Entity projection details exposed in webhook mapping responses.
+ public static record InboundWebhookEntityMappingDtoOut(String identifier, String name,
+ Map properties, Map relations) {
+
+ public InboundWebhookEntityMappingDtoOut {
+ properties = properties != null ? Map.copyOf(properties) : null;
+ relations = relations != null ? Map.copyOf(relations) : null;
+ }
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java
new file mode 100644
index 00000000..f6bcddb1
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java
@@ -0,0 +1,13 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping;
+
+import java.util.Map;
+
+/// Entity projection details exposed in webhook mapping responses.
+public record InboundWebhookEntityMappingDtoOut(String identifier, String title,
+ Map properties, Map relations) {
+
+ public InboundWebhookEntityMappingDtoOut {
+ properties = properties != null ? Map.copyOf(properties) : null;
+ relations = relations != null ? Map.copyOf(relations) : null;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java
new file mode 100644
index 00000000..459a9085
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java
@@ -0,0 +1,15 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook;
+
+import java.util.List;
+
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut;
+
+/// Response payload for created inbound webhook connector.
+public record InboundWebhookDtoOut(String identifier, String name, String description,
+ boolean enabled, List mappings,
+ InboundWebhookSecurityDtoOut security) {
+
+ public InboundWebhookDtoOut {
+ mappings = mappings != null ? List.copyOf(mappings) : null;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java
new file mode 100644
index 00000000..a02994b1
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java
@@ -0,0 +1,12 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook;
+
+import java.util.Map;
+
+/// Security strategy returned for webhook configuration responses.
+/// Only returns the strategy type to avoid exposing technical secret references.
+public record InboundWebhookSecurityDtoOut(String type, Map config) {
+
+ public InboundWebhookSecurityDtoOut {
+ config = config != null ? Map.copyOf(config) : null;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java
index 9a21a6d0..8145b387 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_dynamic_mapping.EntityDynamicMappingAlreadyExistsException;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_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;
@@ -79,6 +75,11 @@ public ResponseEntity handleTemplateNotFoundException(
return ResponseEntity.status(NOT_FOUND).body(errorResponse);
}
+ /// Handles domain exception for malformed filter query strings (`q=` DSL).
+ ///
+ /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad
+ /// Request
+ /// so API consumers receive clear feedback about invalid `q` parameter syntax.
@ExceptionHandler(InvalidFilterDslException.class)
public ResponseEntity handleInvalidFilterDslException(
InvalidFilterDslException ex) {
@@ -106,25 +107,27 @@ public ResponseEntity handleInvalidSearchQueryException(
@ExceptionHandler(EntityTemplateAlreadyExistsException.class)
public ResponseEntity handleEntityTemplateAlreadyExistsException(
EntityTemplateAlreadyExistsException ex) {
- log.warn("Entity template already exists: {}", ex.getMessage());
+ log.warn("Entity entityTemplateIdentifier already exists: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
}
- /// Handles domain exception when entity template names already exist.
+ /// Handles domain exception when entity entityTemplateIdentifier names already
+ /// exist.
///
/// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to
/// HTTP 409 status indicating business rule conflict for duplicate
- /// template names.
+ /// entityTemplateIdentifier names.
@ExceptionHandler(EntityTemplateNameAlreadyExistsException.class)
public ResponseEntity handleEntityTemplateNameAlreadyExistsException(
EntityTemplateNameAlreadyExistsException ex) {
- log.warn("Entity template name already exists: {}", ex.getMessage());
+ log.warn("Entity entityTemplateIdentifier name already exists: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
}
- /// Handles domain exception when attempting to change an entity template
+ /// Handles domain exception when attempting to change an entity
+ /// entityTemplateIdentifier
/// identifier.
///
/// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException
@@ -133,25 +136,26 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept
@ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class)
public ResponseEntity handleEntityTemplateIdentifierCannotChangeException(
EntityTemplateIdentifierCannotChangeException ex) {
- log.warn("Entity template identifier cannot be changed: {}", ex.getMessage());
+ log.warn("Entity entityTemplateIdentifier identifier cannot be changed: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
- /// Handles domain exception for wrong entity template property rules.
+ /// Handles domain exception for wrong entity entityTemplateIdentifier property
+ /// rules.
///
/// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to
/// HTTP 400 status indicating validation error for wrong property rules.
@ExceptionHandler(PropertyDefinitionRulesConflictException.class)
public ResponseEntity handleWrongPropertyRulesException(
PropertyDefinitionRulesConflictException ex) {
- log.warn("Wrong Entity template property rules: {}", ex.getMessage());
+ log.warn("Wrong Entity entityTemplateIdentifier property rules: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
/// Handles domain exception when property names are duplicated within a
- /// template.
+ /// entityTemplateIdentifier.
///
/// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400
/// status indicating validation error for duplicate property names.
@@ -163,7 +167,7 @@ public ResponseEntity handlePropertyNameAlreadyExistsException(
}
/// Handles domain exception when relation names are duplicated within a
- /// template.
+ /// entityTemplateIdentifier.
///
/// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400
/// status indicating validation error for duplicate relation names.
@@ -175,14 +179,15 @@ public ResponseEntity handleRelationNameAlreadyExistsException(
}
/// Handles domain exception when a relation references a non-existent target
- /// template.
+ /// entityTemplateIdentifier.
///
/// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400
- /// status indicating validation error for missing target template.
+ /// status indicating validation error for missing target
+ /// entityTemplateIdentifier.
@ExceptionHandler(TargetTemplateNotFoundException.class)
public ResponseEntity handleTargetTemplateNotFoundException(
TargetTemplateNotFoundException ex) {
- log.warn("Target template not found: {}", ex.getMessage());
+ log.warn("Target entityTemplateIdentifier not found: {}", ex.getMessage());
return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@@ -196,20 +201,23 @@ public ResponseEntity handleTypeChangeException(PropertyTypeChang
return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
- /// Handles domain exception when relation target template changes are
+ /// Handles domain exception when relation target entityTemplateIdentifier
+ /// changes are
/// attempted.
///
/// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP
- /// 400 status indicating validation error for immutable target template field.
+ /// 400 status indicating validation error for immutable target
+ /// entityTemplateIdentifier field.
@ExceptionHandler(RelationTargetTemplateChangeException.class)
public ResponseEntity handleRelationTargetTemplateChangeException(
RelationTargetTemplateChangeException ex) {
- log.warn("Relation target template change error: {}", ex.getMessage());
+ log.warn("Relation target entityTemplateIdentifier change error: {}", ex.getMessage());
return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
- /// Handles domain exception when a relation's target template identifier is the
- /// template itself.
+ /// Handles domain exception when a relation's target entityTemplateIdentifier
+ /// identifier is the
+ /// entityTemplateIdentifier itself.
///
/// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP
/// 400
@@ -288,6 +296,48 @@ 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 entityTemplateIdentifier 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
@@ -442,6 +492,103 @@ 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 entityTemplateIdentifier in use by webhook mapping conflict: {}",
+ ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
+ /// Handles webhook connector name duplication conflicts.
+ @ExceptionHandler(WebhookConnectorTitleAlreadyExistsException.class)
+ public ResponseEntity handleWebhookConnectorTitleAlreadyExistsException(
+ WebhookConnectorTitleAlreadyExistsException ex) {
+ log.warn("Webhook connector name conflict: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
private static ResponseEntity createErrorResponse(HttpStatus httpStatus,
String errorMessage) {
return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus);
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java
new file mode 100644
index 00000000..2d17f39c
--- /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.entity_dynamic_mapping.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.name(), dto.description(),
+ dto.enabled(), safeMappings(resolvedMappings), toDomain(dto.security()));
+ }
+
+ /// Converts API update payload to domain aggregate using the path identifier as
+ /// source of truth.
+ ///
+ /// @param identifier webhook connector identifier from URL path
+ /// @param dto inbound webhook update request body
+ /// @param resolvedMappings the existing dynamic mappings referenced by the
+ /// request, already resolved and validated by the domain layer
+ /// @return domain webhook connector prepared for update
+ public WebhookConnector toDomainForUpdate(String identifier, InboundWebhookCreateDtoIn dto,
+ List resolvedMappings) {
+ return new WebhookConnector(null, identifier, dto.name(), dto.description(), dto.enabled(),
+ safeMappings(resolvedMappings), toDomain(dto.security()));
+ }
+
+ /// Converts domain aggregate to API response payload.
+ ///
+ /// @param domain created webhook connector
+ /// @return response DTO
+ public InboundWebhookDtoOut fromWebhookConnectorToDto(WebhookConnector domain) {
+ List mappings = domain.mappings().stream()
+ .map(dynamicMappingMapper::fromEntityMappingToDto).toList();
+ InboundWebhookSecurityDtoOut security = new InboundWebhookSecurityDtoOut(
+ domain.security().type().name(), domain.security().config());
+ return new InboundWebhookDtoOut(domain.identifier(), domain.name(), domain.description(),
+ domain.enabled(), mappings, security);
+ }
+
+ private List safeMappings(List mappings) {
+ return mappings == null ? List.of() : List.copyOf(mappings);
+ }
+
+ private WebhookSecurity toDomain(InboundWebhookSecurityContractDtoIn security) {
+ if (security == null) {
+ return new WebhookSecurity(WebhookSecurityType.NONE, Map.of());
+ }
+
+ var type = parseSecurityType(security.type());
+ var config = safeMap(security.config());
+
+ return new WebhookSecurity(type, config);
+ }
+
+ private WebhookSecurityType parseSecurityType(String typeString) {
+ try {
+ return WebhookSecurityType.valueOf(typeString.toUpperCase());
+ } catch (IllegalArgumentException _) {
+ throw new WebhookSecurityConfigurationException("Unsupported security type: " + typeString);
+ }
+ }
+
+ private Map safeMap(Map input) {
+ return input == null ? Map.of() : Map.copyOf(input);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/DynamicMappingMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/DynamicMappingMapper.java
new file mode 100644
index 00000000..780eb91d
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/DynamicMappingMapper.java
@@ -0,0 +1,66 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping;
+
+import java.util.Map;
+
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingDtoInCommonFields;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingUpdateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut;
+
+@Component
+public class DynamicMappingMapper {
+
+ public EntityDynamicMapping toDomain(EntityDynamicMappingCreateDtoIn mapping) {
+ // Map each DTO field explicitly to its matching domain field. The
+ // EntityDynamicMapping
+ // constructor order is (id, identifier, entityTemplateIdentifier, filter,
+ // entityIdentifier,
+ // entityName, properties, relations); keeping this alignment prevents the
+ // entityTemplateIdentifier
+ // identifier and the filter expression from being swapped.
+ EntityDynamicMappingDtoInCommonFields fields = mapping.commonFields();
+ return new EntityDynamicMapping(null, // id (assigned by persistence layer)
+ mapping.identifier(), // identifier
+ fields.entityTemplateIdentifier(), // entityTemplateIdentifier
+ fields.filter(), // filter
+ fields.name(), // titre
+ fields.description(), fields.entity().identifier(), // entityIdentifier
+ fields.entity().name(), // entityName
+ safeMap(fields.entity().properties()), // properties
+ safeMap(fields.entity().relations())); // relations
+ }
+
+ public EntityDynamicMappingDtoOut fromEntityMappingToDto(EntityDynamicMapping mapping) {
+ return new EntityDynamicMappingDtoOut(mapping.identifier(), mapping.entityTemplateIdentifier(),
+ mapping.filter(), mapping.name(), mapping.description(),
+ new EntityDynamicMappingDtoOut.InboundWebhookEntityMappingDtoOut(mapping.entityIdentifier(),
+ mapping.entityName(), Map.copyOf(mapping.properties()),
+ Map.copyOf(mapping.relations())));
+ }
+
+ /// Converts an update DTO to domain model, using the identifier from the path.
+ ///
+ /// @param identifier the mapping identifier from the URL path
+ /// @param dto the update request body
+ /// @return the domain model for update
+ public EntityDynamicMapping toDomainForUpdate(String identifier,
+ EntityDynamicMappingUpdateDtoIn dto) {
+ var fields = dto.commonFields();
+ return new EntityDynamicMapping(null, // id (will be set from existing entity)
+ identifier, // identifier from path
+ fields.entityTemplateIdentifier(), // entityTemplateIdentifier
+ fields.filter(), // filter
+ fields.name(), // titre
+ fields.description(), fields.entity().identifier(), // entityIdentifier
+ fields.entity().name(), // entityName
+ safeMap(fields.entity().properties()), // properties
+ safeMap(fields.entity().relations())); // relations
+ }
+
+ private Map safeMap(Map input) {
+ return input == null ? Map.of() : Map.copyOf(input);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java
new file mode 100644
index 00000000..12da5445
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java
@@ -0,0 +1,100 @@
+package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt;
+
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator;
+import com.schibsted.spt.data.jslt.JsltException;
+import com.schibsted.spt.data.jslt.Parser;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class JsltEntityMappingValidator implements EntityDynamicMapperValidator {
+
+ private static final Pattern LOCATION_PATTERN = Pattern
+ .compile("line\\s+(\\d+),\\s+column\\s+(\\d+)");
+ private static final Pattern TOKEN_PATTERN = Pattern.compile("Encountered\\s+\"([^\"]+)\"");
+
+ @Override
+ public void validate(EntityDynamicMapping mapping) {
+ List errors = new ArrayList<>();
+
+ checkExpression(errors, "filter", mapping.filter());
+
+ checkExpression(errors, "entityIdentifier", mapping.entityIdentifier());
+ checkExpression(errors, "entityName", mapping.entityName());
+
+ if (mapping.properties() != null && !mapping.properties().isEmpty()) {
+ mapping.properties()
+ .forEach((key, expr) -> checkExpression(errors, "properties." + key, expr));
+ }
+ if (mapping.relations() != null && !mapping.relations().isEmpty()) {
+ mapping.relations().forEach((key, expr) -> checkExpression(errors, "relations." + key, expr));
+ }
+
+ if (!errors.isEmpty()) {
+ throw new EntityDynamicMappingConfigurationException(String.format(
+ "Validation failed with %d errors: %s", errors.size(), String.join(" | ", errors)));
+ }
+ }
+
+ private void checkExpression(List errors, String fieldName, String expression) {
+ if (!StringUtils.hasText(expression)) {
+ errors.add(
+ String.format("Field '%s' is required and must contain a JSLT expression.", fieldName));
+ return;
+ }
+
+ try {
+ new Parser(new StringReader(expression)).compile();
+ } catch (JsltException exception) {
+ errors.add(String.format("Invalid expression for '%s': %s", fieldName,
+ formatJsltErrorMessage(exception.getMessage())));
+ }
+ }
+
+ private String formatJsltErrorMessage(String rawMessage) {
+ if (!StringUtils.hasText(rawMessage)) {
+ return "JSLT syntax error.";
+ }
+
+ String normalized = rawMessage.replaceAll("\\s+", " ").trim();
+ if (normalized.startsWith("Parse error:")) {
+ normalized = normalized.substring("Parse error:".length()).trim();
+ }
+
+ String line = null;
+ String column = null;
+ Matcher locationMatcher = LOCATION_PATTERN.matcher(rawMessage);
+ if (locationMatcher.find()) {
+ line = locationMatcher.group(1);
+ column = locationMatcher.group(2);
+ }
+
+ String token = null;
+ Matcher tokenMatcher = TOKEN_PATTERN.matcher(rawMessage);
+ if (tokenMatcher.find()) {
+ token = tokenMatcher.group(1);
+ }
+
+ if (line != null && column != null && token != null) {
+ return String.format("JSLT syntax error at line %s, column %s (unexpected token: %s).", line,
+ column, token);
+ }
+ if (line != null && column != null) {
+ return String.format("JSLT syntax error at line %s, column %s.", line, column);
+ }
+
+ return normalized;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java
new file mode 100644
index 00000000..3a1db78a
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java
@@ -0,0 +1,94 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
+import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/// Persistence adapter for [EntityDynamicMapping] read and write operations.
+@Component
+@RequiredArgsConstructor
+public class EntityDynamicMappingAdaptor implements EntityDynamicMappingPort {
+ private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository;
+ private final EntityDynamicMappingPersistenceMapper entityDynamicMappingPersistenceMapper;
+ private final EntityTemplateRepositoryPort entityTemplateRepositoryPort;
+
+ @Override
+ public List findByTemplateIdentifier(String identifier) {
+ return jpaEntityDynamicMappingRepository.findByTemplateIdentifier(identifier).stream()
+ .map(entityDynamicMappingPersistenceMapper::toDomain).toList();
+ }
+
+ @Override
+ public List findByTemplateId(UUID templateIde) {
+ return jpaEntityDynamicMappingRepository.findByTemplateId(templateIde).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) {
+ // The domain model references the entityTemplateIdentifier by its business
+ // identifier, but the
+ // foreign key persisted in `entity_dynamic_mapping.template_id` is the
+ // entityTemplateIdentifier
+ // UUID. Resolve the identifier to the entityTemplateIdentifier id before saving
+ // (fail-fast).
+ UUID templateId = entityTemplateRepositoryPort
+ .findByIdentifier(entityDynamicMapping.entityTemplateIdentifier()).map(EntityTemplate::id)
+ .orElseThrow(() -> new EntityTemplateNotFoundException("identifier",
+ entityDynamicMapping.entityTemplateIdentifier()));
+
+ EntityDynamicMappingJpaEntity entityToPersist = entityDynamicMappingPersistenceMapper
+ .toJpa(entityDynamicMapping);
+ entityToPersist.setTemplateId(templateId);
+ EntityDynamicMappingJpaEntity persistedEntity = jpaEntityDynamicMappingRepository
+ .save(entityToPersist);
+
+ return new EntityDynamicMapping(persistedEntity.getId(), entityDynamicMapping.identifier(),
+ entityDynamicMapping.entityTemplateIdentifier(), entityDynamicMapping.filter(),
+ entityDynamicMapping.name(), entityDynamicMapping.description(),
+ entityDynamicMapping.entityIdentifier(), entityDynamicMapping.entityName(),
+ entityDynamicMapping.properties(), entityDynamicMapping.relations());
+ }
+
+ @Override
+ public Page findAll(Pageable pageable) {
+ return jpaEntityDynamicMappingRepository.findAll(pageable)
+ .map(entityDynamicMappingPersistenceMapper::toDomain);
+ }
+
+ @Override
+ public void deleteByIdentifier(String identifier) {
+ jpaEntityDynamicMappingRepository.deleteByIdentifier(identifier);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java
index af72cfd0..b4de92e6 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java
@@ -90,6 +90,11 @@ public void deleteByIdentifier(String identifier) {
jpaEntityTemplateRepository.deleteByIdentifier(identifier);
}
+ @Override
+ public boolean existsById(UUID id) {
+ return jpaEntityTemplateRepository.existsById(id);
+ }
+
// ββ Merge helpers to update a managed JPA entity from domain values ββ
private void mergeIntoExisting(EntityTemplateJpaEntity jpa, EntityTemplate domain) {
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java
new file mode 100644
index 00000000..a74f9ee4
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java
@@ -0,0 +1,188 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
+import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort;
+import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookConnectorRepository;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/// Persistence adapter implementing [WebhookConnectorRepositoryPort].
+///
+/// Delegates to Spring Data JPA and uses [WebhookConnectorPersistenceMapper]
+/// to convert between JPA entities and domain models.
+///
+/// Handles the complex persistence of mappings across three tables:
+/// - webhook_connector (core connector data)
+/// - entity_dynamic_mapping (mapping configurations)
+/// - webhook_template_mapping (many-to-many link)
+@Component
+@RequiredArgsConstructor
+public class PostgresWebhookConnectorAdapter implements WebhookConnectorRepositoryPort {
+
+ private final JpaWebhookConnectorRepository jpaWebhookConnectorRepository;
+ private final WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository;
+ private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository;
+ private final EntityTemplateRepositoryPort entityTemplateRepositoryPort;
+ private final EntityDynamicMappingPort entityDynamicMappingPort;
+ private final WebhookConnectorPersistenceMapper mapper;
+ private final EntityDynamicMappingPersistenceMapper mappingMapper;
+
+ @Override
+ public Optional findByIdentifier(String identifier) {
+ return jpaWebhookConnectorRepository.findByIdentifier(identifier)
+ .map(this::loadConnectorWithMappings);
+ }
+
+ @Override
+ public Page findAll(Pageable pageable) {
+ Page jpaPage = jpaWebhookConnectorRepository.findAll(pageable);
+
+ if (jpaPage.isEmpty()) {
+ return jpaPage.map(mapper::toDomain);
+ }
+
+ // Collect all webhook IDs from the page
+ List webhookIds = jpaPage.stream().map(WebhookConnectorJpaEntity::getId).toList();
+
+ // Batch load all entityTemplateIdentifier mappings for all webhooks in the page
+ List allTemplateMappings = jpaWebhookTemplateMappingRepository
+ .findByWebhookIdIn(webhookIds);
+
+ // Collect all unique mapping IDs
+ List allMappingIds = allTemplateMappings.stream()
+ .map(WebhookMappingLinkJpaEntity::getEntityMappingId).distinct().toList();
+
+ // Batch load all entity mappings in one query
+ Map allMappingsById = jpaEntityDynamicMappingRepository
+ .findAllById(allMappingIds).stream()
+ .collect(Collectors.toMap(EntityDynamicMappingJpaEntity::getId, mappingMapper::toDomain,
+ (existing, replacement) -> existing));
+
+ // Group mappings by webhook ID
+ Map> mappingsByWebhookId = allTemplateMappings.stream()
+ .collect(Collectors.groupingBy(WebhookMappingLinkJpaEntity::getWebhookId,
+ Collectors.mapping(wtm -> allMappingsById.get(wtm.getEntityMappingId()),
+ Collectors.filtering(Objects::nonNull, Collectors.toList()))));
+
+ // Map each JPA entity to domain with its mappings
+ return jpaPage.map(jpaEntity -> {
+ WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity);
+ List mappings = mappingsByWebhookId.getOrDefault(jpaEntity.getId(),
+ Collections.emptyList());
+
+ return new WebhookConnector(connectorWithoutMappings.id(),
+ connectorWithoutMappings.identifier(), connectorWithoutMappings.name(),
+ connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings,
+ connectorWithoutMappings.security());
+ });
+ }
+
+ @Override
+ public boolean existsByIdentifier(String identifier) {
+ return jpaWebhookConnectorRepository.existsByIdentifier(identifier);
+ }
+
+ @Override
+ public boolean existsByTitle(String title) {
+ return jpaWebhookConnectorRepository.existsByName(title);
+ }
+
+ @Override
+ public WebhookConnector save(WebhookConnector connector) {
+ WebhookConnectorJpaEntity savedConnector = jpaWebhookConnectorRepository
+ .save(mapper.toJpa(connector));
+ persistTemplateMappings(savedConnector.getId(), connector);
+ return loadConnectorWithMappings(savedConnector);
+ }
+
+ @Override
+ public void deleteByIdentifier(String identifier) {
+ jpaWebhookConnectorRepository.deleteByIdentifier(identifier);
+ }
+
+ /// Loads a connector with its associated mappings from the
+ /// webhook_template_mapping table.
+ /// Since WebhookConnector is a Record (immutable), we create a new instance
+ /// with the loaded mappings.
+ private WebhookConnector loadConnectorWithMappings(WebhookConnectorJpaEntity jpaEntity) {
+ WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity);
+ List mappings = loadMappingsForWebhook(jpaEntity.getId());
+
+ // Since WebhookConnector is a Record, create a new instance with loaded
+ // mappings
+ return new WebhookConnector(connectorWithoutMappings.id(),
+ connectorWithoutMappings.identifier(), connectorWithoutMappings.name(),
+ connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings,
+ connectorWithoutMappings.security());
+ }
+
+ /// Loads all dynamic mappings associated with a webhook connector.
+ /// Uses batch loading to avoid N+1 query problem.
+ private List loadMappingsForWebhook(UUID webhookId) {
+ List templateMappings = jpaWebhookTemplateMappingRepository
+ .findByWebhookId(webhookId);
+ List mappingIds = templateMappings.stream()
+ .map(WebhookMappingLinkJpaEntity::getEntityMappingId).toList();
+ if (mappingIds.isEmpty()) {
+ return List.of();
+ }
+ Map mappingsById = jpaEntityDynamicMappingRepository
+ .findAllById(mappingIds).stream()
+ .collect(Collectors.toMap(EntityDynamicMappingJpaEntity::getId, mappingMapper::toDomain));
+ return mappingIds.stream().map(mappingsById::get).filter(Objects::nonNull).toList();
+ }
+
+ /// Persists the webhook's entityTemplateIdentifier mappings in the
+ /// webhook_template_mapping
+ /// table.
+ /// This also persists each EntityDynamicMapping if it's new.
+ private void persistTemplateMappings(UUID webhookId, WebhookConnector connector) {
+ jpaWebhookTemplateMappingRepository.deleteByWebhookId(webhookId);
+ var mappings = connector.mappings().stream()
+ .map(mapping -> persistAndCreateTemplateMapping(webhookId, mapping)).toList();
+
+ if (!mappings.isEmpty()) {
+ jpaWebhookTemplateMappingRepository.saveAll(mappings);
+ }
+ }
+
+ /// Persists a single EntityDynamicMapping and creates a
+ /// WebhookTemplateMappingJpaEntity link.
+ ///
+ /// The mapping is expected to already exist because it is created through the
+ /// dedicated inbound dynamic mapping endpoint. This method only creates the
+ /// association row in webhook_template_mapping.
+ private WebhookMappingLinkJpaEntity persistAndCreateTemplateMapping(UUID webhookId,
+ EntityDynamicMapping mapping) {
+
+ EntityDynamicMapping entityDynamicMapping = entityDynamicMappingPort
+ .findByIdentifier(mapping.identifier())
+ .orElseThrow(() -> new EntityDynamicMappingNotFoundException(mapping.identifier()));
+
+ return WebhookMappingLinkJpaEntity.builder().webhookId(webhookId)
+ .entityMappingId(entityDynamicMapping.id()).jsltFilter(mapping.filter()).build();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java
new file mode 100644
index 00000000..25ec2552
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java
@@ -0,0 +1,34 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence;
+
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping;
+import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookMappingLinkPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/// Persistence adapter for webhook-entityTemplateIdentifier mapping read operations.
+@Component
+@RequiredArgsConstructor
+public class WebhookTemplateMappingAdaptor implements WebhookMappingLinkPort {
+
+ private final WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository;
+ private final WebhookMappingLinkPersistenceMapper webhookMappingLinkPersistenceMapper;
+
+ @Override
+ public boolean existsByEntityMappingId(UUID id) {
+ return jpaWebhookTemplateMappingRepository.existsByEntityMappingId(id);
+ }
+
+ @Override
+ public List findByEntityMappingId(UUID id) {
+ return jpaWebhookTemplateMappingRepository.findByEntityMappingId(id).stream()
+ .map(webhookMappingLinkPersistenceMapper::toDomain).toList();
+ }
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java
new file mode 100644
index 00000000..56461634
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java
@@ -0,0 +1,40 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper;
+
+import static org.mapstruct.MappingConstants.ComponentModel.SPRING;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.EntityDynamicMappingJsonbHelper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity;
+
+/// MapStruct persistence mapper for [EntityDynamicMapping].
+///
+/// Maps between domain model (EntityDynamicMapping) and JPA entity (EntityDynamicMappingJpaEntity).
+/// Handles JSONB columns for properties and relations via the dedicated helper.
+@Mapper(componentModel = SPRING, uses = EntityDynamicMappingJsonbHelper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR)
+public interface EntityDynamicMappingPersistenceMapper {
+
+ @Mapping(target = "properties", qualifiedByName = "jsonStringToMap")
+ @Mapping(target = "relations", qualifiedByName = "jsonStringToMap")
+ @Mapping(target = "entityTemplateIdentifier", source = "template.identifier")
+ // Explicit self-mapping: MapStruct otherwise silently drops the
+ // `entityIdentifier` property, leaving the NOT NULL column unset.
+ @Mapping(target = "entityIdentifier", source = "entityIdentifier")
+ @Mapping(target = "entityName", source = "entityName")
+ EntityDynamicMapping toDomain(EntityDynamicMappingJpaEntity jpa);
+
+ @Mapping(target = "properties", qualifiedByName = "mapToJsonString")
+ @Mapping(target = "relations", qualifiedByName = "mapToJsonString")
+ // The template foreign key (UUID) is resolved from the business identifier and
+ // set by the persistence adapter, so both template fields are ignored here.
+ @Mapping(target = "templateId", ignore = true)
+ @Mapping(target = "template", ignore = true)
+ // Explicit self-mapping: MapStruct otherwise silently drops the
+ // `entityIdentifier` property, leaving the NOT NULL column unset.
+ @Mapping(target = "entityIdentifier", source = "entityIdentifier")
+ @Mapping(target = "entityName", source = "entityName")
+ EntityDynamicMappingJpaEntity toJpa(EntityDynamicMapping domain);
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java
new file mode 100644
index 00000000..7cd398c4
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java
@@ -0,0 +1,31 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper;
+
+import static org.mapstruct.MappingConstants.ComponentModel.SPRING;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.WebhookConnectorJsonbHelper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity;
+
+/// MapStruct persistence mapper for [WebhookConnector].
+///
+/// Maps the connector's direct fields (identifier, name, description, enabled, security).
+/// The mappings list is handled separately by
+/// [com.decathlon.idp_core.infrastructure.adapters.persistence.PostgresWebhookConnectorAdapter]
+/// through the `webhook_template_mapping` table because it requires dedicated persistence
+/// for `entity_dynamic_mapping` rows.
+@Mapper(componentModel = SPRING, uses = WebhookConnectorJsonbHelper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR)
+public interface WebhookConnectorPersistenceMapper {
+
+ @Mapping(target = "mappings", ignore = true)
+ @Mapping(target = "security", qualifiedByName = "jsonToSecurity")
+ WebhookConnector toDomain(WebhookConnectorJpaEntity jpa);
+
+ @Mapping(target = "createdAt", ignore = true)
+ @Mapping(target = "updatedAt", ignore = true)
+ @Mapping(target = "security", qualifiedByName = "securityToJson")
+ WebhookConnectorJpaEntity toJpa(WebhookConnector domain);
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java
new file mode 100644
index 00000000..ee15cd59
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java
@@ -0,0 +1,57 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper;
+
+import java.util.UUID;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingConstants;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity;
+
+/// Persistence mapper for [WebhookTemplateMapping].
+///
+/// Maps the association entity between webhook connector, entity entityTemplateIdentifier and
+/// dynamic mapping configuration. Foreign keys are managed explicitly by adapters
+/// when persisting new links.
+@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = {
+ WebhookConnectorPersistenceMapper.class, EntityTemplatePersistenceMapper.class,
+ EntityDynamicMappingPersistenceMapper.class}, injectionStrategy = InjectionStrategy.CONSTRUCTOR)
+public interface WebhookMappingLinkPersistenceMapper {
+
+ /// Maps JPA association data to the domain model.
+ ///
+ /// @param jpa persisted association entity
+ /// @return mapped domain model
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "webhookConnector", source = "webhookConnector")
+ @Mapping(target = "entityDynamicMapping", source = "entityMapping")
+ @Mapping(target = "jsltFilter", source = "jsltFilter")
+ WebhookTemplateMapping toDomain(WebhookMappingLinkJpaEntity jpa);
+
+ /// Maps domain model to JPA association entity.
+ ///
+ /// All technical IDs are preserved from the domain model.
+ ///
+ /// @param domain domain mapping object
+ /// @return fully mapped JPA association entity
+ @Mapping(target = "webhookId", source = "webhookConnector.id")
+ @Mapping(target = "entityMappingId", source = "entityDynamicMapping.id")
+ @Mapping(target = "jsltFilter", source = "jsltFilter")
+ @Mapping(target = "webhookConnector", ignore = true)
+ @Mapping(target = "entityMapping", ignore = true)
+ WebhookMappingLinkJpaEntity toJpa(WebhookTemplateMapping domain);
+
+ /// Builds a link row with explicit foreign keys.
+ ///
+ /// @param webhookId webhook connector technical id
+ /// @param entityMappingId dynamic mapping technical id
+ /// @param jsltFilter JSLT filter expression
+ /// @return link entity ready for persistence
+ default WebhookMappingLinkJpaEntity toJpa(UUID webhookId, UUID entityMappingId,
+ String jsltFilter) {
+ return WebhookMappingLinkJpaEntity.builder().webhookId(webhookId)
+ .entityMappingId(entityMappingId).jsltFilter(jsltFilter).build();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java
new file mode 100644
index 00000000..7fb0a383
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java
@@ -0,0 +1,51 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common;
+
+import java.util.Map;
+
+import org.mapstruct.Named;
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/// Technical helper for JSONB serialization/deserialization in the persistence layer.
+///
+/// Provides named conversion methods used by [com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper]
+/// via MapStruct's `qualifiedByName` annotation.
+///
+/// This is a pure utility class with no Spring dependencies, facilitating testability and reusability.
+@Component
+public class EntityDynamicMappingJsonbHelper {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ /// Converts JSONB string to `Map`.
+ /// Used when loading from database.
+ @Named("jsonStringToMap")
+ public Map toMap(String json) {
+ if (json == null || json.trim().isEmpty()) {
+ return Map.of();
+ }
+ try {
+ return OBJECT_MAPPER.readValue(json, new TypeReference