diff --git a/docs/src/concepts/index.md b/docs/src/concepts/index.md
index 7ff6f3c2..764ec856 100644
--- a/docs/src/concepts/index.md
+++ b/docs/src/concepts/index.md
@@ -29,27 +29,31 @@ graph TB
---
- Blueprints that define the structure of your entities—like database schemas defined at runtime.
+ Blueprints that define the structure of your entities—like database schemas defined at runtime.
- 🗄️ **[Entities](entities.md)**
---
- Instances of templates with actual data—your software catalog items.
+ Instances of templates with actual data—your software catalog items.
- 📋 **[Properties](properties.md)**
---
- Data fields with types, validation rules, and constraints.
+ Data fields with types, validation rules, and constraints.
- 🔗 **[Relations](relations.md)**
---
- Connections between entities forming a knowledge graph.
+ Connections between entities forming a knowledge graph.
-- 🔍 **[Filtering Entities](entity-filtering.md)**
+- 🌐 **[Webhooks](webhooks.md)**
+
+ ---
+
+ Runtime-configurable connectors to push your data from external systems within your IDP. You can map any source in your data model in minutes!- 🔍 **[Filtering Entities](entity-filtering.md)**
---
@@ -111,7 +115,7 @@ erDiagram
## Quick Reference
| Concept | What It Is | Example |
-| ------------------- | --------------------------------------------------- | --------------------------------------------------------- |
+|---------------------|-----------------------------------------------------|-----------------------------------------------------------|
| **Entity Template** | Blueprint/schema | `service`, `team`, `repository` |
| **Entity** | Instance based on a template that contains the data | `payment-service`, `platform-team`, `idp-core-repository` |
| **Property** | Data field | `name`, `status`, `url` |
@@ -136,3 +140,4 @@ Dive deeper into each concept:
- **[Entity Templates](entity-templates.md)** - Learn how to design your data model
- **[Properties](properties.md)** - Understand property types and validation
- **[Relations](relations.md)** - Connect your entities into a graph
+- **[Webhooks](webhooks.md)** - Configure inbound integrations and security strategies
diff --git a/docs/src/concepts/webhooks.md b/docs/src/concepts/webhooks.md
new file mode 100644
index 00000000..e30ea8ed
--- /dev/null
+++ b/docs/src/concepts/webhooks.md
@@ -0,0 +1,226 @@
+---
+title: Webhooks
+description: Understand webhook connectors, security strategies, and dynamic mappings in IDP-Core
+---
+
+Webhooks let external systems push JSON events to the Internal Developer Platform through a generic HTTP endpoint. You configure a webhook connector at runtime, choose a security strategy, and define mappings that translate incoming payloads into entity data with JSLT expressions.
+
+## Overview
+
+A webhook connector combines three concerns:
+
+- **Connector metadata** - Identifier, title, description, and enabled flag
+- **Security** - How IDP-Core authenticates incoming requests
+- **Mappings** - How the payload maps to an Entity Template
+
+```mermaid
+flowchart LR
+ S[External system] --> E[POST /webhooks/{configurationId}]
+ E --> H[InboundWebhookHandler]
+ H --> D[Security dispatcher]
+ D --> C[WebhookConnector]
+ C --> M[Dynamic mappings]
+ M --> T[Entity Template]
+```
+
+## Webhook Connector
+
+A webhook connector is the runtime configuration stored by IDP-Core for one inbound integration.
+
+| Field | Type | Description |
+| --------------------- | ------- | ------------------------------------------------------ |
+| `identifier` | String | Stable key used in the webhook URL and management APIs |
+| `title` | String | Human-readable name |
+| `description` | String | Optional explanation of the connector purpose |
+| `enabled` | Boolean | Enables or disables request processing |
+| `mapping_identifiers` | Array | One or more dynamic mapping identifier |
+| `security` | Object | Authentication strategy and configuration |
+
+### Webhook Connector Example
+
+```json
+{
+ "identifier": "github-repositories",
+ "title": "GitHub repositories",
+ "description": "Receives repository events from GitHub",
+ "enabled": true,
+ "mapping_identifiers": [],
+ "security": {
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Hub-Signature-256",
+ "secret_alias": "GITHUB_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
+ }
+}
+```
+
+## Dynamic Mappings
+
+Each connector contains at least one dynamic mapping. A mapping targets one Entity Template and describes how to derive entity fields from the incoming JSON payload with a JSLT filter and entity projections.
+
+| Field | Type | Description |
+| ------------- | ------ | --------------------------------------------------------------------------- |
+| `template` | String | Identifier of the target Entity Template |
+| `identifier` | String | Stable and unique key for this specific mapping |
+| `name` | String | Human-readable name of the mapping |
+| `description` | String | Optional explanation of the mapping purpose |
+| `filter` | String | JSLT boolean expression to evaluate if the payload should be processed |
+| `entity` | Object | JSLT projections defining how to map the payload to the entity's attributes |
+
+### Dynamic Mapping Example
+
+```json
+{
+ "template": "github_repository",
+ "identifier": "mapping-github",
+ "name": "mapping github",
+ "description": "mapping github description",
+ "filter": ".repository != null",
+ "entity": {
+ "identifier": "replace(.repository.name, \" \", \"-\")",
+ "title": ".repository.name",
+ "properties": {
+ "name": ".repository.name",
+ "url": ".repository.html_url",
+ "stars": "\"\" + .repository.stargazers_count",
+ "is_public": "if (.repository.private) \"false\" else \"true\""
+ },
+ "relations": {}
+ }
+}
+```
+
+### Validation Rules
+
+When you create or update a connector, the IDP validates each mapping against the target Entity Template.
+
+It checks that:
+
+- The referenced template exists
+- Every mapped property exists in the template
+- Every required property is mapped
+- Every mapped relation exists in the template
+- Every required relation is mapped
+
+This validation keeps the connector configuration aligned with the current data model.
+
+## Security Strategies
+
+Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests again at runtime.
+
+| Type | Required configuration keys | Runtime behavior |
+| -------------- | --------------------------------------- | -------------------------------------------------------------------------------------- |
+| `HMAC_SHA256` | `header_name`, `secret_alias`, `prefix` | Computes the SHA-256 HMAC of the raw body and compares it with the request header |
+| `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment |
+| `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret |
+| `JWT_BEARER` | `jwks_uri` | Validates the bearer token against a JWKS endpoint |
+| `NONE` | none | Skips authentication |
+
+> [!IMPORTANT]
+> Security configuration keys accept `snake_case` and `camelCase` variants for the supported fields.
+> [!WARNING]
+> `secret_alias` must reference an environment variable alias in `UPPER_SNAKE_CASE`. It does not store the raw secret value in the connector configuration.
+
+### Example Security Configurations
+
+=== "HMAC_SHA256"
+
+```json
+{
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Hub-Signature-256",
+ "secret_alias": "GITHUB_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
+}
+```
+
+=== "STATIC_TOKEN"
+
+```json
+{
+ "type": "STATIC_TOKEN",
+ "config": {
+ "header_name": "X-Webhook-Token",
+ "secret_alias": "WEBHOOK_SHARED_TOKEN"
+ }
+}
+```
+
+=== "BASIC_AUTH"
+
+```json
+{
+ "type": "BASIC_AUTH",
+ "config": {
+ "username": "webhook-user",
+ "secret_alias": "WEBHOOK_PASSWORD"
+ }
+}
+```
+
+=== "JWT_BEARER"
+
+```json
+{
+ "type": "JWT_BEARER",
+ "config": {
+ "jwks_uri": "https://issuer.example.com/.well-known/jwks.json"
+ }
+}
+```
+
+## Runtime Flow
+
+The webhook runtime uses a single generic endpoint:
+
+```text
+POST /webhooks/{configurationId}
+```
+
+The request flow is:
+
+1. IDP-Core receives the request on the generic webhook endpoint.
+2. The `configurationId` resolves the stored `WebhookConnector`.
+3. If the connector is disabled, IDP-Core ignores the event.
+4. The security dispatcher selects the matching strategy for the connector security type.
+5. The strategy validates the headers and, when needed, the raw request body.
+6. After authentication, the event is accepted for downstream processing.
+
+> [!IMPORTANT]
+> The connector model, security validation, management APIs, and mapping validation are implemented now.
+
+## Management API Methods
+
+You manage webhook connectors through the inbound webhook management API, which exposes standard CRUD methods.
+
+| HTTP Method | Endpoint | Purpose |
+| ----------- | ----------------------------------------- | ---------------- |
+| `POST` | `/api/v1/inbound_webhooks` | Create connector |
+| `GET` | `/api/v1/inbound_webhooks` | List connectors |
+| `GET` | `/api/v1/inbound_webhooks/{identifier}` | Get connector |
+| `PUT` | `/api/v1/inbound_webhooks/{identifier}` | Update connector |
+| `DELETE` | `/api/v1/inbound_webhooks/{identifier}` | Delete connector |
+
+This separation keeps configuration management under versioned API routes while the event ingestion endpoint stays simple for external systems.
+
+## When to Use Webhooks
+
+Use webhooks when an external system can push JSON events over HTTP and you want to:
+
+- Ingest updates without redeploying IDP-Core
+- Reuse one generic endpoint for multiple providers
+- Apply connector-specific authentication rules
+- Map external payloads to your own Entity Templates at runtime
+
+---
+
+## Next Steps
+
+- **[Entity Templates](entity-templates.md)** - Define the target structures that mappings reference
+- **[Entities](entities.md)** - Understand the records produced by successful ingestion
+- **[Relations](relations.md)** - Model links that webhook mappings can populate
+- **[Data Integration](../features/data-integration.md)** - Explore the broader ingestion roadmap
diff --git a/docs/src/features/data-integration.md b/docs/src/features/data-integration.md
index 131b2c7d..4018fa2d 100644
--- a/docs/src/features/data-integration.md
+++ b/docs/src/features/data-integration.md
@@ -14,7 +14,7 @@ The Internal Developer Platform provides flexible data integration, allowing you
Data integration in the Internal Developer Platform follows a three-step pattern:
1. **Configure a connector** - Set up a Webhook, Kafka consumer, or Pub/Sub subscription
-2. **Define mappings** - Use JQ expressions to transform incoming data
+2. **Define mappings** - Use JSLT expressions to transform incoming data
3. **Ingest data** - Data flows automatically, creating and updating entities
```mermaid
@@ -55,6 +55,19 @@ flowchart LR
Webhooks allow external systems to push data to IDP-Core via HTTP POST requests.
+### Methods
+
+| Method | Endpoint | Purpose |
+| ------ | -------- | ------- |
+| `POST` | `/webhooks/{configurationId}` | Receive an inbound event for the connector identified in the URL |
+| `POST` | `/api/v1/inbound_webhooks` | Create a webhook connector configuration |
+| `GET` | `/api/v1/inbound_webhooks` | List webhook connector configurations |
+| `GET` | `/api/v1/inbound_webhooks/{identifier}` | Read one webhook connector configuration |
+| `PUT` | `/api/v1/inbound_webhooks/{identifier}` | Update one webhook connector configuration |
+| `DELETE` | `/api/v1/inbound_webhooks/{identifier}` | Delete one webhook connector configuration |
+
+ These HTTP routes map to the `InboundWebhookManagementController` methods for connector management.
+
### Webhook Configuration
```json
@@ -83,33 +96,37 @@ Webhooks allow external systems to push data to IDP-Core via HTTP POST requests.
}
],
"security": {
- "signature_header_name": "X-Sonar-Webhook-HMAC-SHA256",
- "signature_value": "your-secret-token"
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Sonar-Webhook-HMAC-SHA256",
+ "secret_alias": "SONAR_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
}
}
```
### Configuration Fields
-| Field | Description |
-| ------------- | ---------------------------- |
-| `identifier` | Unique key for this webhook |
-| `title` | Human-readable name |
-| `description` | Purpose of the webhook |
-| `enabled` | Toggle ingestion on/off |
-| `mappings` | Array of mapping rules |
-| `security` | Authentication configuration |
+| Field | Description |
+|---------------|-----------------------------------------------------------------|
+| `identifier` | Unique key for this webhook |
+| `title` | Human-readable name |
+| `description` | Purpose of the webhook |
+| `enabled` | Toggle ingestion on/off |
+| `mappings` | Array of mapping rules |
+| `security` | Authentication configuration using a `type` + `config` contract |
### Mapping Structure
-| Field | Description |
-| ------------------- | ------------------------------------------- |
-| `template` | Target Entity Template identifier |
-| `filter` | JQ expression to filter incoming payloads |
-| `entity.identifier` | JQ expression to generate entity identifier |
-| `entity.title` | JQ expression for entity title |
-| `entity.properties` | Map of property names to JQ expressions |
-| `entity.relations` | Map of relation names to JQ expressions |
+| Field | Description |
+|---------------------|-----------------------------------------------|
+| `template` | Target Entity Template identifier |
+| `filter` | JSLT expression to filter incoming payloads |
+| `entity.identifier` | JSLT expression to generate entity identifier |
+| `entity.title` | JSLT expression for entity title |
+| `entity.properties` | Map of property names to JSLT expressions |
+| `entity.relations` | Map of relation names to JSLT expressions |
---
@@ -165,9 +182,9 @@ spring:
---
-## JQ Mapping Reference
+## JSLT Mapping Reference
-The Internal Developer Platform will use [JQ](https://jqlang.github.io/jq/) for data transformation. It will access to the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Please refer to the JQ documentation for detailed usage.
+The Internal Developer Platform uses [JSLT](https://github.com/schibsted/jslt) for data transformation. It accesses the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Refer to the JSLT documentation for detailed usage.
---
@@ -201,8 +218,12 @@ Configure a webhook to receive GitHub repository events:
}
],
"security": {
- "signature_header_name": "X-Hub-Signature-256",
- "signature_value": "sha256=your-webhook-secret"
+ "type": "HMAC_SHA256",
+ "config": {
+ "header_name": "X-Hub-Signature-256",
+ "secret_alias": "GITHUB_WEBHOOK_SECRET",
+ "prefix": "sha256="
+ }
}
}
```
@@ -218,8 +239,11 @@ Webhooks support signature-based authentication:
```json
{
"security": {
- "signature_header_name": "X-Webhook-Signature",
- "signature_value": "expected-secret-or-hmac"
+ "type": "STATIC_TOKEN",
+ "config": {
+ "header_name": "X-Webhook-Signature",
+ "secret_alias": "WEBHOOK_SHARED_TOKEN"
+ }
}
}
```
diff --git a/docs/src/features/index.md b/docs/src/features/index.md
index 32deb947..42a7a559 100644
--- a/docs/src/features/index.md
+++ b/docs/src/features/index.md
@@ -13,7 +13,7 @@ The Internal Developer Platform provides a comprehensive set of features to buil
---
- Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JQ expressions.
+ Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JSLT expressions.
**Status:** 🕐 Planned
diff --git a/docs/src/features/self-service-actions.md b/docs/src/features/self-service-actions.md
index 5330bb6a..518a65ba 100644
--- a/docs/src/features/self-service-actions.md
+++ b/docs/src/features/self-service-actions.md
@@ -348,7 +348,7 @@ Use template expressions to inject dynamic values:
```bash
{{ .entity.identifier }} // Entity ID
-{{ .entity.title }} // Entity title
+{{ .entity.name }} // Entity name
{{ .entity.properties.owner }} // Entity property
{{ .entity.relations.team }} // Entity relation
```
diff --git a/docs/src/index.md b/docs/src/index.md
index 3e7bb9ff..e19bd660 100644
--- a/docs/src/index.md
+++ b/docs/src/index.md
@@ -131,7 +131,7 @@ Define your own **Entity Templates** that mirror your organization's specific ne
### Multi-Source Data Ingestion
-Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JQ expressions.
+Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JSLT expressions.
### Scorecards & Metrics
diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml
index 07eb8ccc..28befc80 100644
--- a/docs/src/static/swagger.yaml
+++ b/docs/src/static/swagger.yaml
@@ -11,14 +11,200 @@ security:
tags:
- name: Entity Graph
description: Entity relationship graph operations
+ - name: Inbound Webhook Management
+ description: Operations for managing inbound webhook connector configurations
- name: Entities Management
description: Operations related to entity management
- - name: Entities Templates Management
- description: Operations related to entity template management
- name: Audit
description: Operations related to audit history
+ - name: Entities Templates Management
+ description: Operations related to entity template management
+ - name: Entity dynamic mapping
+ description: Operations related to entity dynamic mapping management
paths:
- '/api/v1/entity-templates/{identifier}':
+ /api/v1/inbound_webhooks/{identifier}:
+ get:
+ tags:
+ - Inbound Webhook Management
+ summary: Get a webhook connector by identifier
+ description: Retrieve a specific webhook connector using its string identifier
+ operationId: getWebhookConnectorByIdentifier
+ parameters:
+ - name: identifier
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Webhook connector found
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/InboundWebhookDtoOut"
+ "404":
+ description: Webhook connector not found with the provided identifier
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ put:
+ tags:
+ - Inbound Webhook Management
+ summary: Update an existing webhook connector by identifier
+ description: Update the details of an existing webhook connector identified by
+ its unique string identifier
+ operationId: putWebhookConnector
+ parameters:
+ - name: identifier
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InboundWebhookUpdateDtoIn"
+ required: true
+ responses:
+ "200":
+ description: Webhook connector updated successfully
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/InboundWebhookDtoOut"
+ "400":
+ description: Invalid request payload
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "404":
+ description: Webhook connector not found with the provided identifier
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "409":
+ description: Webhook connector name already exists
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ delete:
+ tags:
+ - Inbound Webhook Management
+ summary: Delete a webhook connector by identifier
+ description: Remove a webhook connector from the system using its unique identifier
+ operationId: deleteWebhookConnector
+ parameters:
+ - name: identifier
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "204":
+ description: Webhook connector deleted successfully
+ "404":
+ description: Webhook connector not found with the provided identifier
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ /api/v1/entity_dynamic_mappings/{identifier}:
+ get:
+ tags:
+ - Entity dynamic mapping
+ summary: Get an entity dynamic mapping by identifier
+ description: Retrieve an entity dynamic mapping using its string identifier
+ operationId: getEntityDynamicMappingByIdentifier
+ parameters:
+ - name: identifier
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Entity dynamic mapping found
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/EntityDynamicMappingDtoOut"
+ "404":
+ description: Entity dynamic mapping not found with the provided identifier
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ put:
+ tags:
+ - Entity dynamic mapping
+ summary: Update an existing entity dynamic mapping by identifier
+ description: Update the details of an existing entity dynamic
+ mapping identified by its unique string identifier
+ operationId: updateEntityDynamicMapping
+ parameters:
+ - name: identifier
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EntityDynamicMappingUpdateDtoIn"
+ required: true
+ responses:
+ "200":
+ description: Entity dynamic mapping updated successfully
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/EntityDynamicMappingDtoOut"
+ "400":
+ description: Bad Request
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "404":
+ description: Entity dynamic mapping not found with the provided identifier
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "409":
+ description: Conflict
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ delete:
+ tags:
+ - Entity dynamic mapping
+ summary: Delete an entity dynamic mapping by identifier
+ description: Remove an entity dynamic from the system using its unique identifier
+ operationId: deleteEntityDynamicMapping
+ parameters:
+ - name: identifier
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "204":
+ description: Entity dynamic mapping deleted successfully
+ "404":
+ description: Entity dynamic mapping not found with the provided identifier
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ /api/v1/entity-templates/{identifier}:
get:
tags:
- Entities Templates Management
@@ -32,24 +218,23 @@ paths:
schema:
type: string
responses:
- '200':
+ "200":
description: Template found
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/EntityTemplateDtoOut'
- '404':
+ $ref: "#/components/schemas/EntityTemplateDtoOut"
+ "404":
description: Template not found with the provided identifier
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: "#/components/schemas/ErrorResponse"
put:
tags:
- Entities Templates Management
summary: Update an existing template by template identifier
- description: >-
- Update the details of an existing template identified by its unique
+ description: Update the details of an existing template identified by its unique
string identifier
operationId: updateTemplate
parameters:
@@ -62,21 +247,21 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/EntityTemplateUpdateDtoIn'
+ $ref: "#/components/schemas/EntityTemplateUpdateDtoIn"
required: true
responses:
- '200':
+ "200":
description: Template update successfully
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/EntityTemplateDtoOut'
- '404':
+ $ref: "#/components/schemas/EntityTemplateDtoOut"
+ "404":
description: Template not found with the provided identifier
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: "#/components/schemas/ErrorResponse"
delete:
tags:
- Entities Templates Management
@@ -90,22 +275,21 @@ paths:
schema:
type: string
responses:
- '204':
+ "204":
description: Template deleted successfully
- '404':
+ "404":
description: Template not found with the provided identifier
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '/api/v1/entities/{templateIdentifier}/{entityIdentifier}':
+ $ref: "#/components/schemas/ErrorResponse"
+ /api/v1/entities/{templateIdentifier}/{entityIdentifier}:
get:
tags:
- Entities Management
summary: Get entity by entity template and identifier
- description: >-
- Retrieve a specific entity using its string identifier and its template
- identifier
+ description: Retrieve a specific entity using its string identifier and its
+ template identifier
operationId: getEntity
parameters:
- name: templateIdentifier
@@ -119,18 +303,18 @@ paths:
schema:
type: string
responses:
- '200':
+ "200":
description: Entity found
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/EntityDtoOut'
- '404':
+ $ref: "#/components/schemas/EntityDtoOut"
+ "404":
description: Entity not found with the provided identifier
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: "#/components/schemas/ErrorResponse"
put:
tags:
- Entities Management
@@ -154,43 +338,42 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/EntityUpdateDtoIn'
+ $ref: "#/components/schemas/EntityUpdateDtoIn"
required: true
responses:
- '200':
+ "200":
description: Entity updated successfully
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/EntityDtoOut'
- '400':
+ $ref: "#/components/schemas/EntityDtoOut"
+ "400":
description: Invalid entity data provided
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '401':
+ $ref: "#/components/schemas/ErrorResponse"
+ "401":
description: Unauthorized - Missing or invalid token
- '403':
+ "403":
description: Insufficient rights
- '404':
+ "404":
description: Entity not found with the provided identifier
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '500':
+ $ref: "#/components/schemas/ErrorResponse"
+ "500":
description: Unexpected server-side failure
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: "#/components/schemas/ErrorResponse"
delete:
tags:
- Entities Management
summary: Delete an existing entity
- description: >-
- Delete an entity from the system using its template and entity
+ description: Delete an entity from the system using its template and entity
identifiers. This operation removes the entity and automatically cleans
up any relations from other entities that reference it.
operationId: deleteEntity
@@ -208,36 +391,193 @@ paths:
type: string
minLength: 1
responses:
- '204':
+ "204":
description: Entity deleted successfully
- '400':
+ "400":
description: Invalid entity data provided
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '401':
+ $ref: "#/components/schemas/ErrorResponse"
+ "401":
description: Unauthorized - Missing or invalid token
- '403':
+ "403":
description: Insufficient rights
- '404':
+ "404":
description: Entity not found with the provided identifier
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '409':
+ $ref: "#/components/schemas/ErrorResponse"
+ "409":
description: Target entity has required relations
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '500':
+ $ref: "#/components/schemas/ErrorResponse"
+ "500":
description: Unexpected server-side failure
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: "#/components/schemas/ErrorResponse"
+ /api/v1/inbound_webhooks:
+ get:
+ tags:
+ - Inbound Webhook Management
+ summary: Get paginated Webhook connectors
+ description: Retrieve a paginated list of webhook connectors with optional sorting
+ operationId: getWebhooksPaginated
+ parameters:
+ - name: page
+ in: query
+ description: Page number for pagination. Defaults to 0.
+ content:
+ "*/*":
+ schema:
+ type: integer
+ default: "0"
+ - name: size
+ in: query
+ description: Number of items per page. Defaults to 20.
+ content:
+ "*/*":
+ schema:
+ type: integer
+ default: "20"
+ - name: sort
+ in: query
+ description: "Sorting criteria in the format: property(,asc|desc). Defaults to
+ identifier,asc."
+ content:
+ "*/*":
+ schema:
+ type: string
+ default: identifier,asc
+ responses:
+ "200":
+ description: Paginated webhook connector retrieved successfully
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/WebhookConnectorPageResponse"
+ "400":
+ description: Invalid pagination parameters
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ post:
+ tags:
+ - Inbound Webhook Management
+ summary: Create a new webhook connector configuration
+ description: Creates a webhook connector configuration used by the generic
+ inbound webhook endpoint
+ operationId: createInboundWebhook
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InboundWebhookCreateDtoIn"
+ required: true
+ responses:
+ "201":
+ description: Webhook connector created
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/InboundWebhookDtoOut"
+ "400":
+ description: Invalid webhook connector data provided
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/InboundWebhookDtoOut"
+ "409":
+ description: Webhook connector already exists in this entityTemplateIdentifier
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/InboundWebhookDtoOut"
+ /api/v1/entity_dynamic_mappings:
+ get:
+ tags:
+ - Entity dynamic mapping
+ summary: Get paginated entity dynamic mappings
+ description: Retrieve a paginated list of entity dynamic mappings with optional
+ sorting
+ operationId: getEntityDynamicMappingPaginated
+ parameters:
+ - name: page
+ in: query
+ description: Page number for pagination. Defaults to 0.
+ content:
+ "*/*":
+ schema:
+ type: integer
+ default: "0"
+ - name: size
+ in: query
+ description: Number of items per page. Defaults to 20.
+ content:
+ "*/*":
+ schema:
+ type: integer
+ default: "20"
+ - name: sort
+ in: query
+ description: "Sorting criteria in the format: property(,asc|desc). Defaults to
+ identifier,asc."
+ content:
+ "*/*":
+ schema:
+ type: string
+ default: identifier,asc
+ responses:
+ "200":
+ description: Paginated entity dynamic mapping retrieved successfully
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/EntityDynamicMappingPageResponse"
+ "400":
+ description: Invalid pagination parameters
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ post:
+ tags:
+ - Entity dynamic mapping
+ summary: Create entity dynamic mapping
+ description: Creates a new entity dynamic mapping used by the generic inbound
+ webhook endpoint
+ operationId: createDynamicMapping
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EntityDynamicMappingCreateDtoIn"
+ required: true
+ responses:
+ "201":
+ description: Entity dynamic mapping created
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/EntityDynamicMappingDtoOut"
+ "400":
+ description: Invalid entity dynamic mapping data provided
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/EntityDynamicMappingDtoOut"
+ "409":
+ description: Identifier already exists
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/EntityDynamicMappingDtoOut"
/api/v1/entity-templates:
get:
tags:
@@ -250,41 +590,40 @@ paths:
in: query
description: Page number for pagination. Defaults to 0.
content:
- '*/*':
+ "*/*":
schema:
type: integer
- default: '0'
+ default: "0"
- name: size
in: query
description: Number of items per page. Defaults to 20.
content:
- '*/*':
+ "*/*":
schema:
type: integer
- default: '20'
+ default: "20"
- name: sort
in: query
- description: >-
- Sorting criteria in the format: property(,asc|desc). Defaults to
- identifier,asc.
+ description: "Sorting criteria in the format: property(,asc|desc). Defaults to
+ identifier,asc."
content:
- '*/*':
+ "*/*":
schema:
type: string
- default: 'identifier,asc'
+ default: identifier,asc
responses:
- '200':
+ "200":
description: Paginated templates retrieved successfully
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/TemplatePageResponse'
- '400':
+ $ref: "#/components/schemas/TemplatePageResponse"
+ "400":
description: Invalid pagination parameters
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: "#/components/schemas/ErrorResponse"
post:
tags:
- Entities Templates Management
@@ -295,22 +634,22 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/EntityTemplateCreateDtoIn'
+ $ref: "#/components/schemas/EntityTemplateCreateDtoIn"
required: true
responses:
- '201':
+ "201":
description: Template created successfully
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/EntityTemplateDtoOut'
- '400':
+ $ref: "#/components/schemas/EntityTemplateDtoOut"
+ "400":
description: Invalid template data provided
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '/api/v1/entities/{templateIdentifier}':
+ $ref: "#/components/schemas/ErrorResponse"
+ /api/v1/entities/{templateIdentifier}:
get:
tags:
- Entities Management
@@ -323,19 +662,19 @@ paths:
description: Page number for pagination. Defaults to 0.
required: false
content:
- '*/*':
+ "*/*":
schema:
type: integer
- default: '0'
+ default: "0"
- name: size
in: query
description: Number of items per page. Defaults to 20.
required: false
content:
- '*/*':
+ "*/*":
schema:
type: integer
- default: '20'
+ default: "20"
- name: templateIdentifier
in: path
required: true
@@ -349,32 +688,31 @@ paths:
with names containing 'idp'.
required: false
content:
- '*/*':
+ "*/*":
schema:
type: string
- name: sort
in: query
- description: >-
- Sorting criteria in the format: property(,asc|desc). Defaults to
- identifier,asc.
+ description: "Sorting criteria in the format: property(,asc|desc). Defaults to
+ identifier,asc."
content:
- '*/*':
+ "*/*":
schema:
type: string
- default: 'identifier,asc'
+ default: identifier,asc
responses:
- '200':
+ "200":
description: Paginated entities retrieved successfully
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/EntityPageResponse'
- '400':
+ $ref: "#/components/schemas/EntityPageResponse"
+ "400":
description: Invalid filter query syntax
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: "#/components/schemas/ErrorResponse"
post:
tags:
- Entities Management
@@ -392,80 +730,78 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/EntityCreateDtoIn'
+ $ref: "#/components/schemas/EntityCreateDtoIn"
required: true
responses:
- '201':
+ "201":
description: Entity created successfully
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/EntityDtoOut'
- '400':
+ $ref: "#/components/schemas/EntityDtoOut"
+ "400":
description: Invalid entity data provided
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '401':
+ $ref: "#/components/schemas/ErrorResponse"
+ "401":
description: Unauthorized - Missing or invalid token
- '403':
+ "403":
description: Insufficient rights
- '404':
+ "404":
description: Template not found with the provided identifier
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '409':
+ $ref: "#/components/schemas/ErrorResponse"
+ "409":
description: Entity already exists in this template
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '500':
+ $ref: "#/components/schemas/ErrorResponse"
+ "500":
description: Unexpected server-side failure
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: "#/components/schemas/ErrorResponse"
/api/v1/entities/search:
post:
tags:
- Entities Management
summary: Search entities
- description: >-
- Search for entities across all templates using nested filter queries.
- Supports complex logical compositions (AND / OR) of filter criteria on
- template, identifier, name, properties, relations, and reverse
- relations.
+ description: Search for entities across all templates using nested filter
+ queries. Supports complex logical compositions (AND / OR) of filter
+ criteria on template, identifier, name, properties, relations, and
+ reverse relations.
operationId: searchEntities
requestBody:
content:
application/json:
schema:
- $ref: '#/components/schemas/EntitySearchRequestDtoIn'
+ $ref: "#/components/schemas/EntitySearchRequestDtoIn"
required: true
responses:
- '200':
+ "200":
description: Entities retrieved successfully
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/EntityPageResponse'
- '400':
+ $ref: "#/components/schemas/EntityPageResponse"
+ "400":
description: Invalid search filter
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
- '/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph':
+ $ref: "#/components/schemas/ErrorResponse"
+ /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph:
get:
tags:
- Entity Graph
summary: Get entity relationship graph as flat nodes and edges
- description: >-
- Retrieves the entity relationship graph as a flat nodes-and-edges
+ description: Retrieves the entity relationship graph as a flat nodes-and-edges
structure, suitable for frontend visualization tools such as React Flow,
Vis.js, and Cytoscape.
operationId: getEntityGraph
@@ -484,9 +820,8 @@ paths:
minLength: 1
- name: depth
in: query
- description: >-
- Maximum traversal depth for relationship resolution. Clamped between
- 1 and 10.
+ description: Maximum traversal depth for relationship resolution. Clamped
+ between 1 and 6.
required: false
schema:
type: integer
@@ -494,8 +829,7 @@ paths:
default: 1
- name: include_data
in: query
- description: >-
- When true, each graph node includes a data object containing the
+ description: When true, each graph node includes a data object containing the
entity's property values. Defaults to false.
required: false
schema:
@@ -503,8 +837,7 @@ paths:
default: false
- name: traversal_mode
in: query
- description: >-
- Specifies the traversal mode for the entity graph. Defaults to
+ description: Specifies the traversal mode for the entity graph. Defaults to
DIRECT_LINEAGE.
required: false
schema:
@@ -516,8 +849,7 @@ paths:
- OUTBOUND_ONLY
- name: relations
in: query
- description: >-
- When provided, only relations whose name matches one of the listed
+ description: When provided, only relations whose name matches one of the listed
values are traversed and included. Omit to include all relations.
required: false
schema:
@@ -526,8 +858,7 @@ paths:
type: string
- name: properties
in: query
- description: >-
- When provided, each node's data object is restricted to the listed
+ description: When provided, each node's data object is restricted to the listed
property names. Requires include_data=true to have any effect. Omit
to include all properties.
required: false
@@ -536,24 +867,24 @@ paths:
items:
type: string
responses:
- '200':
+ "200":
description: Flat entity graph successfully retrieved
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/EntityGraphFlatDtoOut'
- '404':
+ $ref: "#/components/schemas/EntityGraphFlatDtoOut"
+ "404":
description: Entity not found with the provided identifier
content:
- '*/*':
+ "*/*":
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: "#/components/schemas/ErrorResponse"
/api/v1/audit/entities/{templateIdentifier}/{entityIdentifier}:
get:
tags:
- Audit
- summary: Get audit history
- description: Retrieve the complete audit history for a specific object,
+ summary: Get entity audit history
+ description: Retrieve the complete audit history for a specific entity,
including all revisions with timestamps and modification types
operationId: getEntityAuditHistory
parameters:
@@ -568,41 +899,179 @@ paths:
required: true
schema:
type: string
- minLength: 1
- responses:
- "200":
- description: Successfully retrieved entity audit history
- content:
- "*/*":
- schema:
- type: array
- items:
- $ref: "#/components/schemas/EntityAuditDtoOut"
- "400":
- description: Invalid template or entity identifier
- content:
- "*/*":
- schema:
- $ref: "#/components/schemas/ErrorResponse"
- "401":
- description: Unauthorized - Missing or invalid token
- "403":
- description: Insufficient rights
- "404":
- description: Template not found with the provided identifier or Entity not found
- with the provided identifier
- content:
- "*/*":
- schema:
- $ref: "#/components/schemas/ErrorResponse"
- "500":
- description: Unexpected server-side failure
- content:
- "*/*":
- schema:
- $ref: "#/components/schemas/ErrorResponse"
-components:
- schemas:
+ minLength: 1
+ responses:
+ "200":
+ description: Successfully retrieved entity audit history
+ content:
+ "*/*":
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/EntityAuditDtoOut"
+ "400":
+ description: Invalid template or entity identifier
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "401":
+ description: Unauthorized - Missing or invalid token
+ "403":
+ description: Insufficient rights
+ "404":
+ description: Template not found with the provided identifier or Entity not found
+ with the provided identifier
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "500":
+ description: Unexpected server-side failure
+ content:
+ "*/*":
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+components:
+ schemas:
+ InboundWebhookSecurityContractDtoIn:
+ type: object
+ properties:
+ type:
+ type: string
+ minLength: 1
+ config:
+ type: object
+ additionalProperties:
+ type: string
+ required:
+ - config
+ - type
+ InboundWebhookUpdateDtoIn:
+ type: object
+ properties:
+ name:
+ type: string
+ maxLength: 255
+ minLength: 0
+ description:
+ type: string
+ enabled:
+ type: boolean
+ mapping_identifiers:
+ type: array
+ items:
+ type: string
+ security:
+ $ref: "#/components/schemas/InboundWebhookSecurityContractDtoIn"
+ required:
+ - name
+ EntityDynamicMappingDtoOut:
+ type: object
+ properties:
+ identifier:
+ type: string
+ entityTemplateIdentifier:
+ type: string
+ filter:
+ type: string
+ name:
+ type: string
+ description:
+ type: string
+ entity:
+ $ref: "#/components/schemas/InboundWebhookEntityMappingDtoOut"
+ InboundWebhookDtoOut:
+ type: object
+ properties:
+ identifier:
+ type: string
+ name:
+ type: string
+ description:
+ type: string
+ enabled:
+ type: boolean
+ mappings:
+ type: array
+ items:
+ $ref: "#/components/schemas/EntityDynamicMappingDtoOut"
+ security:
+ $ref: "#/components/schemas/InboundWebhookSecurityDtoOut"
+ InboundWebhookEntityMappingDtoOut:
+ type: object
+ properties:
+ identifier:
+ type: string
+ name:
+ type: string
+ properties:
+ type: object
+ additionalProperties:
+ type: string
+ relations:
+ type: object
+ additionalProperties:
+ type: string
+ InboundWebhookSecurityDtoOut:
+ type: object
+ properties:
+ type:
+ type: string
+ config:
+ type: object
+ additionalProperties:
+ type: string
+ ErrorResponse:
+ type: object
+ properties:
+ error:
+ type: string
+ errorDescription:
+ type: string
+ EntityDynamicMappingUpdateDtoIn:
+ type: object
+ properties:
+ entity_template_identifier:
+ type: string
+ minLength: 1
+ filter:
+ type: string
+ minLength: 1
+ name:
+ type: string
+ minLength: 1
+ description:
+ type: string
+ entity:
+ $ref: "#/components/schemas/EntityMappingDtoIn"
+ required:
+ - entity
+ - entity_template_identifier
+ - filter
+ - name
+ EntityMappingDtoIn:
+ type: object
+ properties:
+ identifier:
+ type: string
+ minLength: 1
+ name:
+ type: string
+ minLength: 1
+ properties:
+ type: object
+ additionalProperties:
+ type: string
+ relations:
+ type: object
+ additionalProperties:
+ type: string
+ required:
+ - identifier
+ - name
+ - properties
+ - relations
EntityTemplateUpdateDtoIn:
type: object
description: Input DTO for updating an entity template
@@ -613,7 +1082,7 @@ components:
example: Service
maxLength: 255
minLength: 0
- pattern: '^[a-zA-Z0-9 _-]+$'
+ pattern: ^[a-zA-Z0-9 _-]+$
description:
type: string
description: Entity Template description
@@ -622,12 +1091,12 @@ components:
type: array
description: List of property definitions for this template
items:
- $ref: '#/components/schemas/PropertyDefinitionDtoIn'
+ $ref: "#/components/schemas/PropertyDefinitionDtoIn"
relations_definitions:
type: array
description: List of relation definitions for this template
items:
- $ref: '#/components/schemas/RelationDefinitionDtoIn'
+ $ref: "#/components/schemas/RelationDefinitionDtoIn"
required:
- name
PropertyDefinitionDtoIn:
@@ -658,7 +1127,7 @@ components:
description: Whether this property is required
example: true
rules:
- $ref: '#/components/schemas/PropertyRulesDtoIn'
+ $ref: "#/components/schemas/PropertyRulesDtoIn"
description: Property validation rules
required:
- description
@@ -686,7 +1155,7 @@ components:
regex:
type: string
description: Regular expression pattern for validation
- example: '^[a-zA-Z0-9]+$'
+ example: ^[a-zA-Z0-9]+$
max_length:
type: integer
format: int32
@@ -754,12 +1223,12 @@ components:
type: array
description: List of property definitions for this template
items:
- $ref: '#/components/schemas/PropertyDefinitionDtoOut'
+ $ref: "#/components/schemas/PropertyDefinitionDtoOut"
relations_definitions:
type: array
description: List of relation definitions for this template
items:
- $ref: '#/components/schemas/RelationDefinitionDtoOut'
+ $ref: "#/components/schemas/RelationDefinitionDtoOut"
PropertyDefinitionDtoOut:
type: object
description: Output DTO for property definition
@@ -810,7 +1279,7 @@ components:
regex:
type: string
description: Regular expression for property validation
- example: '^[A-Za-z0-9]+$'
+ example: ^[A-Za-z0-9]+$
max_length:
type: integer
format: int32
@@ -851,13 +1320,6 @@ components:
type: boolean
description: Whether this relation can have multiple targets
example: true
- ErrorResponse:
- type: object
- properties:
- error:
- type: string
- errorDescription:
- type: string
EntityUpdateDtoIn:
type: object
description: Input DTO for updating an entity
@@ -873,13 +1335,13 @@ components:
type: string
description: Map of property name to value for this entity
example:
- port: '8080'
+ port: "8080"
environment: dev
relations:
type: array
description: List of relations for this entity
items:
- $ref: '#/components/schemas/RelationDtoIn'
+ $ref: "#/components/schemas/RelationDtoIn"
required:
- name
RelationDtoIn:
@@ -919,13 +1381,13 @@ components:
additionalProperties:
type: array
items:
- $ref: '#/components/schemas/EntitySummaryDto'
+ $ref: "#/components/schemas/EntitySummaryDto"
relations_as_target:
type: object
additionalProperties:
type: array
items:
- $ref: '#/components/schemas/EntitySummaryDto'
+ $ref: "#/components/schemas/EntitySummaryDto"
EntitySummaryDto:
type: object
properties:
@@ -933,6 +1395,55 @@ components:
type: string
name:
type: string
+ InboundWebhookCreateDtoIn:
+ type: object
+ properties:
+ identifier:
+ type: string
+ maxLength: 255
+ minLength: 0
+ name:
+ type: string
+ maxLength: 255
+ minLength: 0
+ description:
+ type: string
+ enabled:
+ type: boolean
+ mapping_identifiers:
+ type: array
+ items:
+ type: string
+ security:
+ $ref: "#/components/schemas/InboundWebhookSecurityContractDtoIn"
+ required:
+ - identifier
+ - name
+ EntityDynamicMappingCreateDtoIn:
+ type: object
+ properties:
+ identifier:
+ type: string
+ minLength: 1
+ entity_template_identifier:
+ type: string
+ minLength: 1
+ filter:
+ type: string
+ minLength: 1
+ name:
+ type: string
+ minLength: 1
+ description:
+ type: string
+ entity:
+ $ref: "#/components/schemas/EntityMappingDtoIn"
+ required:
+ - entity
+ - entity_template_identifier
+ - filter
+ - identifier
+ - name
EntityTemplateCreateDtoIn:
type: object
description: Input DTO for creating an entity template
@@ -948,7 +1459,7 @@ components:
example: Service
maxLength: 255
minLength: 0
- pattern: '^[a-zA-Z0-9 _-]+$'
+ pattern: ^[a-zA-Z0-9 _-]+$
description:
type: string
description: Entity Template description
@@ -957,12 +1468,12 @@ components:
type: array
description: List of property definitions for this template
items:
- $ref: '#/components/schemas/PropertyDefinitionDtoIn'
+ $ref: "#/components/schemas/PropertyDefinitionDtoIn"
relations_definitions:
type: array
description: List of relation definitions for this template
items:
- $ref: '#/components/schemas/RelationDefinitionDtoIn'
+ $ref: "#/components/schemas/RelationDefinitionDtoIn"
required:
- identifier
- name
@@ -986,13 +1497,13 @@ components:
type: string
description: Map of property name to value for this entity
example:
- port: '8080'
+ port: "8080"
environment: dev
relations:
type: array
description: List of relations for this entity
items:
- $ref: '#/components/schemas/RelationDtoIn'
+ $ref: "#/components/schemas/RelationDtoIn"
required:
- identifier
- name
@@ -1002,15 +1513,13 @@ components:
properties:
query:
type: string
- description: >-
- Free-text search string. When present, returns entities whose
+ description: Free-text search string. When present, returns entities whose
identifier, name, templateIdentifier, or any property value contains
this string (case-insensitive). Can be combined with filter.
example: checkout
filter:
- $ref: '#/components/schemas/FilterNodeDtoIn'
- description: >-
- Root node of the search filter tree. May be omitted or null to
+ $ref: "#/components/schemas/FilterNodeDtoIn"
+ description: Root node of the search filter tree. May be omitted or null to
return all entities.
page:
type: integer
@@ -1026,48 +1535,41 @@ components:
example: 20
sort:
type: string
- description: >-
- Sorting criteria in the format: property(,asc|desc). Defaults to
- identifier,asc.
- example: 'identifier:asc'
+ description: "Sorting criteria in the format: property(,asc|desc). Defaults to
+ identifier,asc."
+ example: identifier:asc
FilterNodeDtoIn:
type: object
- description: >-
- A node in the search filter tree. Either a logical group (connector +
- criteria) or a leaf criterion (field + operation + value).
+ description: A node in the search filter tree. Either a logical group (connector
+ + criteria) or a leaf criterion (field + operation + value).
properties:
connector:
type: string
- description: >-
- Logical connector for a group node. One of: AND, OR. Required for
- group nodes.
+ description: "Logical connector for a group node. One of: AND, OR. Required for
+ group nodes."
example: AND
criteria:
type: array
- description: >-
- Child filter nodes for a group node. Required for group nodes (must
+ description: Child filter nodes for a group node. Required for group nodes (must
be non-empty).
items:
- $ref: '#/components/schemas/FilterNodeDtoIn'
+ $ref: "#/components/schemas/FilterNodeDtoIn"
field:
type: string
- description: >-
- Field to filter on for a criterion node. Required for leaf nodes.
+ description: "Field to filter on for a criterion node. Required for leaf nodes.
Examples: template, identifier, name, relation, property.language,
relation.api-link, relation.api-link.identifier,
- relations_as_target.api-link.name
+ relations_as_target.api-link.name"
example: template
operation:
type: string
- description: >-
- Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS,
+ description: "Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS,
NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for
- leaf nodes.
+ leaf nodes."
example: EQ
value:
type: string
- description: >-
- Value to compare against for a criterion node. Required for leaf
+ description: Value to compare against for a criterion node. Required for leaf
nodes.
example: microservice
EntityPageResponse:
@@ -1077,22 +1579,22 @@ components:
content:
type: array
items:
- $ref: '#/components/schemas/EntityDtoOut'
+ $ref: "#/components/schemas/EntityDtoOut"
pageable:
- $ref: '#/components/schemas/PageableObject'
+ $ref: "#/components/schemas/PageableObject"
+ last:
+ type: boolean
totalElements:
type: integer
format: int64
totalPages:
type: integer
format: int32
- last:
- type: boolean
- sort:
- $ref: '#/components/schemas/SortObject'
numberOfElements:
type: integer
format: int32
+ sort:
+ $ref: "#/components/schemas/SortObject"
first:
type: boolean
size:
@@ -1107,7 +1609,7 @@ components:
type: object
properties:
sort:
- $ref: '#/components/schemas/SortObject'
+ $ref: "#/components/schemas/SortObject"
paged:
type: boolean
pageNumber:
@@ -1130,29 +1632,95 @@ components:
type: boolean
empty:
type: boolean
- TemplatePageResponse:
+ WebhookConnectorPageResponse:
type: object
- description: Paginated response containing Template objects
+ description: Paginated response containing Inbound Webhook Connector objects
properties:
content:
type: array
items:
- $ref: '#/components/schemas/EntityTemplateDtoOut'
+ $ref: "#/components/schemas/InboundWebhookDtoOut"
pageable:
- $ref: '#/components/schemas/PageableObject'
+ $ref: "#/components/schemas/PageableObject"
+ last:
+ type: boolean
totalElements:
type: integer
format: int64
totalPages:
type: integer
format: int32
+ numberOfElements:
+ type: integer
+ format: int32
+ sort:
+ $ref: "#/components/schemas/SortObject"
+ first:
+ type: boolean
+ size:
+ type: integer
+ format: int32
+ number:
+ type: integer
+ format: int32
+ empty:
+ type: boolean
+ EntityDynamicMappingPageResponse:
+ type: object
+ description: Paginated response containing Entity Dynamic Mapping objects
+ properties:
+ content:
+ type: array
+ items:
+ $ref: "#/components/schemas/EntityDynamicMappingDtoOut"
+ pageable:
+ $ref: "#/components/schemas/PageableObject"
last:
type: boolean
+ totalElements:
+ type: integer
+ format: int64
+ totalPages:
+ type: integer
+ format: int32
+ numberOfElements:
+ type: integer
+ format: int32
sort:
- $ref: '#/components/schemas/SortObject'
+ $ref: "#/components/schemas/SortObject"
+ first:
+ type: boolean
+ size:
+ type: integer
+ format: int32
+ number:
+ type: integer
+ format: int32
+ empty:
+ type: boolean
+ TemplatePageResponse:
+ type: object
+ description: Paginated response containing Template objects
+ properties:
+ content:
+ type: array
+ items:
+ $ref: "#/components/schemas/EntityTemplateDtoOut"
+ pageable:
+ $ref: "#/components/schemas/PageableObject"
+ last:
+ type: boolean
+ totalElements:
+ type: integer
+ format: int64
+ totalPages:
+ type: integer
+ format: int32
numberOfElements:
type: integer
format: int32
+ sort:
+ $ref: "#/components/schemas/SortObject"
first:
type: boolean
size:
@@ -1163,6 +1731,54 @@ components:
format: int32
empty:
type: boolean
+ EntityGraphEdgeDtoOut:
+ type: object
+ properties:
+ id:
+ type: string
+ description: Unique edge identifier
+ source:
+ type: string
+ description: Node id of the source entity
+ target:
+ type: string
+ description: Node id of the target entity
+ type:
+ type: string
+ description: Relation name as defined in the entity template
+ EntityGraphFlatDtoOut:
+ type: object
+ properties:
+ nodes:
+ type: array
+ description: All entity nodes in the graph
+ items:
+ $ref: "#/components/schemas/EntityGraphNodeFlatDtoOut"
+ edges:
+ type: array
+ description: All directed relation edges in the graph
+ items:
+ $ref: "#/components/schemas/EntityGraphEdgeDtoOut"
+ EntityGraphNodeFlatDtoOut:
+ type: object
+ properties:
+ id:
+ type: string
+ description: Unique node identifier composed of templateIdentifier:identifier
+ label:
+ type: string
+ description: Human-readable entity name
+ template_identifier:
+ type: string
+ description: Template identifier this entity belongs to
+ identifier:
+ type: string
+ description: Business identifier of the entity within its template
+ data:
+ type: object
+ additionalProperties: {}
+ description: Entity property values keyed by property name; present only when
+ include_data=true is requested
EntityAuditDtoOut:
type: object
description: Audit information for an entity revision
@@ -1191,11 +1807,6 @@ components:
type: object
description: Snapshot of entity state at a specific audit revision
properties:
- id:
- type: string
- format: uuid
- description: Unique identifier
- example: 550e8400-e29b-41d4-a716-446655440000
template_identifier:
type: string
description: Template identifier
@@ -1218,20 +1829,10 @@ components:
description: Relations of the entity at this revision
items:
$ref: "#/components/schemas/RelationSnapshotDtoOut"
- modified_flags:
- type: object
- additionalProperties:
- type: boolean
- description: Map of modified flags (for example, 'name_mod', true)
PropertySnapshotDtoOut:
type: object
description: Snapshot of a property at a specific audit revision
properties:
- id:
- type: string
- format: uuid
- description: Unique identifier of the property
- example: 550e8400-e29b-41d4-a716-446655440000
name:
type: string
description: Name of the property matching a PropertyDefinition
@@ -1240,20 +1841,10 @@ components:
type: string
description: Value of the property at this revision
example: My service description
- modified_flags:
- type: object
- additionalProperties:
- type: boolean
- description: Map of modified flags (for example, 'name_mod', true, 'value_mod', false)
RelationSnapshotDtoOut:
type: object
description: Snapshot of a relation at a specific audit revision
properties:
- id:
- type: string
- format: uuid
- description: Unique identifier of the relation
- example: 550e8400-e29b-41d4-a716-446655440000
name:
type: string
description: Name of the relation matching a RelationDefinition
@@ -1270,60 +1861,6 @@ components:
- staging-cluster
items:
type: string
- modified_flags:
- type: object
- additionalProperties:
- type: boolean
- description: Map of modified flags (for example, 'name_mod', true, 'target_template_identifier_mod', false)
- EntityGraphEdgeDtoOut:
- type: object
- properties:
- id:
- type: string
- description: Unique edge identifier
- source:
- type: string
- description: Node id of the source entity
- target:
- type: string
- description: Node id of the target entity
- type:
- type: string
- description: Relation name as defined in the entity template
- EntityGraphFlatDtoOut:
- type: object
- properties:
- nodes:
- type: array
- description: All entity nodes in the graph
- items:
- $ref: '#/components/schemas/EntityGraphNodeFlatDtoOut'
- edges:
- type: array
- description: All directed relation edges in the graph
- items:
- $ref: '#/components/schemas/EntityGraphEdgeDtoOut'
- EntityGraphNodeFlatDtoOut:
- type: object
- properties:
- id:
- type: string
- description: 'Unique node identifier composed of templateIdentifier:identifier'
- label:
- type: string
- description: Human-readable entity name
- template_identifier:
- type: string
- description: Template identifier this entity belongs to
- identifier:
- type: string
- description: Business identifier of the entity within its template
- data:
- type: object
- additionalProperties: {}
- description: >-
- Entity property values keyed by property name; present only when
- include_data=true is requested
securitySchemes:
clientId:
type: oauth2
diff --git a/docs/zensical.toml b/docs/zensical.toml
index f72132b5..f9434c84 100644
--- a/docs/zensical.toml
+++ b/docs/zensical.toml
@@ -37,7 +37,8 @@ nav = [
"concepts/entity-filtering.md"
]},
"concepts/properties.md",
- "concepts/relations.md"
+ "concepts/relations.md",
+ "concepts/webhooks.md"
]},
{ "Features" = [
"features/index.md",
diff --git a/pom.xml b/pom.xml
index 6c9f57bd..bdac46d8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -246,6 +246,14 @@
3.20.0
+
+
+
+ com.schibsted.spt.data
+ jslt
+ 0.1.14
+
+
org.springframework.boot
spring-boot-starter-actuator
diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java
index e32977de..cf575833 100644
--- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java
+++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java
@@ -15,6 +15,7 @@ public class ValidationMessages {
public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank";
public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters";
public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores";
+ public static final String TEMPLATE_ALREADY_MAPPED = "Cannot delete template because it is currently mapped to '%s' entity dynamic mappings. Please remove the associated entity dynamic mappings before deleting the template.";
// Property Definition validation messages
public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank";
@@ -62,6 +63,15 @@ public class ValidationMessages {
public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank";
public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank";
+ // Webhook connector validation messages
+ public static final String WEBHOOK_CONNECTOR_ALREADY_EXIST = "Webhook Connector already exists with the same identifier";
+ public static final String WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY = "Webhook Connector identifier is mandatory and cannot be blank";
+ public static final String WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST = "Webhook Connector already exist with the same name";
+ public static final String WEBHOOK_IDENTIFIER_NOT_FOUND = "Target webhook with identifier '%s' does not exist";
+ public static final String ENTITY_DYNAMIC_MAPPING_NOT_FOUND = "Entity dynamic mapping with identifier '%s' does not exist";
+ public static final String ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS = "Entity dynamic mapping already exists with the same identifier '%s'";
+ public static final String ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE = "Entity dynamic mapping already in use, please remove it from the associated webhook connector '%s' before deleting it";
+
// Entity creation validation messages
public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'";
public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'";
@@ -114,4 +124,24 @@ public static String minMaxConstraintViolated(String constraint) {
public static final String SEARCH_NUMERIC_OPERATOR_INVALID_VALUE = "Value '%s' is not a valid number for operator '%s'";
public static final String SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH = "Property '%s' in template '%s' is of type %s; operators GT, GTE, LT, LTE require type NUMBER";
+ // Entity Dynamic mapping validation messages
+ public static final String ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY = "Webhook mapping filter is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY = "Entity dynamic mapping identifier is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY = "Entity dynamic mapping name is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template Identifier is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY = "Dynamic mapping entity section is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY = "Entity name is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MANDATORY = "Entity relations section is mandatory";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_PROPERTIES_MANDATORY = "Entity properties section is mandatory";
+
+ public static final String WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY = "Webhook security type is mandatory";
+ public static final String WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY = "Webhook security config is mandatory";
+ public static final String WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH = "Webhook identifier must not exceed 255 characters";
+ public static final String WEBHOOK_CONNECTOR_NAME_MAX_LENGTH = "Webhook name must not exceed 255 characters";
+ public static final String WEBHOOK_CONNECTOR_NAME_MANDATORY = "Webhook name is mandatory";
+ public static final String PROPERTY_NOT_EXPECTED_FORMAT = "Property '%s' does not match expected format";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_PROPERTIES_MISSING = "The mapping is missing required properties: %s";
+ public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MISSING = "The mapping is missing required relations: %s";
+
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java
new file mode 100644
index 00000000..3f37a79e
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyExistsException.java
@@ -0,0 +1,10 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS;
+
+public class EntityDynamicMappingAlreadyExistsException extends RuntimeException {
+
+ public EntityDynamicMappingAlreadyExistsException(String identifier) {
+ super(String.format(ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS, identifier));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java
new file mode 100644
index 00000000..63cfee53
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingAlreadyInUseException.java
@@ -0,0 +1,12 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE;
+
+import java.util.List;
+
+public class EntityDynamicMappingAlreadyInUseException extends RuntimeException {
+
+ public EntityDynamicMappingAlreadyInUseException(List identifier) {
+ super(ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE.formatted(identifier));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java
new file mode 100644
index 00000000..512c3f56
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingConfigurationException.java
@@ -0,0 +1,13 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+public class EntityDynamicMappingConfigurationException extends RuntimeException {
+
+ public EntityDynamicMappingConfigurationException(String message) {
+ super(message);
+ }
+
+ public EntityDynamicMappingConfigurationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoPropertiesException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoPropertiesException.java
new file mode 100644
index 00000000..26abc8b2
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoPropertiesException.java
@@ -0,0 +1,8 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+public class EntityDynamicMappingHasNoPropertiesException extends RuntimeException {
+
+ public EntityDynamicMappingHasNoPropertiesException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoRelationsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoRelationsException.java
new file mode 100644
index 00000000..ceb1152a
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingHasNoRelationsException.java
@@ -0,0 +1,8 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+public class EntityDynamicMappingHasNoRelationsException extends RuntimeException {
+
+ public EntityDynamicMappingHasNoRelationsException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java
new file mode 100644
index 00000000..7b5dadbc
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_dynamic_mapping/EntityDynamicMappingNotFoundException.java
@@ -0,0 +1,10 @@
+package com.decathlon.idp_core.domain.exception.entity_dynamic_mapping;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_NOT_FOUND;
+
+public class EntityDynamicMappingNotFoundException extends RuntimeException {
+
+ public EntityDynamicMappingNotFoundException(String identifier) {
+ super(String.format(ENTITY_DYNAMIC_MAPPING_NOT_FOUND, identifier));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateUsedByDynamicMappingException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateUsedByDynamicMappingException.java
new file mode 100644
index 00000000..ad5edb3c
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateUsedByDynamicMappingException.java
@@ -0,0 +1,32 @@
+package com.decathlon.idp_core.domain.exception.entity_template;
+
+import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
+
+/// Domain exception for missing [EntityTemplate] business entities.
+///
+/// **Business purpose:** Represents the business rule violation when attempting
+/// to access an EntityTemplate that doesn't exist in the system. This is a
+/// critical business error since entities cannot be created without valid templates.
+///
+/// **Exception design rationale:**
+/// - Multiple constructors support different lookup scenarios (ID, identifier, field-based)
+/// - Meaningful error messages aid in debugging and API error responses
+/// - Domain-level exception keeps business logic separate from HTTP concerns
+///
+/// **Usage patterns:**
+/// - Template validation before entity operations
+/// - Template-based entity queries
+/// - Template management operations
+public class EntityTemplateUsedByDynamicMappingException extends RuntimeException {
+
+ /// Constructs a new exception with a custom error message.
+ ///
+ /// **Why this exists:** Allows for specific error messages that provide more
+ /// context about the search criteria or operation that failed.
+ ///
+ /// @param message the detail message explaining what was not found
+ public EntityTemplateUsedByDynamicMappingException(String message) {
+ super(message);
+ }
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java
index 2ce1db43..151aa0ae 100644
--- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java
@@ -1,7 +1,5 @@
package com.decathlon.idp_core.domain.exception.entity_template;
-import com.decathlon.idp_core.domain.model.enums.PropertyType;
-
/// Domain exception for property rule validation violations.
///
/// **Business purpose:** Represents the business rule violation when property rules
@@ -18,7 +16,7 @@ public class PropertyDefinitionRulesConflictException extends RuntimeException {
/// @param propertyName the name of the property with invalid rules
/// @param propertyType the data type of the property
/// @param violationMessage detailed explanation of what rule is invalid
- public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType,
+ public PropertyDefinitionRulesConflictException(String propertyName, String propertyType,
String violationMessage) {
super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage);
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java
new file mode 100644
index 00000000..5658985b
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java
@@ -0,0 +1,7 @@
+package com.decathlon.idp_core.domain.exception.entity_template;
+
+public class PropertyNameNotFoundEntityTemplatePropertiesException extends RuntimeException {
+ public PropertyNameNotFoundEntityTemplatePropertiesException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java
index 44d8d2a7..0f1b685b 100644
--- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java
@@ -2,8 +2,6 @@
import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_CANNOT_CHANGE;
-import com.decathlon.idp_core.domain.model.enums.PropertyType;
-
/// Exception thrown when attempting any property type change.
///
/// This exception represents a business rule violation where type changes are blocked
@@ -20,8 +18,7 @@ public class PropertyTypeChangeException extends RuntimeException {
/// @param propertyName the name of the property whose type is being changed
/// @param fromType the current property type
/// @param toType the requested new property type
- public PropertyTypeChangeException(String propertyName, PropertyType fromType,
- PropertyType toType) {
+ public PropertyTypeChangeException(String propertyName, String fromType, String toType) {
super(String.format(PROPERTY_TYPE_CANNOT_CHANGE, propertyName, fromType, toType));
}
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java
new file mode 100644
index 00000000..3ac2b906
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java
@@ -0,0 +1,7 @@
+package com.decathlon.idp_core.domain.exception.entity_template;
+
+public class RelationNameNotFoundEntityTemplateRelationsException extends RuntimeException {
+ public RelationNameNotFoundEntityTemplateRelationsException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java
index 737c7c84..6f4ddd0d 100644
--- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java
@@ -1,7 +1,5 @@
package com.decathlon.idp_core.domain.exception.property;
-import com.decathlon.idp_core.domain.model.enums.PropertyType;
-
/// Domain exception for property rule validation violations.
///
/// **Business purpose:** Represents the business rule violation when property rules
@@ -18,7 +16,7 @@ public class PropertyDefinitionRulesConflictException extends RuntimeException {
/// @param propertyName the name of the property with invalid rules
/// @param propertyType the data type of the property
/// @param violationMessage detailed explanation of what rule is invalid
- public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType,
+ public PropertyDefinitionRulesConflictException(String propertyName, String propertyType,
String violationMessage) {
super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage);
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java
new file mode 100644
index 00000000..eafbc81a
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java
@@ -0,0 +1,11 @@
+package com.decathlon.idp_core.domain.exception.webhook;
+
+public class WebhookAuthenticationException extends RuntimeException {
+ public WebhookAuthenticationException(String message) {
+ super(message);
+ }
+
+ public WebhookAuthenticationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java
new file mode 100644
index 00000000..26b07865
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java
@@ -0,0 +1,10 @@
+package com.decathlon.idp_core.domain.exception.webhook;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_ALREADY_EXIST;
+
+public class WebhookConnectorAlreadyExistException extends RuntimeException {
+
+ public WebhookConnectorAlreadyExistException(String identifier) {
+ super(String.format("%s:%s", WEBHOOK_CONNECTOR_ALREADY_EXIST, identifier));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java
new file mode 100644
index 00000000..8e848a26
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java
@@ -0,0 +1,7 @@
+package com.decathlon.idp_core.domain.exception.webhook;
+
+public class WebhookConnectorConfigurationException extends RuntimeException {
+ public WebhookConnectorConfigurationException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java
new file mode 100644
index 00000000..bd9fc6bc
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java
@@ -0,0 +1,10 @@
+package com.decathlon.idp_core.domain.exception.webhook;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_IDENTIFIER_NOT_FOUND;
+
+public class WebhookConnectorNotFoundException extends RuntimeException {
+
+ public WebhookConnectorNotFoundException(String identifier) {
+ super(String.format(WEBHOOK_IDENTIFIER_NOT_FOUND, identifier));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java
new file mode 100644
index 00000000..da65c822
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java
@@ -0,0 +1,9 @@
+package com.decathlon.idp_core.domain.exception.webhook;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST;
+
+public class WebhookConnectorTitleAlreadyExistsException extends RuntimeException {
+ public WebhookConnectorTitleAlreadyExistsException(String webhookName) {
+ super(String.format("%s:%s", WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST, webhookName));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java
new file mode 100644
index 00000000..6bc70eb0
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java
@@ -0,0 +1,8 @@
+package com.decathlon.idp_core.domain.exception.webhook;
+
+public class WebhookSecurityConfigurationException extends RuntimeException {
+
+ public WebhookSecurityConfigurationException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java
new file mode 100644
index 00000000..8d13c103
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java
@@ -0,0 +1,55 @@
+package com.decathlon.idp_core.domain.model.entity_mapping;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import java.util.Map;
+import java.util.UUID;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException;
+
+/// Domain model representing dynamic entity mapping configuration.
+///
+/// Each mapping defines how to transform inbound webhook events into entity instances,
+/// including property/relation mappings and JSLT transformation rules.
+///
+/// Note: The technical ID is managed purely at the infrastructure layer
+/// (persisted in entity_dynamic_mapping table) and is NOT part of the domain model.
+public record EntityDynamicMapping(UUID id, String identifier, String entityTemplateIdentifier,
+ String filter, String name, String description, String entityIdentifier, String entityName,
+ Map properties, Map relations) {
+
+ public EntityDynamicMapping {
+ if (isBlank(identifier)) {
+ throw new EntityDynamicMappingConfigurationException(
+ ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY);
+ }
+ if (isBlank(name)) {
+ throw new EntityDynamicMappingConfigurationException(ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY);
+ }
+ if (isBlank(entityTemplateIdentifier)) {
+ throw new EntityDynamicMappingConfigurationException(
+ ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY);
+ }
+
+ if (isBlank(filter)) {
+ throw new EntityDynamicMappingConfigurationException(ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY);
+ }
+
+ if (isBlank(entityIdentifier)) {
+ throw new EntityDynamicMappingConfigurationException(
+ ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY);
+ }
+
+ if (isBlank(entityName)) {
+ throw new EntityDynamicMappingConfigurationException(
+ ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY);
+ }
+
+ properties = properties == null ? Map.of() : Map.copyOf(properties);
+ relations = relations == null ? Map.of() : Map.copyOf(relations);
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java
new file mode 100644
index 00000000..992e7dc8
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java
@@ -0,0 +1,16 @@
+package com.decathlon.idp_core.domain.model.enums;
+
+/// Discriminator for the security validation strategy of a [WebhookConnector].
+///
+/// | Strategy | headerName | secretAlias | prefix | username | jwksUri |
+/// |--------------|------------|--------------------|----------|----------|---------|
+/// | HMAC_SHA256 | Required | Required (hash key)| Optional | — | — |
+/// | JWT_BEARER | — | — | — | — | Required|
+/// | STATIC_TOKEN | Required | Required (target) | — | — | — |
+/// | BASIC_AUTH | — | Required (password)| — | Required | — |
+/// | NONE | — | — | — | — | — |
+///
+/// `NONE` means the connector intentionally accepts unauthenticated requests.
+public enum WebhookSecurityType {
+ HMAC_SHA256, JWT_BEARER, STATIC_TOKEN, BASIC_AUTH, NONE
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java
new file mode 100644
index 00000000..828a9207
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java
@@ -0,0 +1,47 @@
+package com.decathlon.idp_core.domain.model.inbound_connectors.webhook;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import java.util.List;
+import java.util.UUID;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorConfigurationException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+
+public record WebhookConnector(UUID id, String identifier, String name, String description,
+ boolean enabled, List mappings, WebhookSecurity security) {
+ public WebhookConnector {
+ mappings = mappings == null ? List.of() : List.copyOf(mappings);
+
+ if (security == null) {
+ throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY);
+ }
+
+ if (isBlank(identifier)) {
+ throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY);
+ }
+ if (identifier.length() > 255) {
+ throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH);
+ }
+
+ if (isBlank(name)) {
+ throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_NAME_MANDATORY);
+ }
+ if (name.length() > 255) {
+ throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_NAME_MAX_LENGTH);
+ }
+ }
+
+ /// Creates a copy of this connector with the specified enabled state.
+ ///
+ /// @param enabled the new enabled state
+ /// @return a new WebhookConnector instance with the updated enabled field
+ public WebhookConnector withEnabled(boolean enabled) {
+ return new WebhookConnector(id, identifier, name, description, enabled, mappings, security);
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java
new file mode 100644
index 00000000..9aba1e97
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java
@@ -0,0 +1,22 @@
+package com.decathlon.idp_core.domain.model.inbound_connectors.webhook;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY;
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY;
+
+import java.util.Map;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+
+public record WebhookSecurity(WebhookSecurityType type, Map config) {
+
+ public WebhookSecurity {
+ if (type == null) {
+ throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY);
+ }
+ if (config == null) {
+ throw new WebhookSecurityConfigurationException(WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY);
+ }
+ config = Map.copyOf(config);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java
new file mode 100644
index 00000000..9f8ff75e
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java
@@ -0,0 +1,22 @@
+package com.decathlon.idp_core.domain.model.inbound_connectors.webhook;
+
+import java.util.UUID;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
+
+/// Domain model representing the mapping between a webhook event and an entity entityTemplateIdentifier.
+///
+/// Per the webhook_template_mapping schema:
+/// - Links a webhook connector to an entity entityTemplateIdentifier for event ingestion
+/// - Contains the JSLT filter to apply during transformation
+/// - Includes both technical IDs (from persistence) and functional domain objects
+///
+/// @param id technical identifier of the mapping record
+/// @param webhookConnector domain model of the associated webhook connector
+/// @param entityTemplate domain model of the target entity entityTemplateIdentifier
+/// @param entityDynamicMapping domain model of the dynamic mapping configuration
+/// @param jsltFilter JSLT filter expression for event ingestion
+public record WebhookTemplateMapping(UUID id, WebhookConnector webhookConnector,
+ EntityTemplate entityTemplate, EntityDynamicMapping entityDynamicMapping, String jsltFilter) {
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java
new file mode 100644
index 00000000..7c55a86f
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java
@@ -0,0 +1,8 @@
+package com.decathlon.idp_core.domain.port;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+
+public interface EntityDynamicMapperValidator {
+
+ void validate(EntityDynamicMapping mapping);
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java
new file mode 100644
index 00000000..e11af1c6
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java
@@ -0,0 +1,29 @@
+package com.decathlon.idp_core.domain.port;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+
+public interface EntityDynamicMappingPort {
+
+ List findByEntityTemplateIdentifier(String templateIdentifier);
+ List findByEntityTemplateId(UUID templateId);
+
+ Boolean existsByEntityTemplateIdentifier(String templateIdentifier);
+
+ boolean existsByIdentifier(String identifier);
+
+ Optional findByIdentifier(String identifier);
+
+ EntityDynamicMapping save(EntityDynamicMapping entityDynamicMapping);
+
+ Page findAll(Pageable pageable);
+
+ void deleteByIdentifier(String identifier);
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java
index 5f4f9106..0ffcebc2 100644
--- a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java
+++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java
@@ -35,4 +35,6 @@ public interface EntityTemplateRepositoryPort {
EntityTemplate save(EntityTemplate entityTemplate);
void deleteByIdentifier(String identifier);
+
+ boolean existsById(UUID id);
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java
new file mode 100644
index 00000000..fba415ae
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java
@@ -0,0 +1,23 @@
+package com.decathlon.idp_core.domain.port;
+
+import java.util.Optional;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+
+public interface WebhookConnectorRepositoryPort {
+
+ Optional findByIdentifier(String identifier);
+
+ Page findAll(Pageable pageable);
+
+ boolean existsByIdentifier(String identifier);
+
+ boolean existsByTitle(String title);
+
+ WebhookConnector save(WebhookConnector connector);
+
+ void deleteByIdentifier(String identifier);
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java
new file mode 100644
index 00000000..afa3be65
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookMappingLinkPort.java
@@ -0,0 +1,14 @@
+package com.decathlon.idp_core.domain.port;
+
+import java.util.List;
+import java.util.UUID;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping;
+
+public interface WebhookMappingLinkPort {
+
+ boolean existsByEntityMappingId(UUID id);
+
+ List findByEntityMappingId(UUID id);
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java
new file mode 100644
index 00000000..1a7868f3
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java
@@ -0,0 +1,30 @@
+package com.decathlon.idp_core.domain.port;
+
+import java.util.Map;
+
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+
+/// Unified strategy contract for webhook security handling.
+///
+/// This interface consolidates two responsibilities that were previously scattered:
+/// 1. Validating security configuration at creation/update time
+/// 2. Validating incoming webhook requests at runtime
+///
+/// Implementations should focus on security logic without side effects.
+public interface WebhookSecurityStrategy {
+
+ /// Checks if this strategy supports the given security type.
+ ///
+ /// @param securityType the security type to check
+ /// @return true if this strategy handles this security type
+ boolean supports(WebhookSecurityType securityType);
+
+ /// Validates the security configuration provided at creation/update time.
+ ///
+ /// @param config the security configuration map (e.g., username, secret_alias)
+ /// @throws
+ /// com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException
+ /// if validation fails
+ void validateConfiguration(Map config);
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingService.java
new file mode 100644
index 00000000..dfbfc685
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingService.java
@@ -0,0 +1,111 @@
+package com.decathlon.idp_core.domain.service.entity_dynamic_mapping;
+
+import java.util.List;
+import java.util.UUID;
+
+import jakarta.transaction.Transactional;
+import jakarta.validation.Valid;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyExistsException;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingAlreadyInUseException;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
+import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Validated
+@RequiredArgsConstructor
+public class EntityDynamicMappingService {
+
+ private final EntityDynamicMappingPort entityDynamicMappingPort;
+ private final WebhookMappingLinkPort webhookTemplateMappingPort;
+ private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService;
+
+ @Transactional
+ public EntityDynamicMapping createEntityDynamicMapping(
+ EntityDynamicMapping entityDynamicMapping) {
+ validateIdentifierUniqueness(entityDynamicMapping.identifier());
+ webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping);
+ return entityDynamicMappingPort.save(entityDynamicMapping);
+ }
+
+ public Page getAllEntityDynamicMapping(Pageable pageable) {
+ return entityDynamicMappingPort.findAll(pageable);
+ }
+
+ @Transactional
+ public void deleteEntityDynamicMapping(String entityDynamicMappingIdentifier) {
+ validateIdentifierExists(entityDynamicMappingIdentifier);
+ UUID dynamicMappingIdentifier = entityDynamicMappingPort
+ .findByIdentifier(entityDynamicMappingIdentifier).orElseThrow(
+ () -> new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier))
+ .id();
+ validateIsNotInUse(dynamicMappingIdentifier);
+ entityDynamicMappingPort.deleteByIdentifier(entityDynamicMappingIdentifier);
+ }
+
+ private void validateIsNotInUse(UUID entityDynamicMappingId) {
+ if (webhookTemplateMappingPort.existsByEntityMappingId(entityDynamicMappingId)) {
+ List webhookTemplateMappingList = webhookTemplateMappingPort
+ .findByEntityMappingId(entityDynamicMappingId);
+ List webhookIdentifiers = webhookTemplateMappingList.stream()
+ .map(WebhookTemplateMapping::webhookConnector)
+ .filter(webhook -> webhook != null && webhook.identifier() != null)
+ .map(WebhookConnector::identifier).distinct().toList();
+ throw new EntityDynamicMappingAlreadyInUseException(webhookIdentifiers);
+ }
+ }
+
+ private void validateIdentifierExists(String entityDynamicMappingIdentifier) {
+ if (!entityDynamicMappingPort.existsByIdentifier(entityDynamicMappingIdentifier)) {
+ throw new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier);
+ }
+ }
+
+ /// Ensures no other dynamic mapping already uses the provided identifier.
+ ///
+ /// This enforces the `entity_dynamic_mapping_identifier_key` unique constraint
+ /// at the domain level, returning a meaningful conflict instead of letting the
+ /// database raise a low-level integrity violation.
+ ///
+ /// @param identifier the candidate mapping identifier
+ /// @throws EntityDynamicMappingAlreadyExistsException when the identifier is
+ /// already used
+ private void validateIdentifierUniqueness(String identifier) {
+ if (entityDynamicMappingPort.existsByIdentifier(identifier)) {
+ throw new EntityDynamicMappingAlreadyExistsException(identifier);
+ }
+ }
+
+ public EntityDynamicMapping getEntityDynamicMapping(String identifier) {
+ return entityDynamicMappingPort.findByIdentifier(identifier)
+ .orElseThrow(() -> new EntityDynamicMappingNotFoundException(identifier));
+ }
+
+ @Transactional
+ public EntityDynamicMapping updateEntityDynamicMapping(String identifier,
+ @Valid EntityDynamicMapping entityDynamicMapping) {
+ EntityDynamicMapping existingMapping = getEntityDynamicMapping(identifier);
+ webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping);
+
+ EntityDynamicMapping mergedMapping = new EntityDynamicMapping(existingMapping.id(),
+ existingMapping.identifier(), entityDynamicMapping.entityTemplateIdentifier(),
+ entityDynamicMapping.filter(), entityDynamicMapping.name(),
+ entityDynamicMapping.description(), entityDynamicMapping.entityIdentifier(),
+ entityDynamicMapping.entityName(), entityDynamicMapping.properties(),
+ entityDynamicMapping.relations());
+
+ return entityDynamicMappingPort.save(mergedMapping);
+ }
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java
new file mode 100644
index 00000000..eafde38c
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_dynamic_mapping/EntityDynamicMappingValidationService.java
@@ -0,0 +1,55 @@
+package com.decathlon.idp_core.domain.service.entity_dynamic_mapping;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
+import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator;
+import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService;
+import com.decathlon.idp_core.domain.service.property.PropertyValidationService;
+import com.decathlon.idp_core.domain.service.relation.RelationValidationService;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Validated
+@RequiredArgsConstructor
+public class EntityDynamicMappingValidationService {
+
+ private final EntityTemplateService entityTemplateService;
+ private final EntityDynamicMapperValidator entityDynamicMapperValidator;
+ private final PropertyValidationService propertyValidationService;
+ private final RelationValidationService relationValidationService;
+
+ public void validateMappings(List mappings) {
+ mappings.forEach(this::validateMapping);
+ }
+
+ public void validateMapping(EntityDynamicMapping entityDynamicMapping) {
+ String templateIdentifier = entityDynamicMapping.entityTemplateIdentifier();
+ EntityTemplate entityTemplate = entityTemplateService
+ .getEntityTemplateByIdentifier(templateIdentifier);
+
+ Map properties = entityDynamicMapping.properties() != null
+ ? entityDynamicMapping.properties()
+ : Collections.emptyMap();
+ Map relations = entityDynamicMapping.relations() != null
+ ? entityDynamicMapping.relations()
+ : Collections.emptyMap();
+
+ // Validate properties against template
+ propertyValidationService.validateMappingPropertiesAgainstTemplate(entityTemplate,
+ List.copyOf(properties.keySet()));
+
+ // Validate relations against template
+ relationValidationService.validateMappingRelationsAgainstTemplate(entityTemplate,
+ List.copyOf(relations.keySet()));
+
+ entityDynamicMapperValidator.validate(entityDynamicMapping);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java
index 4ec24faa..9d3d4371 100644
--- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java
+++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java
@@ -1,21 +1,17 @@
package com.decathlon.idp_core.domain.service.entity_template;
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_MAPPED;
+
+import java.util.List;
import java.util.Objects;
import org.springframework.stereotype.Service;
-import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException;
-import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException;
-import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException;
-import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException;
-import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException;
-import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException;
-import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException;
-import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException;
-import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException;
-import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException;
+import com.decathlon.idp_core.domain.exception.entity_template.*;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort;
import lombok.RequiredArgsConstructor;
@@ -34,6 +30,7 @@ public class EntityTemplateValidationService {
private final EntityTemplateRepositoryPort entityTemplateRepositoryPort;
private final PropertyDefinitionValidationService propertyDefinitionValidationService;
private final RelationDefinitionValidationService relationDefinitionValidationService;
+ private final EntityDynamicMappingPort entityDynamicMappingPort;
/// Validates all business rules before creating a new entity template.
///
@@ -139,6 +136,33 @@ public void validateForDeletion(String identifier) {
throw new EntityTemplateNotFoundException("identifier", "null");
}
validateTemplateExists(identifier);
+ validateTemplateIsNotUsedInEntityDynamicMapper(identifier);
+ }
+
+ /// Validates that the entity template is not currently referenced by any
+ /// dynamic mapping.
+ ///
+ /// Design Note: This check relies directly on the `EntityDynamicMappingPort`
+ /// rather than
+ /// injecting `DynamicMappingService`. This is a deliberate architectural choice
+ /// to prevent
+ /// a circular dependency between our domain services (TemplateValidation ->
+ /// DynamicMapping -> TemplateValidation).
+ /// It allows the template domain to enforce its own referential integrity
+ /// before deletion
+ /// while remaining loosely coupled.
+ ///
+ /// **Precondition:** Template existence must be validated before calling this
+ /// method (via [#validateTemplateExists]).
+ public void validateTemplateIsNotUsedInEntityDynamicMapper(String entityTemplateIdentifier) {
+ List mappings = entityDynamicMappingPort
+ .findByEntityTemplateIdentifier(entityTemplateIdentifier);
+ if (!mappings.isEmpty()) {
+ List mappingIdentifiers = mappings.stream().map(EntityDynamicMapping::identifier)
+ .toList();
+ throw new EntityTemplateUsedByDynamicMappingException(
+ TEMPLATE_ALREADY_MAPPED.formatted(mappingIdentifiers));
+ }
}
/// Checks that the entity template exists.
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java
index d61a4bda..8063c293 100644
--- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java
+++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java
@@ -102,7 +102,8 @@ public void validateTypeChanges(List existingProperties,
boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type());
if (propertyTypeChanged) {
- throw new PropertyTypeChangeException(existing.name(), existing.type(), updated.type());
+ throw new PropertyTypeChangeException(existing.name(), existing.type().name(),
+ updated.type().name());
}
}
}
@@ -177,18 +178,18 @@ private void validateStringPropertyRules(String propertyName, PropertyRules rule
private void validateStringConstraints(String propertyName, PropertyRules rules) {
// Validate min_length is non-negative
if (rules.minLength() != null && rules.minLength() < 0) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE);
}
// Validate max_length is not zero or negative
if (rules.maxLength() != null && rules.maxLength() <= 0) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
PROPERTY_RULES_MAX_LENGTH_POSITIVE);
}
// Validate min_length is below or equal to max_length
if (rules.minLength() != null && rules.maxLength() != null
&& rules.minLength() > rules.maxLength()) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
minMaxConstraintViolated(LENGTH));
}
}
@@ -210,31 +211,31 @@ private void validateStringIncompatibleRules(String propertyName, PropertyRules
// Reject numeric rules for STRING type
if (rules.maxValue() != null || rules.minValue() != null) {
String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE;
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName));
}
// format, regex, and enum_values are incompatible with each other
if (rules.format() != null && rules.enumValues() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
rulesAreIncompatible(FORMAT, ENUM_VALUES));
}
if (rules.format() != null && rules.regex() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
rulesAreIncompatible(FORMAT, REGEX));
}
if (rules.regex() != null && rules.enumValues() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
rulesAreIncompatible(REGEX, ENUM_VALUES));
}
// enum_values and length constraints are incompatible with each other
if (rules.enumValues() != null && rules.maxLength() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH));
}
if (rules.enumValues() != null && rules.minLength() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH));
}
@@ -254,33 +255,33 @@ private void validateStringIncompatibleRules(String propertyName, PropertyRules
/// or min/max value constraints are violated
private void validateNumberPropertyRules(String propertyName, PropertyRules rules) {
if (rules.format() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(),
ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()));
}
if (rules.enumValues() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(),
ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()));
}
if (rules.regex() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(),
ruleNotAllowed(REGEX, PropertyType.NUMBER.name()));
}
if (rules.minLength() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(),
ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()));
}
if (rules.maxLength() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(),
ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()));
}
if (rules.minValue() != null && rules.maxValue() != null
&& rules.minValue() > rules.maxValue()) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER.name(),
minMaxConstraintViolated(VALUE));
}
}
@@ -299,7 +300,7 @@ private void validateBooleanPropertyRules(String propertyName, PropertyRules rul
|| rules.maxLength() != null || rules.minLength() != null || rules.maxValue() != null
|| rules.minValue() != null) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN.name(),
PROPERTY_RULES_BOOLEAN_NOT_ALLOWED);
}
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java
index ee4aca98..7a98f8d3 100644
--- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java
+++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java
@@ -52,12 +52,12 @@ public class PropertyRegexValidationService {
/// @throws PropertyDefinitionRulesConflictException if any security check fails
public void validateRegexPattern(String propertyName, String regexPattern) {
if (regexPattern.length() > MAX_REGEX_LENGTH) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
"Regex pattern too long (max " + MAX_REGEX_LENGTH + " characters)");
}
if (containsDangerousPatterns(regexPattern)) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
"Regex pattern contains potentially unsafe constructs");
}
@@ -65,7 +65,7 @@ public void validateRegexPattern(String propertyName, String regexPattern) {
try {
compiledRegexPattern = Pattern.compile(regexPattern);
} catch (PatternSyntaxException e) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
"Invalid regex pattern: " + e.getMessage());
}
@@ -91,14 +91,14 @@ private void validatePatternWithTimeout(String propertyName, Pattern pattern) {
future.get(VALIDATION_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} catch (TimeoutException _) {
future.cancel(true);
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
"Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)");
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
"Regex pattern validation was interrupted");
} catch (ExecutionException e) {
- throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING,
+ throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING.name(),
"Regex validation failed: " + e.getCause().getMessage());
}
}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java
index f1e8ff44..f7628a49 100644
--- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java
+++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java
@@ -1,27 +1,20 @@
package com.decathlon.idp_core.domain.service.property;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_ENUM_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_FORMAT_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REGEX_VIOLATION;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING;
-import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_MISMATCH;
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
-import com.decathlon.idp_core.domain.exception.entity.EntityValidationException;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoPropertiesException;
+import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException;
import com.decathlon.idp_core.domain.model.entity.Property;
import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition;
@@ -76,6 +69,7 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition,
/// provided and non-blank. If a required property is missing, adds a violation.
/// If the property is present, validates its value against the definition's
/// rules and accumulates any violations found.
+ ///
/// @param template the entity template whose property definitions are used for
/// validation
/// @param definitions the list of property definitions from the template
@@ -118,6 +112,53 @@ public void validatePropertiesAgainstTemplate(final EntityTemplate template,
}
}
+ public void validateMappingPropertiesAgainstTemplate(EntityTemplate template,
+ List mappedPropertyNames) {
+ validateNamesExistInTemplate(template, mappedPropertyNames);
+ validateRequiredPropertiesAreMapped(template, mappedPropertyNames);
+ }
+
+ private void validateNamesExistInTemplate(EntityTemplate template, List propertyNames) {
+ if (propertyNames == null || propertyNames.isEmpty()) {
+ return;
+ }
+
+ Set definedPropertyNames = getDefinedPropertyNames(template);
+
+ propertyNames.stream().filter(name -> !definedPropertyNames.contains(name)).findFirst()
+ .ifPresent(name -> {
+ throw new PropertyNameNotFoundEntityTemplatePropertiesException(
+ PROPERTY_NOT_EXPECTED_FORMAT.formatted(name));
+ });
+ }
+
+ /// Validates that all required property definitions in the template are mapped.
+ private void validateRequiredPropertiesAreMapped(EntityTemplate template,
+ List mappedPropertyNames) {
+ List definitions = template.propertiesDefinitions() != null
+ ? template.propertiesDefinitions()
+ : List.of();
+
+ List mappedNames = mappedPropertyNames != null ? mappedPropertyNames : List.of();
+
+ List missingProperties = definitions.stream().filter(PropertyDefinition::required)
+ .map(PropertyDefinition::name).filter(requiredName -> !mappedNames.contains(requiredName))
+ .toList();
+
+ if (!missingProperties.isEmpty()) {
+ throw new EntityDynamicMappingHasNoPropertiesException(
+ String.format(ENTITY_DYNAMIC_MAPPING_ENTITY_PROPERTIES_MISSING, missingProperties));
+ }
+ }
+
+ private Set getDefinedPropertyNames(EntityTemplate template) {
+ if (template.propertiesDefinitions() == null) {
+ return Set.of();
+ }
+ return template.propertiesDefinitions().stream().map(PropertyDefinition::name)
+ .collect(Collectors.toSet());
+ }
+
private List validateStringPropertyValue(String propertyName, Object rawValue,
PropertyRules rules) {
if (!(rawValue instanceof String stringValue)) {
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java
index 45408d79..00fd7c84 100644
--- a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java
+++ b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java
@@ -1,4 +1,6 @@
package com.decathlon.idp_core.domain.service.relation;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MISSING;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND;
@@ -11,6 +13,8 @@
import org.springframework.stereotype.Service;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingHasNoRelationsException;
+import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException;
import com.decathlon.idp_core.domain.model.entity.EntitySummary;
import com.decathlon.idp_core.domain.model.entity.Relation;
import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
@@ -21,12 +25,90 @@
import lombok.RequiredArgsConstructor;
/// Domain service validating entity relations against template relation definitions.
+///
+/// This service provides two levels of validation:
+/// 1. **Mapping validation** ([#validateMappingRelationsAgainstTemplate]): validates that
+/// relation names exist in the template and required relations are mapped.
+/// Used by dynamic mapping validation.
+/// 2. **Full relation validation** ([#validateRelationsAgainstTemplate]): validates complete
+/// [Relation] objects including cardinality and target existence. Used by entity validation.
@Service
@RequiredArgsConstructor
public class RelationValidationService {
private final EntityRepositoryPort entityRepository;
+ /// Validates mapping relation names against the template's relation
+ /// definitions.
+ ///
+ /// This is the **unified validation method for dynamic mappings**, similar to
+ /// `PropertyValidationService.validateAgainstTemplate()`.
+ ///
+ /// **Validations performed:**
+ /// 1. All relation names must exist in the template
+ /// 2. All required relations must have mappings
+ ///
+ /// **Fail-fast:** Throws on the first validation error encountered.
+ ///
+ /// @param template the target template whose relation definitions are the
+ /// source of truth
+ /// @param mappedRelationNames the relation names that have mappings
+ /// @throws RelationNameNotFoundEntityTemplateRelationsException when a name is
+ /// not declared
+ /// in the template
+ /// @throws EntityDynamicMappingHasNoRelationsException when required relations
+ /// are missing
+ public void validateMappingRelationsAgainstTemplate(EntityTemplate template,
+ List mappedRelationNames) {
+ validateNamesExistInTemplate(template, mappedRelationNames);
+ validateRequiredRelationsAreMapped(template, mappedRelationNames);
+ }
+
+ /// Validates that all relation names in the provided list exist in the
+ /// template.
+ ///
+ /// **Fail-fast:** Throws on the first unknown relation name encountered.
+ private void validateNamesExistInTemplate(EntityTemplate template, List relationNames) {
+ if (relationNames == null || relationNames.isEmpty()) {
+ return;
+ }
+
+ Set definedRelationNames = getDefinedRelationNames(template);
+
+ relationNames.stream().filter(name -> !definedRelationNames.contains(name)).findFirst()
+ .ifPresent(name -> {
+ throw new RelationNameNotFoundEntityTemplateRelationsException(
+ String.format(RELATION_NOT_DEFINED_IN_TEMPLATE, name, template.identifier()));
+ });
+ }
+
+ /// Validates that all required relation definitions in the template are mapped.
+ private void validateRequiredRelationsAreMapped(EntityTemplate template,
+ List mappedRelationNames) {
+ List definitions = template.relationsDefinitions() != null
+ ? template.relationsDefinitions()
+ : List.of();
+
+ List mappedNames = mappedRelationNames != null ? mappedRelationNames : List.of();
+
+ List missingRelations = definitions.stream().filter(RelationDefinition::required)
+ .map(RelationDefinition::name).filter(requiredName -> !mappedNames.contains(requiredName))
+ .toList();
+
+ if (!missingRelations.isEmpty()) {
+ throw new EntityDynamicMappingHasNoRelationsException(
+ String.format(ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MISSING, missingRelations));
+ }
+ }
+
+ private Set getDefinedRelationNames(EntityTemplate template) {
+ if (template.relationsDefinitions() == null) {
+ return Set.of();
+ }
+ return template.relationsDefinitions().stream().map(RelationDefinition::name)
+ .collect(Collectors.toSet());
+ }
+
/// Validates entity relations against the template's relation definitions,
/// enforcing required relations and cardinality constraints.
/// @param template the entity template whose relation definitions are used for
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java
new file mode 100644
index 00000000..adf6a2db
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java
@@ -0,0 +1,82 @@
+package com.decathlon.idp_core.domain.service.webhook;
+
+import java.util.List;
+
+import jakarta.transaction.Transactional;
+import jakarta.validation.Valid;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
+import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Validated
+@RequiredArgsConstructor
+public class WebhookConnectorService {
+
+ private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort;
+ private final WebhookConnectorValidationService webhookConnectorValidationService;
+ private final EntityDynamicMappingPort entityDynamicMappingPort;
+
+ public List resolveAndValidateMappings(List mappingIdentifiers) {
+ if (mappingIdentifiers == null || mappingIdentifiers.isEmpty()) {
+ return List.of();
+ }
+ return mappingIdentifiers.stream().map(this::resolveMappingOrThrow).toList();
+ }
+
+ private EntityDynamicMapping resolveMappingOrThrow(String identifier) {
+ return entityDynamicMappingPort.findByIdentifier(identifier)
+ .orElseThrow(() -> new EntityDynamicMappingNotFoundException(identifier));
+ }
+
+ public WebhookConnector getWebhookConnector(String identifier) {
+ return webhookConnectorRepositoryPort.findByIdentifier(identifier)
+ .orElseThrow(() -> new WebhookConnectorNotFoundException(identifier));
+ }
+
+ @Transactional
+ public WebhookConnector createWebhookConnector(WebhookConnector connector) {
+ webhookConnectorValidationService.validateWebhookConnectorForCreation(connector);
+
+ WebhookConnector connectorToSave = connector.mappings().isEmpty() && connector.enabled()
+ ? connector.withEnabled(false)
+ : connector;
+
+ return webhookConnectorRepositoryPort.save(connectorToSave);
+ }
+
+ @Transactional
+ public WebhookConnector updateWebhookConnector(String identifier,
+ @Valid WebhookConnector connectorToUpdate) {
+ WebhookConnector webhookConnectorInDb = getWebhookConnector(identifier);
+ webhookConnectorValidationService.validateWebhookConnectorForUpdate(connectorToUpdate);
+ boolean enabledValue = !connectorToUpdate.mappings().isEmpty() && connectorToUpdate.enabled();
+ WebhookConnector mergedConnector = new WebhookConnector(webhookConnectorInDb.id(),
+ webhookConnectorInDb.identifier(), connectorToUpdate.name(),
+ connectorToUpdate.description(), enabledValue, connectorToUpdate.mappings(),
+ connectorToUpdate.security());
+
+ return webhookConnectorRepositoryPort.save(mergedConnector);
+ }
+
+ @Transactional
+ public void deleteWebhookConnector(String webhookConnectorIdentifier) {
+ webhookConnectorValidationService.validateIdentifierExists(webhookConnectorIdentifier);
+ webhookConnectorRepositoryPort.deleteByIdentifier(webhookConnectorIdentifier);
+ }
+
+ public Page getAllWebhookConnector(Pageable pageable) {
+ return webhookConnectorRepositoryPort.findAll(pageable);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java
new file mode 100644
index 00000000..fac62077
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java
@@ -0,0 +1,63 @@
+package com.decathlon.idp_core.domain.service.webhook;
+
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorAlreadyExistException;
+import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort;
+import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingValidationService;
+import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService;
+
+import lombok.RequiredArgsConstructor;
+
+/// Domain validation service for webhook connector lifecycle operations.
+/// It validates connector uniqueness rules and delegates mapping and security
+/// validation to dedicated domain services.
+@Service
+@Validated
+@RequiredArgsConstructor
+public class WebhookConnectorValidationService {
+
+ private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort;
+ private final EntityDynamicMappingValidationService entityDynamicMappingValidationService;
+ private final WebhookSecurityValidationService webhookSecurityValidationService;
+
+ public void validateWebhookConnectorForCreation(WebhookConnector webhookConnector) {
+ validateIdentifierUniqueness(webhookConnector.identifier());
+ webhookSecurityValidationService.validateForCreation(webhookConnector.security());
+
+ }
+
+ public void validateWebhookConnectorForUpdate(WebhookConnector webhookConnectorToUpdate) {
+ validateMappingsIfPresent(webhookConnectorToUpdate);
+ webhookSecurityValidationService.validateForCreation(webhookConnectorToUpdate.security());
+ }
+
+ private void validateMappingsIfPresent(WebhookConnector webhookConnector) {
+ if (!webhookConnector.mappings().isEmpty()) {
+ entityDynamicMappingValidationService.validateMappings(webhookConnector.mappings());
+ }
+ }
+
+ /// Checks that no other [WebhookConnector] exists with the same identifier
+ /// before allowing creation.
+ ///
+ /// @param webhookConnectorIdentifier the webhook connector identifier to check
+ /// for uniqueness
+ /// @throws WebhookConnectorAlreadyExistException if a connector with the same
+ /// identifier already exists
+ private void validateIdentifierUniqueness(String webhookConnectorIdentifier) {
+ if (webhookConnectorRepositoryPort.existsByIdentifier(webhookConnectorIdentifier)) {
+ throw new WebhookConnectorAlreadyExistException(webhookConnectorIdentifier);
+ }
+ }
+
+ public void validateIdentifierExists(String webhookConnectorIdentifier) {
+ if (!webhookConnectorRepositoryPort.existsByIdentifier(webhookConnectorIdentifier)) {
+ throw new WebhookConnectorNotFoundException(webhookConnectorIdentifier);
+ }
+ }
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java
new file mode 100644
index 00000000..97f95b33
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java
@@ -0,0 +1,56 @@
+package com.decathlon.idp_core.domain.service.webhook.security;
+
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Service;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity;
+import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy;
+
+/// Domain service for validating webhook security configuration at creation/update time.
+///
+/// This service ensures that the security configuration provided when creating or updating
+/// a webhook connector is valid before storing it in the database.
+@Service
+public class WebhookSecurityValidationService {
+
+ private final List strategies;
+
+ public WebhookSecurityValidationService(List strategies) {
+ this.strategies = List.copyOf(strategies);
+ }
+
+ /// Validates webhook security configuration for creation or update.
+ ///
+ /// @param security the security configuration to validate
+ /// @throws WebhookSecurityConfigurationException if the configuration is
+ /// invalid
+ public void validateForCreation(WebhookSecurity security) {
+ if (security == null) {
+ throw new WebhookSecurityConfigurationException("Webhook security section is mandatory");
+ }
+
+ Map config = security.config();
+
+ if (security.type() == WebhookSecurityType.NONE) {
+ validateNoSecurityConfig(config);
+ return;
+ }
+
+ strategies.stream().filter(strategy -> strategy.supports(security.type())).findFirst()
+ .ifPresentOrElse(strategy -> strategy.validateConfiguration(config), () -> {
+ throw new WebhookSecurityConfigurationException(
+ "No validator registered for security type: " + security.type());
+ });
+ }
+
+ private void validateNoSecurityConfig(Map config) {
+ if (!config.isEmpty()) {
+ throw new WebhookSecurityConfigurationException(
+ "Webhook security config must be empty when type is NONE");
+ }
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java
index 47ce8abb..a5e679ab 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java
@@ -14,7 +14,9 @@
import org.springframework.data.domain.Pageable;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut;
import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut;
import io.swagger.v3.core.converter.ModelConverters;
import io.swagger.v3.core.jackson.ModelResolver;
@@ -100,4 +102,21 @@ public EntityPageResponse(List content, Pageable pageable, long to
}
}
+ @Schema(description = "Paginated response containing Inbound Webhook Connector objects")
+ public static class WebhookConnectorPageResponse extends PageImpl {
+ public WebhookConnectorPageResponse(List content, Pageable pageable,
+ long total) {
+ super(content, pageable, total);
+ }
+ }
+
+ @Schema(description = "Paginated response containing Entity Dynamic Mapping objects")
+ public static class EntityDynamicMappingPageResponse
+ extends
+ PageImpl {
+ public EntityDynamicMappingPageResponse(List content,
+ Pageable pageable, long total) {
+ super(content, pageable, total);
+ }
+ }
}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java
index 5470466b..6bdcc93e 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java
@@ -42,9 +42,6 @@ public class SwaggerDescription {
public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates";
public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting";
- public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID";
- public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier";
-
public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier";
public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier";
@@ -68,11 +65,41 @@ public class SwaggerDescription {
public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity";
public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information";
+
public static final String ENDPOINT_PUT_ENTITY_SUMMARY = "Update an existing entity";
public static final String ENDPOINT_PUT_ENTITY_DESCRIPTION = "Update an existing entity in the system with the provided information";
public static final String ENDPOINT_DELETE_ENTITY_SUMMARY = "Delete an existing entity";
public static final String ENDPOINT_DELETE_ENTITY_DESCRIPTION = "Delete an entity from the system using its template and entity identifiers. This operation removes the entity and automatically cleans up any relations from other entities that reference it.";
+ public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_SUMMARY = "Get paginated Webhook connectors";
+ public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_DESCRIPTION = "Retrieve a paginated list of webhook connectors with optional sorting";
+
+ public static final String ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY = "Delete a webhook connector by identifier";
+ public static final String ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION = "Remove a webhook connector from the system using its unique identifier";
+
+ public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_SUMMARY = "Get a webhook connector by identifier";
+ public static final String ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific webhook connector using its string identifier";
+
+ public static final String ENDPOINT_PUT_WEBHOOK_CONNECTOR_SUMMARY = "Update an existing webhook connector by identifier";
+ public static final String ENDPOINT_PUT_WEBHOOK_CONNECTOR_DESCRIPTION = "Update the details of an existing webhook connector identified by its unique string identifier";
+
+ public static final String ENDPOINT_POST_WEBHOOK_CONNECTOR_SUMMARY = "Create a new webhook connector configuration";
+ public static final String ENDPOINT_POST_WEBHOOK_CONNECTOR_DESCRIPTION = "Creates a webhook connector configuration used by the generic inbound webhook endpoint";
+
+ public static final String ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Create entity dynamic mapping";
+ public static final String ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Creates a new entity dynamic mapping used by the generic inbound webhook endpoint";
+
+ public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY = "Get paginated entity dynamic mappings";
+ public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entity dynamic mappings with optional sorting";
+
+ public static final String ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Delete an entity dynamic mapping by identifier";
+ public static final String ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Remove an entity dynamic from the system using its unique identifier";
+
+ public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_SUMMARY = "Get an entity dynamic mapping by identifier";
+ public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_DESCRIPTION = "Retrieve an entity dynamic mapping using its string identifier";
+
+ public static final String ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Update an existing entity dynamic mapping by identifier";
+ public static final String ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Update the details of an existing entity dynamic mapping identified by its unique string identifier";
/// Entity Audit API endpoint constants
public static final String ENDPOINT_GET_ENTITY_AUDIT_SUMMARY = "Get entity audit history";
public static final String ENDPOINT_GET_ENTITY_AUDIT_DESCRIPTION = "Retrieve the complete audit history for a specific entity, including all revisions with timestamps and modification types";
@@ -103,6 +130,21 @@ public class SwaggerDescription {
public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure";
public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights";
public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS = "Paginated webhook connector retrieved successfully";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_DELETED = "Webhook connector deleted successfully";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER = "Webhook connector not found with the provided identifier";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_FOUND = "Webhook connector found";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_CREATED = "Webhook connector created";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_UPDATED = "Webhook connector updated successfully";
+ public static final String RESPONSE_WEBHOOK_CONNECTOR_CONFLICT = "Webhook connector already exists in this entityTemplateIdentifier";
+ public static final String RESPONSE_INVALID_WEBHOOK_CONNECTOR_DATA = "Invalid webhook connector data provided";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_CREATED = "Entity dynamic mapping created";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_DATA = "Invalid entity dynamic mapping data provided";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS = "Paginated entity dynamic mapping retrieved successfully";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED = "Entity dynamic mapping deleted successfully";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER = "Entity dynamic mapping not found with the provided identifier";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND = "Entity dynamic mapping found";
+ public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED = "Entity dynamic mapping updated successfully";
// --- Schema (class) descriptions ---
public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template";
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java
new file mode 100644
index 00000000..d5402559
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java
@@ -0,0 +1,109 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.controller;
+
+import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*;
+import static org.springframework.http.HttpStatus.*;
+
+import jakarta.validation.Valid;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.web.bind.annotation.*;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingService;
+import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingUpdateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler;
+import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingMapper;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/entity_dynamic_mappings")
+@Tag(name = "Entity dynamic mapping", description = "Operations related to entity dynamic mapping management")
+public class EntityDynamicMappingController {
+
+ private final EntityDynamicMappingMapper dynamicMappingMapper;
+ private final EntityDynamicMappingService dynamicMappingService;
+
+ @Operation(summary = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION)
+ @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_CREATED)
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DATA)
+ @ApiResponse(responseCode = CONFLICT_CODE, description = "Identifier already exists")
+ @PostMapping
+ @ResponseStatus(CREATED)
+ public EntityDynamicMappingDtoOut createDynamicMapping(
+ @Valid @RequestBody EntityDynamicMappingCreateDtoIn inboundWebhookMappingDtoIn) {
+ EntityDynamicMapping entityDynamicMapping = dynamicMappingService
+ .createEntityDynamicMapping(dynamicMappingMapper.toDomain(inboundWebhookMappingDtoIn));
+ return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping);
+ }
+
+ @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.EntityDynamicMappingPageResponse.class)))
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0")))
+ @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20")))
+ @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc")))
+ @GetMapping
+ @ResponseStatus(OK)
+ public Page getEntityDynamicMappingPaginated(
+ @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) {
+ return dynamicMappingService.getAllEntityDynamicMapping(pageable)
+ .map(dynamicMappingMapper::fromEntityMappingToDto);
+ }
+
+ @Operation(summary = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION)
+ @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED)
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ResponseStatus(NO_CONTENT)
+ @DeleteMapping("/{identifier}")
+ public void deleteEntityDynamicMapping(@PathVariable String identifier) {
+ dynamicMappingService.deleteEntityDynamicMapping(identifier);
+ }
+
+ @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND, content = {
+ @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))})
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @GetMapping("/{identifier}")
+ @ResponseStatus(OK)
+ public EntityDynamicMappingDtoOut getEntityDynamicMappingByIdentifier(
+ @PathVariable String identifier) {
+ EntityDynamicMapping entityDynamicMapping = dynamicMappingService
+ .getEntityDynamicMapping(identifier);
+ return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping);
+ }
+
+ @Operation(summary = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED, content = {
+ @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))})
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "", content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ApiResponse(responseCode = CONFLICT_CODE, description = "", content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @PutMapping("/{identifier}")
+ @ResponseStatus(OK)
+ public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable String identifier,
+ @Valid @RequestBody EntityDynamicMappingUpdateDtoIn entityDynamicMappingDtoIn) {
+ return dynamicMappingMapper
+ .fromEntityMappingToDto(dynamicMappingService.updateEntityDynamicMapping(identifier,
+ dynamicMappingMapper.toDomainForUpdate(identifier, entityDynamicMappingDtoIn)));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java
new file mode 100644
index 00000000..9a846012
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java
@@ -0,0 +1,115 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.controller;
+
+import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*;
+import static org.springframework.http.HttpStatus.*;
+
+import jakarta.validation.Valid;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.web.bind.annotation.*;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService;
+import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler;
+import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook.InboundWebhookMapper;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+
+/// REST controller exposing inbound webhook configuration management endpoints.
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/inbound_webhooks")
+@Tag(name = "Inbound Webhook Management", description = "Operations for managing inbound webhook connector configurations")
+public class InboundWebhookConfigurationController {
+
+ private final WebhookConnectorService webhookConnectorService;
+ private final InboundWebhookMapper inboundWebhookMapper;
+
+ /// Creates a new inbound webhook connector configuration.
+ ///
+ /// @param request creation payload
+ /// @return created connector response
+ @Operation(summary = ENDPOINT_POST_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_POST_WEBHOOK_CONNECTOR_DESCRIPTION)
+ @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_CREATED)
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_WEBHOOK_CONNECTOR_DATA)
+ @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_CONFLICT)
+ @PostMapping
+ @ResponseStatus(CREATED)
+ public InboundWebhookDtoOut createInboundWebhook(
+ @Valid @RequestBody InboundWebhookCreateDtoIn request) {
+ WebhookConnector webhookConnector = webhookConnectorService
+ .createWebhookConnector(inboundWebhookMapper.toDomain(request,
+ webhookConnectorService.resolveAndValidateMappings(request.mappingIdentifiers())));
+ return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector);
+ }
+
+ @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.WebhookConnectorPageResponse.class)))
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0")))
+ @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20")))
+ @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc")))
+ @GetMapping
+ @ResponseStatus(OK)
+ public Page getWebhooksPaginated(
+ @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) {
+ return webhookConnectorService.getAllWebhookConnector(pageable)
+ .map(inboundWebhookMapper::fromWebhookConnectorToDto);
+ }
+
+ @Operation(summary = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION)
+ @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_DELETED)
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ResponseStatus(NO_CONTENT)
+ @DeleteMapping("/{identifier}")
+ public void deleteWebhookConnector(@PathVariable String identifier) {
+ webhookConnectorService.deleteWebhookConnector(identifier);
+ }
+
+ @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_FOUND, content = {
+ @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))})
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @GetMapping("/{identifier}")
+ @ResponseStatus(OK)
+ public InboundWebhookDtoOut getWebhookConnectorByIdentifier(@PathVariable String identifier) {
+ WebhookConnector webhookConnector = webhookConnectorService.getWebhookConnector(identifier);
+ return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector);
+ }
+
+ @Operation(summary = ENDPOINT_PUT_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_PUT_WEBHOOK_CONNECTOR_DESCRIPTION)
+ @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_UPDATED, content = {
+ @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))})
+ @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid request payload", content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @ApiResponse(responseCode = CONFLICT_CODE, description = "Webhook connector name already exists", content = {
+ @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))})
+ @PutMapping("/{identifier}")
+ @ResponseStatus(OK)
+ public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier,
+ @Valid @RequestBody InboundWebhookUpdateDtoIn request) {
+ var resolvedMappings = webhookConnectorService
+ .resolveAndValidateMappings(request.mappingIdentifiers());
+ return inboundWebhookMapper
+ .fromWebhookConnectorToDto(webhookConnectorService.updateWebhookConnector(identifier,
+ inboundWebhookMapper.toDomainForUpdate(identifier, request, resolvedMappings)));
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java
new file mode 100644
index 00000000..589b6ef9
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java
@@ -0,0 +1,27 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+
+/// Mapping rule request for inbound webhook transformation.
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record EntityDynamicMappingCreateDtoIn(
+
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY) String identifier,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description,
+ @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid EntityMappingDtoIn entity) {
+
+ /// Returns a CommonFields view for compatibility with the mapper.
+ public EntityDynamicMappingDtoInCommonFields commonFields() {
+ return new EntityDynamicMappingDtoInCommonFields(entityTemplateIdentifier, filter, name,
+ description, entity);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java
new file mode 100644
index 00000000..6912954e
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java
@@ -0,0 +1,19 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+
+/// Common fields for entity dynamic mapping requests (create and update).
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record EntityDynamicMappingDtoInCommonFields(
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description,
+ @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid EntityMappingDtoIn entity) {
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java
new file mode 100644
index 00000000..ef391ee2
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java
@@ -0,0 +1,25 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+
+/// Mapping rule request for inbound webhook update.
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record EntityDynamicMappingUpdateDtoIn(
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description,
+ @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid EntityMappingDtoIn entity) {
+
+ /// Returns a CommonFields view for compatibility with the mapper.
+ public EntityDynamicMappingDtoInCommonFields commonFields() {
+ return new EntityDynamicMappingDtoInCommonFields(entityTemplateIdentifier, filter, name,
+ description, entity);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityMappingDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityMappingDtoIn.java
new file mode 100644
index 00000000..15b05158
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityMappingDtoIn.java
@@ -0,0 +1,21 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import java.util.Map;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+/// Entity projection section for an inbound webhook mapping.
+public record EntityMappingDtoIn(
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY) String identifier,
+ @NotBlank(message = ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY) String name,
+ @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_PROPERTIES_MANDATORY) Map properties,
+ @NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_RELATIONS_MANDATORY) Map relations) {
+
+ public EntityMappingDtoIn {
+ properties = properties != null ? Map.copyOf(properties) : null;
+ relations = relations != null ? Map.copyOf(relations) : null;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java
new file mode 100644
index 00000000..899c5f8f
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java
@@ -0,0 +1,24 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
+
+import java.util.List;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record InboundWebhookCreateDtoIn(
+ @NotBlank(message = WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY) @Size(max = 255, message = WEBHOOK_CONNECTOR_IDENTIFIER_MAX_LENGTH) String identifier,
+ @NotBlank(message = WEBHOOK_CONNECTOR_NAME_MANDATORY) @Size(max = 255, message = WEBHOOK_CONNECTOR_NAME_MAX_LENGTH) String name,
+ String description, boolean enabled, List mappingIdentifiers,
+ @Valid InboundWebhookSecurityContractDtoIn security) {
+
+ public InboundWebhookCreateDtoIn {
+ mappingIdentifiers = mappingIdentifiers != null ? List.copyOf(mappingIdentifiers) : List.of();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java
new file mode 100644
index 00000000..ee59b552
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java
@@ -0,0 +1,19 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY;
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY;
+
+import java.util.Map;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+/// Security contract request payload represented as `{ type, config }`.
+public record InboundWebhookSecurityContractDtoIn(
+ @NotBlank(message = WEBHOOK_CONNECTOR_SECURITY_TYPE_MANDATORY) String type,
+ @NotNull(message = WEBHOOK_CONNECTOR_SECURITY_CONFIG_MANDATORY) Map config) {
+
+ public InboundWebhookSecurityContractDtoIn {
+ config = config != null ? Map.copyOf(config) : null;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookUpdateDtoIn.java
new file mode 100644
index 00000000..1e4486d2
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookUpdateDtoIn.java
@@ -0,0 +1,24 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.in;
+
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_NAME_MANDATORY;
+import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_NAME_MAX_LENGTH;
+
+import java.util.List;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record InboundWebhookUpdateDtoIn(
+ @NotBlank(message = WEBHOOK_CONNECTOR_NAME_MANDATORY) @Size(max = 255, message = WEBHOOK_CONNECTOR_NAME_MAX_LENGTH) String name,
+ String description, boolean enabled, List mappingIdentifiers,
+ @Valid InboundWebhookSecurityContractDtoIn security) {
+
+ public InboundWebhookUpdateDtoIn {
+ mappingIdentifiers = mappingIdentifiers != null ? List.copyOf(mappingIdentifiers) : List.of();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java
new file mode 100644
index 00000000..f56b8e64
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/EntityDynamicMappingDtoOut.java
@@ -0,0 +1,17 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping;
+
+import java.util.Map;
+
+/// Mapping rule returned by the inbound webhook management API.
+public record EntityDynamicMappingDtoOut(String identifier, String entityTemplateIdentifier,
+ String filter, String name, String description, InboundWebhookEntityMappingDtoOut entity) {
+ /// Entity projection details exposed in webhook mapping responses.
+ public static record InboundWebhookEntityMappingDtoOut(String identifier, String name,
+ Map properties, Map relations) {
+
+ public InboundWebhookEntityMappingDtoOut {
+ properties = properties != null ? Map.copyOf(properties) : null;
+ relations = relations != null ? Map.copyOf(relations) : null;
+ }
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java
new file mode 100644
index 00000000..f6bcddb1
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_dynamic_mapping/InboundWebhookEntityMappingDtoOut.java
@@ -0,0 +1,13 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping;
+
+import java.util.Map;
+
+/// Entity projection details exposed in webhook mapping responses.
+public record InboundWebhookEntityMappingDtoOut(String identifier, String title,
+ Map properties, Map relations) {
+
+ public InboundWebhookEntityMappingDtoOut {
+ properties = properties != null ? Map.copyOf(properties) : null;
+ relations = relations != null ? Map.copyOf(relations) : null;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java
new file mode 100644
index 00000000..459a9085
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java
@@ -0,0 +1,15 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook;
+
+import java.util.List;
+
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut;
+
+/// Response payload for created inbound webhook connector.
+public record InboundWebhookDtoOut(String identifier, String name, String description,
+ boolean enabled, List mappings,
+ InboundWebhookSecurityDtoOut security) {
+
+ public InboundWebhookDtoOut {
+ mappings = mappings != null ? List.copyOf(mappings) : null;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java
new file mode 100644
index 00000000..a02994b1
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java
@@ -0,0 +1,12 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook;
+
+import java.util.Map;
+
+/// Security strategy returned for webhook configuration responses.
+/// Only returns the strategy type to avoid exposing technical secret references.
+public record InboundWebhookSecurityDtoOut(String type, Map config) {
+
+ public InboundWebhookSecurityDtoOut {
+ config = config != null ? Map.copyOf(config) : null;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java
index 9a21a6d0..4c69fc17 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java
@@ -10,6 +10,7 @@
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
@@ -24,19 +25,11 @@
import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException;
import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException;
import com.decathlon.idp_core.domain.exception.entity.EntityValidationException;
-import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException;
-import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException;
-import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException;
-import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException;
-import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException;
-import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException;
-import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException;
-import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException;
-import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException;
-import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException;
-import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException;
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.*;
+import com.decathlon.idp_core.domain.exception.entity_template.*;
import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException;
import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException;
+import com.decathlon.idp_core.domain.exception.webhook.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -79,6 +72,11 @@ public ResponseEntity handleTemplateNotFoundException(
return ResponseEntity.status(NOT_FOUND).body(errorResponse);
}
+ /// Handles domain exception for malformed filter query strings (`q=` DSL).
+ ///
+ /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad
+ /// Request
+ /// so API consumers receive clear feedback about invalid `q` parameter syntax.
@ExceptionHandler(InvalidFilterDslException.class)
public ResponseEntity handleInvalidFilterDslException(
InvalidFilterDslException ex) {
@@ -106,25 +104,27 @@ public ResponseEntity handleInvalidSearchQueryException(
@ExceptionHandler(EntityTemplateAlreadyExistsException.class)
public ResponseEntity handleEntityTemplateAlreadyExistsException(
EntityTemplateAlreadyExistsException ex) {
- log.warn("Entity template already exists: {}", ex.getMessage());
+ log.warn("Entity entityTemplateIdentifier already exists: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
}
- /// Handles domain exception when entity template names already exist.
+ /// Handles domain exception when entity entityTemplateIdentifier names already
+ /// exist.
///
/// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to
/// HTTP 409 status indicating business rule conflict for duplicate
- /// template names.
+ /// entityTemplateIdentifier names.
@ExceptionHandler(EntityTemplateNameAlreadyExistsException.class)
public ResponseEntity handleEntityTemplateNameAlreadyExistsException(
EntityTemplateNameAlreadyExistsException ex) {
- log.warn("Entity template name already exists: {}", ex.getMessage());
+ log.warn("Entity entityTemplateIdentifier name already exists: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
}
- /// Handles domain exception when attempting to change an entity template
+ /// Handles domain exception when attempting to change an entity
+ /// entityTemplateIdentifier
/// identifier.
///
/// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException
@@ -133,25 +133,26 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept
@ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class)
public ResponseEntity handleEntityTemplateIdentifierCannotChangeException(
EntityTemplateIdentifierCannotChangeException ex) {
- log.warn("Entity template identifier cannot be changed: {}", ex.getMessage());
+ log.warn("Entity entityTemplateIdentifier identifier cannot be changed: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
- /// Handles domain exception for wrong entity template property rules.
+ /// Handles domain exception for wrong entity entityTemplateIdentifier property
+ /// rules.
///
/// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to
/// HTTP 400 status indicating validation error for wrong property rules.
@ExceptionHandler(PropertyDefinitionRulesConflictException.class)
public ResponseEntity handleWrongPropertyRulesException(
PropertyDefinitionRulesConflictException ex) {
- log.warn("Wrong Entity template property rules: {}", ex.getMessage());
+ log.warn("Wrong Entity entityTemplateIdentifier property rules: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
/// Handles domain exception when property names are duplicated within a
- /// template.
+ /// entityTemplateIdentifier.
///
/// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400
/// status indicating validation error for duplicate property names.
@@ -163,7 +164,7 @@ public ResponseEntity handlePropertyNameAlreadyExistsException(
}
/// Handles domain exception when relation names are duplicated within a
- /// template.
+ /// entityTemplateIdentifier.
///
/// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400
/// status indicating validation error for duplicate relation names.
@@ -175,14 +176,15 @@ public ResponseEntity handleRelationNameAlreadyExistsException(
}
/// Handles domain exception when a relation references a non-existent target
- /// template.
+ /// entityTemplateIdentifier.
///
/// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400
- /// status indicating validation error for missing target template.
+ /// status indicating validation error for missing target
+ /// entityTemplateIdentifier.
@ExceptionHandler(TargetTemplateNotFoundException.class)
public ResponseEntity handleTargetTemplateNotFoundException(
TargetTemplateNotFoundException ex) {
- log.warn("Target template not found: {}", ex.getMessage());
+ log.warn("Target entityTemplateIdentifier not found: {}", ex.getMessage());
return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@@ -196,20 +198,23 @@ public ResponseEntity handleTypeChangeException(PropertyTypeChang
return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
- /// Handles domain exception when relation target template changes are
+ /// Handles domain exception when relation target entityTemplateIdentifier
+ /// changes are
/// attempted.
///
/// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP
- /// 400 status indicating validation error for immutable target template field.
+ /// 400 status indicating validation error for immutable target
+ /// entityTemplateIdentifier field.
@ExceptionHandler(RelationTargetTemplateChangeException.class)
public ResponseEntity handleRelationTargetTemplateChangeException(
RelationTargetTemplateChangeException ex) {
- log.warn("Relation target template change error: {}", ex.getMessage());
+ log.warn("Relation target entityTemplateIdentifier change error: {}", ex.getMessage());
return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
- /// Handles domain exception when a relation's target template identifier is the
- /// template itself.
+ /// Handles domain exception when a relation's target entityTemplateIdentifier
+ /// identifier is the
+ /// entityTemplateIdentifier itself.
///
/// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP
/// 400
@@ -288,6 +293,39 @@ public ResponseEntity handleHttpMessageNotReadableException(
return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage);
}
+ /// Handles invalid dynamic mapping expressions (JSLT) provided in webhook
+ /// configuration.
+ ///
+ /// **HTTP mapping:** Maps domain mapping configuration failures to HTTP 400,
+ /// because clients can fix these expressions and retry.
+ @ExceptionHandler(EntityDynamicMappingConfigurationException.class)
+ public ResponseEntity handleEntityDynamicMappingConfigurationException(
+ EntityDynamicMappingConfigurationException ex) {
+ log.warn("Invalid entity dynamic mapping configuration: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
+ @ExceptionHandler(PropertyNameNotFoundEntityTemplatePropertiesException.class)
+ public ResponseEntity handlePropertyNameNotFoundEntityTemplatePropertiesException(
+ PropertyNameNotFoundEntityTemplatePropertiesException ex) {
+ log.warn("Webhook mapping references unknown property: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
+ @ExceptionHandler(RelationNameNotFoundEntityTemplateRelationsException.class)
+ public ResponseEntity handleRelationNameNotFoundEntityTemplateRelationsException(
+ RelationNameNotFoundEntityTemplateRelationsException ex) {
+ log.warn("Webhook mapping references unknown relation: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
+ @ExceptionHandler(WebhookSecurityConfigurationException.class)
+ public ResponseEntity handleWebhookSecurityConfigurationException(
+ WebhookSecurityConfigurationException ex) {
+ log.warn("Invalid webhook security configuration: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
/// Handles domain exception when entities are not found.
///
/// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status
@@ -442,6 +480,116 @@ public ResponseEntity handleGenericException(Exception ex) {
return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage);
}
+ /// Handles webhook signature and credential validation failures.
+ ///
+ /// HTTP mapping: Maps WebhookAuthenticationException to HTTP 401 Unauthorized.
+ @ExceptionHandler(WebhookAuthenticationException.class)
+ public ResponseEntity handleWebhookAuthenticationException(
+ WebhookAuthenticationException ex) {
+ log.warn("Webhook authentication failed: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.UNAUTHORIZED.name(),
+ ex.getMessage());
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
+ }
+
+ /// Handles missing webhook connector configuration.
+ ///
+ /// HTTP mapping: Maps WebhookConnectorNotFoundException to HTTP 404 Not Found.
+ @ExceptionHandler(WebhookConnectorNotFoundException.class)
+ public ResponseEntity handleWebhookConnectorNotFoundException(
+ WebhookConnectorNotFoundException ex) {
+ log.warn("Webhook connector not found: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage());
+ return ResponseEntity.status(NOT_FOUND).body(errorResponse);
+ }
+
+ /// Handles a webhook connector referencing a non-existent entity dynamic
+ /// mapping.
+ ///
+ /// HTTP mapping: Maps EntityDynamicMappingNotFoundException to HTTP 404 Not
+ /// Found, because the referenced mapping must be created beforehand.
+ @ExceptionHandler(EntityDynamicMappingNotFoundException.class)
+ public ResponseEntity handleEntityDynamicMappingNotFoundException(
+ EntityDynamicMappingNotFoundException ex) {
+ log.warn("Referenced entity dynamic mapping not found: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage());
+ return ResponseEntity.status(NOT_FOUND).body(errorResponse);
+ }
+
+ /// Handles creation of a dynamic mapping whose identifier already exists.
+ ///
+ /// HTTP mapping: Maps EntityDynamicMappingAlreadyExistsException to HTTP 409
+ /// Conflict, surfacing the uniqueness violation with business meaning.
+ @ExceptionHandler(EntityDynamicMappingAlreadyExistsException.class)
+ public ResponseEntity handleEntityDynamicMappingAlreadyExistsException(
+ EntityDynamicMappingAlreadyExistsException ex) {
+ log.warn("Entity dynamic mapping identifier conflict: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
+ /// Handles low-level database integrity violations (for example, unique
+ /// constraint breaches) that were not caught earlier by domain validation.
+ ///
+ /// HTTP mapping: Maps DataIntegrityViolationException to HTTP 409 Conflict to
+ /// avoid leaking technical SQL details while signaling a conflicting state.
+ @ExceptionHandler(DataIntegrityViolationException.class)
+ public ResponseEntity handleDataIntegrityViolationException(
+ DataIntegrityViolationException ex) {
+ log.warn("Data integrity violation: {}", ex.getMostSpecificCause().getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(),
+ "The request conflicts with the current state of the resource");
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
+ @ExceptionHandler(EntityDynamicMappingAlreadyInUseException.class)
+ public ResponseEntity handleEntityDynamicMappingAlreadyInUseException(
+ EntityDynamicMappingAlreadyInUseException ex) {
+ log.warn("Entity dynamic mapping already in use: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
+ /// Handles webhook connector identifier duplication conflicts.
+ @ExceptionHandler(WebhookConnectorAlreadyExistException.class)
+ public ResponseEntity handleWebhookConnectorAlreadyExistException(
+ WebhookConnectorAlreadyExistException ex) {
+ log.warn("Webhook connector identifier conflict: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
+ @ExceptionHandler(EntityTemplateUsedByDynamicMappingException.class)
+ public ResponseEntity handleTemplateAlreadyMappedInWebhookConfiguration(
+ EntityTemplateUsedByDynamicMappingException ex) {
+ log.warn("Entity entityTemplateIdentifier in use by webhook mapping conflict: {}",
+ ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+
+ /// Handles webhook connector name duplication conflicts.
+ @ExceptionHandler(WebhookConnectorTitleAlreadyExistsException.class)
+ public ResponseEntity handleWebhookConnectorTitleAlreadyExistsException(
+ WebhookConnectorTitleAlreadyExistsException ex) {
+ log.warn("Webhook connector name conflict: {}", ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
+ }
+ @ExceptionHandler(EntityDynamicMappingHasNoPropertiesException.class)
+ public ResponseEntity handleEntityDynamicMappingHasNoPropertiesException(
+ EntityDynamicMappingHasNoPropertiesException ex) {
+ log.warn("Entity dynamic mapping has no properties: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
+ @ExceptionHandler(EntityDynamicMappingHasNoRelationsException.class)
+ public ResponseEntity handleEntityDynamicMappingHasNoRelationsException(
+ EntityDynamicMappingHasNoRelationsException ex) {
+ log.warn("Entity dynamic mapping has no relations: {}", ex.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+
private static ResponseEntity createErrorResponse(HttpStatus httpStatus,
String errorMessage) {
return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus);
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java
new file mode 100644
index 00000000..76aa51e4
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java
@@ -0,0 +1,95 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook;
+
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookSecurityDtoOut;
+import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingMapper;
+
+import lombok.AllArgsConstructor;
+
+/// Maps inbound webhook API DTOs to domain models and back.
+@Component
+@AllArgsConstructor
+public class InboundWebhookMapper {
+
+ private final EntityDynamicMappingMapper dynamicMappingMapper;
+
+ /// Converts API input payload to the domain aggregate.
+ ///
+ /// @param dto inbound webhook creation request
+ /// @param resolvedMappings the existing dynamic mappings referenced by the
+ /// request, already resolved and validated by the domain layer
+ /// @return domain webhook connector
+ public WebhookConnector toDomain(InboundWebhookCreateDtoIn dto,
+ List resolvedMappings) {
+ return new WebhookConnector(null, dto.identifier(), dto.name(), dto.description(),
+ dto.enabled(), safeMappings(resolvedMappings), toDomain(dto.security()));
+ }
+
+ /// Converts API update payload to domain aggregate using the path identifier as
+ /// source of truth.
+ ///
+ /// @param identifier webhook connector identifier from URL path
+ /// @param dto inbound webhook update request body
+ /// @param resolvedMappings the existing dynamic mappings referenced by the
+ /// request, already resolved and validated by the domain layer
+ /// @return domain webhook connector prepared for update
+ public WebhookConnector toDomainForUpdate(String identifier, InboundWebhookUpdateDtoIn dto,
+ List resolvedMappings) {
+ return new WebhookConnector(null, identifier, dto.name(), dto.description(), dto.enabled(),
+ safeMappings(resolvedMappings), toDomain(dto.security()));
+ }
+
+ /// Converts domain aggregate to API response payload.
+ ///
+ /// @param domain created webhook connector
+ /// @return response DTO
+ public InboundWebhookDtoOut fromWebhookConnectorToDto(WebhookConnector domain) {
+ List mappings = domain.mappings().stream()
+ .map(dynamicMappingMapper::fromEntityMappingToDto).toList();
+ InboundWebhookSecurityDtoOut security = new InboundWebhookSecurityDtoOut(
+ domain.security().type().name(), domain.security().config());
+ return new InboundWebhookDtoOut(domain.identifier(), domain.name(), domain.description(),
+ domain.enabled(), mappings, security);
+ }
+
+ private List safeMappings(List mappings) {
+ return mappings == null ? List.of() : List.copyOf(mappings);
+ }
+
+ private WebhookSecurity toDomain(InboundWebhookSecurityContractDtoIn security) {
+ if (security == null) {
+ return new WebhookSecurity(WebhookSecurityType.NONE, Map.of());
+ }
+
+ var type = parseSecurityType(security.type());
+ var config = safeMap(security.config());
+
+ return new WebhookSecurity(type, config);
+ }
+
+ private WebhookSecurityType parseSecurityType(String typeString) {
+ try {
+ return WebhookSecurityType.valueOf(typeString.toUpperCase());
+ } catch (IllegalArgumentException _) {
+ throw new WebhookSecurityConfigurationException("Unsupported security type: " + typeString);
+ }
+ }
+
+ private Map safeMap(Map input) {
+ return input == null ? Map.of() : Map.copyOf(input);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/EntityDynamicMappingMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/EntityDynamicMappingMapper.java
new file mode 100644
index 00000000..f5c73595
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_dynamic_mapping/EntityDynamicMappingMapper.java
@@ -0,0 +1,66 @@
+package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping;
+
+import java.util.Map;
+
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingDtoInCommonFields;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingUpdateDtoIn;
+import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_dynamic_mapping.EntityDynamicMappingDtoOut;
+
+@Component
+public class EntityDynamicMappingMapper {
+
+ public EntityDynamicMapping toDomain(EntityDynamicMappingCreateDtoIn mapping) {
+ // Map each DTO field explicitly to its matching domain field. The
+ // EntityDynamicMapping
+ // constructor order is (id, identifier, entityTemplateIdentifier, filter,
+ // entityIdentifier,
+ // entityName, properties, relations); keeping this alignment prevents the
+ // entityTemplateIdentifier
+ // identifier and the filter expression from being swapped.
+ EntityDynamicMappingDtoInCommonFields fields = mapping.commonFields();
+ return new EntityDynamicMapping(null, // id (assigned by persistence layer)
+ mapping.identifier(), // identifier
+ fields.entityTemplateIdentifier(), // entityTemplateIdentifier
+ fields.filter(), // filter
+ fields.name(), // titre
+ fields.description(), fields.entity().identifier(), // entityIdentifier
+ fields.entity().name(), // entityName
+ safeMap(fields.entity().properties()), // properties
+ safeMap(fields.entity().relations())); // relations
+ }
+
+ public EntityDynamicMappingDtoOut fromEntityMappingToDto(EntityDynamicMapping mapping) {
+ return new EntityDynamicMappingDtoOut(mapping.identifier(), mapping.entityTemplateIdentifier(),
+ mapping.filter(), mapping.name(), mapping.description(),
+ new EntityDynamicMappingDtoOut.InboundWebhookEntityMappingDtoOut(mapping.entityIdentifier(),
+ mapping.entityName(), Map.copyOf(mapping.properties()),
+ Map.copyOf(mapping.relations())));
+ }
+
+ /// Converts an update DTO to domain model, using the identifier from the path.
+ ///
+ /// @param identifier the mapping identifier from the URL path
+ /// @param dto the update request body
+ /// @return the domain model for update
+ public EntityDynamicMapping toDomainForUpdate(String identifier,
+ EntityDynamicMappingUpdateDtoIn dto) {
+ var fields = dto.commonFields();
+ return new EntityDynamicMapping(null, // id (will be set from existing entity)
+ identifier, // identifier from path
+ fields.entityTemplateIdentifier(), // entityTemplateIdentifier
+ fields.filter(), // filter
+ fields.name(), // titre
+ fields.description(), fields.entity().identifier(), // entityIdentifier
+ fields.entity().name(), // entityName
+ safeMap(fields.entity().properties()), // properties
+ safeMap(fields.entity().relations())); // relations
+ }
+
+ private Map safeMap(Map input) {
+ return input == null ? Map.of() : Map.copyOf(input);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java
new file mode 100644
index 00000000..12da5445
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java
@@ -0,0 +1,100 @@
+package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt;
+
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator;
+import com.schibsted.spt.data.jslt.JsltException;
+import com.schibsted.spt.data.jslt.Parser;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class JsltEntityMappingValidator implements EntityDynamicMapperValidator {
+
+ private static final Pattern LOCATION_PATTERN = Pattern
+ .compile("line\\s+(\\d+),\\s+column\\s+(\\d+)");
+ private static final Pattern TOKEN_PATTERN = Pattern.compile("Encountered\\s+\"([^\"]+)\"");
+
+ @Override
+ public void validate(EntityDynamicMapping mapping) {
+ List errors = new ArrayList<>();
+
+ checkExpression(errors, "filter", mapping.filter());
+
+ checkExpression(errors, "entityIdentifier", mapping.entityIdentifier());
+ checkExpression(errors, "entityName", mapping.entityName());
+
+ if (mapping.properties() != null && !mapping.properties().isEmpty()) {
+ mapping.properties()
+ .forEach((key, expr) -> checkExpression(errors, "properties." + key, expr));
+ }
+ if (mapping.relations() != null && !mapping.relations().isEmpty()) {
+ mapping.relations().forEach((key, expr) -> checkExpression(errors, "relations." + key, expr));
+ }
+
+ if (!errors.isEmpty()) {
+ throw new EntityDynamicMappingConfigurationException(String.format(
+ "Validation failed with %d errors: %s", errors.size(), String.join(" | ", errors)));
+ }
+ }
+
+ private void checkExpression(List errors, String fieldName, String expression) {
+ if (!StringUtils.hasText(expression)) {
+ errors.add(
+ String.format("Field '%s' is required and must contain a JSLT expression.", fieldName));
+ return;
+ }
+
+ try {
+ new Parser(new StringReader(expression)).compile();
+ } catch (JsltException exception) {
+ errors.add(String.format("Invalid expression for '%s': %s", fieldName,
+ formatJsltErrorMessage(exception.getMessage())));
+ }
+ }
+
+ private String formatJsltErrorMessage(String rawMessage) {
+ if (!StringUtils.hasText(rawMessage)) {
+ return "JSLT syntax error.";
+ }
+
+ String normalized = rawMessage.replaceAll("\\s+", " ").trim();
+ if (normalized.startsWith("Parse error:")) {
+ normalized = normalized.substring("Parse error:".length()).trim();
+ }
+
+ String line = null;
+ String column = null;
+ Matcher locationMatcher = LOCATION_PATTERN.matcher(rawMessage);
+ if (locationMatcher.find()) {
+ line = locationMatcher.group(1);
+ column = locationMatcher.group(2);
+ }
+
+ String token = null;
+ Matcher tokenMatcher = TOKEN_PATTERN.matcher(rawMessage);
+ if (tokenMatcher.find()) {
+ token = tokenMatcher.group(1);
+ }
+
+ if (line != null && column != null && token != null) {
+ return String.format("JSLT syntax error at line %s, column %s (unexpected token: %s).", line,
+ column, token);
+ }
+ if (line != null && column != null) {
+ return String.format("JSLT syntax error at line %s, column %s.", line, column);
+ }
+
+ return normalized;
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java
new file mode 100644
index 00000000..dadc21c9
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java
@@ -0,0 +1,94 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
+import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/// Persistence adapter for [EntityDynamicMapping] read and write operations.
+@Component
+@RequiredArgsConstructor
+public class EntityDynamicMappingAdaptor implements EntityDynamicMappingPort {
+ private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository;
+ private final EntityDynamicMappingPersistenceMapper entityDynamicMappingPersistenceMapper;
+ private final EntityTemplateRepositoryPort entityTemplateRepositoryPort;
+
+ @Override
+ public List findByEntityTemplateIdentifier(String identifier) {
+ return jpaEntityDynamicMappingRepository.findByTemplateIdentifier(identifier).stream()
+ .map(entityDynamicMappingPersistenceMapper::toDomain).toList();
+ }
+
+ @Override
+ public List findByEntityTemplateId(UUID templateId) {
+ return jpaEntityDynamicMappingRepository.findByTemplateId(templateId).stream()
+ .map(entityDynamicMappingPersistenceMapper::toDomain).toList();
+ }
+
+ @Override
+ public Boolean existsByEntityTemplateIdentifier(String templateIdentifier) {
+ return jpaEntityDynamicMappingRepository.existsByTemplateIdentifier(templateIdentifier);
+ }
+
+ @Override
+ public boolean existsByIdentifier(String identifier) {
+ return jpaEntityDynamicMappingRepository.existsByIdentifier(identifier);
+ }
+
+ @Override
+ public Optional findByIdentifier(String identifier) {
+ return jpaEntityDynamicMappingRepository.findByIdentifier(identifier)
+ .map(entityDynamicMappingPersistenceMapper::toDomain);
+ }
+
+ @Override
+ public EntityDynamicMapping save(EntityDynamicMapping entityDynamicMapping) {
+ // The domain model references the entityTemplateIdentifier by its business
+ // identifier, but the
+ // foreign key persisted in `entity_dynamic_mapping.template_id` is the
+ // entityTemplateIdentifier
+ // UUID. Resolve the identifier to the entityTemplateIdentifier id before saving
+ // (fail-fast).
+ UUID templateId = entityTemplateRepositoryPort
+ .findByIdentifier(entityDynamicMapping.entityTemplateIdentifier()).map(EntityTemplate::id)
+ .orElseThrow(() -> new EntityTemplateNotFoundException("identifier",
+ entityDynamicMapping.entityTemplateIdentifier()));
+
+ EntityDynamicMappingJpaEntity entityToPersist = entityDynamicMappingPersistenceMapper
+ .toJpa(entityDynamicMapping);
+ entityToPersist.setEntityTemplateId(templateId);
+ EntityDynamicMappingJpaEntity persistedEntity = jpaEntityDynamicMappingRepository
+ .save(entityToPersist);
+
+ return new EntityDynamicMapping(persistedEntity.getId(), entityDynamicMapping.identifier(),
+ entityDynamicMapping.entityTemplateIdentifier(), entityDynamicMapping.filter(),
+ entityDynamicMapping.name(), entityDynamicMapping.description(),
+ entityDynamicMapping.entityIdentifier(), entityDynamicMapping.entityName(),
+ entityDynamicMapping.properties(), entityDynamicMapping.relations());
+ }
+
+ @Override
+ public Page findAll(Pageable pageable) {
+ return jpaEntityDynamicMappingRepository.findAll(pageable)
+ .map(entityDynamicMappingPersistenceMapper::toDomain);
+ }
+
+ @Override
+ public void deleteByIdentifier(String identifier) {
+ jpaEntityDynamicMappingRepository.deleteByIdentifier(identifier);
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java
index af72cfd0..b4de92e6 100644
--- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java
@@ -90,6 +90,11 @@ public void deleteByIdentifier(String identifier) {
jpaEntityTemplateRepository.deleteByIdentifier(identifier);
}
+ @Override
+ public boolean existsById(UUID id) {
+ return jpaEntityTemplateRepository.existsById(id);
+ }
+
// ── Merge helpers to update a managed JPA entity from domain values ──
private void mergeIntoExisting(EntityTemplateJpaEntity jpa, EntityTemplate domain) {
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java
new file mode 100644
index 00000000..a74f9ee4
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java
@@ -0,0 +1,188 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingNotFoundException;
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort;
+import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort;
+import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookConnectorRepository;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/// Persistence adapter implementing [WebhookConnectorRepositoryPort].
+///
+/// Delegates to Spring Data JPA and uses [WebhookConnectorPersistenceMapper]
+/// to convert between JPA entities and domain models.
+///
+/// Handles the complex persistence of mappings across three tables:
+/// - webhook_connector (core connector data)
+/// - entity_dynamic_mapping (mapping configurations)
+/// - webhook_template_mapping (many-to-many link)
+@Component
+@RequiredArgsConstructor
+public class PostgresWebhookConnectorAdapter implements WebhookConnectorRepositoryPort {
+
+ private final JpaWebhookConnectorRepository jpaWebhookConnectorRepository;
+ private final WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository;
+ private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository;
+ private final EntityTemplateRepositoryPort entityTemplateRepositoryPort;
+ private final EntityDynamicMappingPort entityDynamicMappingPort;
+ private final WebhookConnectorPersistenceMapper mapper;
+ private final EntityDynamicMappingPersistenceMapper mappingMapper;
+
+ @Override
+ public Optional findByIdentifier(String identifier) {
+ return jpaWebhookConnectorRepository.findByIdentifier(identifier)
+ .map(this::loadConnectorWithMappings);
+ }
+
+ @Override
+ public Page findAll(Pageable pageable) {
+ Page jpaPage = jpaWebhookConnectorRepository.findAll(pageable);
+
+ if (jpaPage.isEmpty()) {
+ return jpaPage.map(mapper::toDomain);
+ }
+
+ // Collect all webhook IDs from the page
+ List webhookIds = jpaPage.stream().map(WebhookConnectorJpaEntity::getId).toList();
+
+ // Batch load all entityTemplateIdentifier mappings for all webhooks in the page
+ List allTemplateMappings = jpaWebhookTemplateMappingRepository
+ .findByWebhookIdIn(webhookIds);
+
+ // Collect all unique mapping IDs
+ List allMappingIds = allTemplateMappings.stream()
+ .map(WebhookMappingLinkJpaEntity::getEntityMappingId).distinct().toList();
+
+ // Batch load all entity mappings in one query
+ Map allMappingsById = jpaEntityDynamicMappingRepository
+ .findAllById(allMappingIds).stream()
+ .collect(Collectors.toMap(EntityDynamicMappingJpaEntity::getId, mappingMapper::toDomain,
+ (existing, replacement) -> existing));
+
+ // Group mappings by webhook ID
+ Map> mappingsByWebhookId = allTemplateMappings.stream()
+ .collect(Collectors.groupingBy(WebhookMappingLinkJpaEntity::getWebhookId,
+ Collectors.mapping(wtm -> allMappingsById.get(wtm.getEntityMappingId()),
+ Collectors.filtering(Objects::nonNull, Collectors.toList()))));
+
+ // Map each JPA entity to domain with its mappings
+ return jpaPage.map(jpaEntity -> {
+ WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity);
+ List mappings = mappingsByWebhookId.getOrDefault(jpaEntity.getId(),
+ Collections.emptyList());
+
+ return new WebhookConnector(connectorWithoutMappings.id(),
+ connectorWithoutMappings.identifier(), connectorWithoutMappings.name(),
+ connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings,
+ connectorWithoutMappings.security());
+ });
+ }
+
+ @Override
+ public boolean existsByIdentifier(String identifier) {
+ return jpaWebhookConnectorRepository.existsByIdentifier(identifier);
+ }
+
+ @Override
+ public boolean existsByTitle(String title) {
+ return jpaWebhookConnectorRepository.existsByName(title);
+ }
+
+ @Override
+ public WebhookConnector save(WebhookConnector connector) {
+ WebhookConnectorJpaEntity savedConnector = jpaWebhookConnectorRepository
+ .save(mapper.toJpa(connector));
+ persistTemplateMappings(savedConnector.getId(), connector);
+ return loadConnectorWithMappings(savedConnector);
+ }
+
+ @Override
+ public void deleteByIdentifier(String identifier) {
+ jpaWebhookConnectorRepository.deleteByIdentifier(identifier);
+ }
+
+ /// Loads a connector with its associated mappings from the
+ /// webhook_template_mapping table.
+ /// Since WebhookConnector is a Record (immutable), we create a new instance
+ /// with the loaded mappings.
+ private WebhookConnector loadConnectorWithMappings(WebhookConnectorJpaEntity jpaEntity) {
+ WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity);
+ List mappings = loadMappingsForWebhook(jpaEntity.getId());
+
+ // Since WebhookConnector is a Record, create a new instance with loaded
+ // mappings
+ return new WebhookConnector(connectorWithoutMappings.id(),
+ connectorWithoutMappings.identifier(), connectorWithoutMappings.name(),
+ connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings,
+ connectorWithoutMappings.security());
+ }
+
+ /// Loads all dynamic mappings associated with a webhook connector.
+ /// Uses batch loading to avoid N+1 query problem.
+ private List loadMappingsForWebhook(UUID webhookId) {
+ List templateMappings = jpaWebhookTemplateMappingRepository
+ .findByWebhookId(webhookId);
+ List mappingIds = templateMappings.stream()
+ .map(WebhookMappingLinkJpaEntity::getEntityMappingId).toList();
+ if (mappingIds.isEmpty()) {
+ return List.of();
+ }
+ Map mappingsById = jpaEntityDynamicMappingRepository
+ .findAllById(mappingIds).stream()
+ .collect(Collectors.toMap(EntityDynamicMappingJpaEntity::getId, mappingMapper::toDomain));
+ return mappingIds.stream().map(mappingsById::get).filter(Objects::nonNull).toList();
+ }
+
+ /// Persists the webhook's entityTemplateIdentifier mappings in the
+ /// webhook_template_mapping
+ /// table.
+ /// This also persists each EntityDynamicMapping if it's new.
+ private void persistTemplateMappings(UUID webhookId, WebhookConnector connector) {
+ jpaWebhookTemplateMappingRepository.deleteByWebhookId(webhookId);
+ var mappings = connector.mappings().stream()
+ .map(mapping -> persistAndCreateTemplateMapping(webhookId, mapping)).toList();
+
+ if (!mappings.isEmpty()) {
+ jpaWebhookTemplateMappingRepository.saveAll(mappings);
+ }
+ }
+
+ /// Persists a single EntityDynamicMapping and creates a
+ /// WebhookTemplateMappingJpaEntity link.
+ ///
+ /// The mapping is expected to already exist because it is created through the
+ /// dedicated inbound dynamic mapping endpoint. This method only creates the
+ /// association row in webhook_template_mapping.
+ private WebhookMappingLinkJpaEntity persistAndCreateTemplateMapping(UUID webhookId,
+ EntityDynamicMapping mapping) {
+
+ EntityDynamicMapping entityDynamicMapping = entityDynamicMappingPort
+ .findByIdentifier(mapping.identifier())
+ .orElseThrow(() -> new EntityDynamicMappingNotFoundException(mapping.identifier()));
+
+ return WebhookMappingLinkJpaEntity.builder().webhookId(webhookId)
+ .entityMappingId(entityDynamicMapping.id()).jsltFilter(mapping.filter()).build();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java
new file mode 100644
index 00000000..25ec2552
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java
@@ -0,0 +1,34 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence;
+
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.stereotype.Component;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping;
+import com.decathlon.idp_core.domain.port.WebhookMappingLinkPort;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookMappingLinkPersistenceMapper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.WebhookMappingLinkRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/// Persistence adapter for webhook-entityTemplateIdentifier mapping read operations.
+@Component
+@RequiredArgsConstructor
+public class WebhookTemplateMappingAdaptor implements WebhookMappingLinkPort {
+
+ private final WebhookMappingLinkRepository jpaWebhookTemplateMappingRepository;
+ private final WebhookMappingLinkPersistenceMapper webhookMappingLinkPersistenceMapper;
+
+ @Override
+ public boolean existsByEntityMappingId(UUID id) {
+ return jpaWebhookTemplateMappingRepository.existsByEntityMappingId(id);
+ }
+
+ @Override
+ public List findByEntityMappingId(UUID id) {
+ return jpaWebhookTemplateMappingRepository.findByEntityMappingId(id).stream()
+ .map(webhookMappingLinkPersistenceMapper::toDomain).toList();
+ }
+
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java
new file mode 100644
index 00000000..d53ae887
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java
@@ -0,0 +1,40 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper;
+
+import static org.mapstruct.MappingConstants.ComponentModel.SPRING;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.EntityDynamicMappingJsonbHelper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_dynamic_mapping.EntityDynamicMappingJpaEntity;
+
+/// MapStruct persistence mapper for [EntityDynamicMapping].
+///
+/// Maps between domain model (EntityDynamicMapping) and JPA entity (EntityDynamicMappingJpaEntity).
+/// Handles JSONB columns for properties and relations via the dedicated helper.
+@Mapper(componentModel = SPRING, uses = EntityDynamicMappingJsonbHelper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR)
+public interface EntityDynamicMappingPersistenceMapper {
+
+ @Mapping(target = "properties", qualifiedByName = "jsonStringToMap")
+ @Mapping(target = "relations", qualifiedByName = "jsonStringToMap")
+ @Mapping(target = "entityTemplateIdentifier", source = "template.identifier")
+ // Explicit self-mapping: MapStruct otherwise silently drops the
+ // `entityIdentifier` property, leaving the NOT NULL column unset.
+ @Mapping(target = "entityIdentifier", source = "entityIdentifier")
+ @Mapping(target = "entityName", source = "entityName")
+ EntityDynamicMapping toDomain(EntityDynamicMappingJpaEntity jpa);
+
+ @Mapping(target = "properties", qualifiedByName = "mapToJsonString")
+ @Mapping(target = "relations", qualifiedByName = "mapToJsonString")
+ // The template foreign key (UUID) is resolved from the business identifier and
+ // set by the persistence adapter, so both template fields are ignored here.
+ @Mapping(target = "entityTemplateId", ignore = true)
+ @Mapping(target = "template", ignore = true)
+ // Explicit self-mapping: MapStruct otherwise silently drops the
+ // `entityIdentifier` property, leaving the NOT NULL column unset.
+ @Mapping(target = "entityIdentifier", source = "entityIdentifier")
+ @Mapping(target = "entityName", source = "entityName")
+ EntityDynamicMappingJpaEntity toJpa(EntityDynamicMapping domain);
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java
new file mode 100644
index 00000000..7cd398c4
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java
@@ -0,0 +1,31 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper;
+
+import static org.mapstruct.MappingConstants.ComponentModel.SPRING;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.WebhookConnectorJsonbHelper;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity;
+
+/// MapStruct persistence mapper for [WebhookConnector].
+///
+/// Maps the connector's direct fields (identifier, name, description, enabled, security).
+/// The mappings list is handled separately by
+/// [com.decathlon.idp_core.infrastructure.adapters.persistence.PostgresWebhookConnectorAdapter]
+/// through the `webhook_template_mapping` table because it requires dedicated persistence
+/// for `entity_dynamic_mapping` rows.
+@Mapper(componentModel = SPRING, uses = WebhookConnectorJsonbHelper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR)
+public interface WebhookConnectorPersistenceMapper {
+
+ @Mapping(target = "mappings", ignore = true)
+ @Mapping(target = "security", qualifiedByName = "jsonToSecurity")
+ WebhookConnector toDomain(WebhookConnectorJpaEntity jpa);
+
+ @Mapping(target = "createdAt", ignore = true)
+ @Mapping(target = "updatedAt", ignore = true)
+ @Mapping(target = "security", qualifiedByName = "securityToJson")
+ WebhookConnectorJpaEntity toJpa(WebhookConnector domain);
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java
new file mode 100644
index 00000000..ee15cd59
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookMappingLinkPersistenceMapper.java
@@ -0,0 +1,57 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper;
+
+import java.util.UUID;
+
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingConstants;
+
+import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping;
+import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookMappingLinkJpaEntity;
+
+/// Persistence mapper for [WebhookTemplateMapping].
+///
+/// Maps the association entity between webhook connector, entity entityTemplateIdentifier and
+/// dynamic mapping configuration. Foreign keys are managed explicitly by adapters
+/// when persisting new links.
+@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = {
+ WebhookConnectorPersistenceMapper.class, EntityTemplatePersistenceMapper.class,
+ EntityDynamicMappingPersistenceMapper.class}, injectionStrategy = InjectionStrategy.CONSTRUCTOR)
+public interface WebhookMappingLinkPersistenceMapper {
+
+ /// Maps JPA association data to the domain model.
+ ///
+ /// @param jpa persisted association entity
+ /// @return mapped domain model
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "webhookConnector", source = "webhookConnector")
+ @Mapping(target = "entityDynamicMapping", source = "entityMapping")
+ @Mapping(target = "jsltFilter", source = "jsltFilter")
+ WebhookTemplateMapping toDomain(WebhookMappingLinkJpaEntity jpa);
+
+ /// Maps domain model to JPA association entity.
+ ///
+ /// All technical IDs are preserved from the domain model.
+ ///
+ /// @param domain domain mapping object
+ /// @return fully mapped JPA association entity
+ @Mapping(target = "webhookId", source = "webhookConnector.id")
+ @Mapping(target = "entityMappingId", source = "entityDynamicMapping.id")
+ @Mapping(target = "jsltFilter", source = "jsltFilter")
+ @Mapping(target = "webhookConnector", ignore = true)
+ @Mapping(target = "entityMapping", ignore = true)
+ WebhookMappingLinkJpaEntity toJpa(WebhookTemplateMapping domain);
+
+ /// Builds a link row with explicit foreign keys.
+ ///
+ /// @param webhookId webhook connector technical id
+ /// @param entityMappingId dynamic mapping technical id
+ /// @param jsltFilter JSLT filter expression
+ /// @return link entity ready for persistence
+ default WebhookMappingLinkJpaEntity toJpa(UUID webhookId, UUID entityMappingId,
+ String jsltFilter) {
+ return WebhookMappingLinkJpaEntity.builder().webhookId(webhookId)
+ .entityMappingId(entityMappingId).jsltFilter(jsltFilter).build();
+ }
+}
diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java
new file mode 100644
index 00000000..7fb0a383
--- /dev/null
+++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java
@@ -0,0 +1,51 @@
+package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common;
+
+import java.util.Map;
+
+import org.mapstruct.Named;
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/// Technical helper for JSONB serialization/deserialization in the persistence layer.
+///
+/// Provides named conversion methods used by [com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper]
+/// via MapStruct's `qualifiedByName` annotation.
+///
+/// This is a pure utility class with no Spring dependencies, facilitating testability and reusability.
+@Component
+public class EntityDynamicMappingJsonbHelper {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ /// Converts JSONB string to `Map`.
+ /// Used when loading from database.
+ @Named("jsonStringToMap")
+ public Map toMap(String json) {
+ if (json == null || json.trim().isEmpty()) {
+ return Map.of();
+ }
+ try {
+ return OBJECT_MAPPER.readValue(json, new TypeReference