From c400ac6ee77a78f78420dace520b89129f3d07c4 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Wed, 10 Jun 2026 17:30:07 +0200 Subject: [PATCH 1/2] feat(core): add endpoint for creating inbound webhook connector Signed-off-by: foukou19 --- docs/src/concepts/index.md | 6 + docs/src/concepts/webhooks.md | 216 ++++++++ docs/src/features/data-integration.md | 74 ++- docs/src/features/index.md | 2 +- docs/src/index.md | 2 +- docs/zensical.toml | 3 +- pom.xml | 8 + .../domain/constant/ValidationMessages.java | 7 + ...yDynamicMappingConfigurationException.java | 13 + ...emplateInUseByWebhookMappingException.java | 32 ++ ...oundEntityTemplatePropertiesException.java | 7 + ...FoundEntityTemplateRelationsException.java | 7 + .../WebhookAuthenticationException.java | 11 + ...WebhookConnectorAlreadyExistException.java | 10 + ...ebhookConnectorConfigurationException.java | 7 + .../WebhookConnectorNotFoundException.java | 10 + ...kConnectorTitleAlreadyExistsException.java | 9 + ...WebhookSecurityConfigurationException.java | 8 + ...bhookTemplateHasNoPropertiesException.java | 8 + .../entity_mapping/EntityDynamicMapping.java | 43 ++ .../model/enums/WebhookSecurityType.java | 16 + .../webhook/WebhookConnector.java | 43 ++ .../webhook/WebhookSecurity.java | 20 + .../webhook/WebhookTemplateMapping.java | 22 + .../port/EntityDynamicMapperValidator.java | 8 + .../domain/port/EntityDynamicMappingPort.java | 11 + .../port/WebhookConnectorRepositoryPort.java | 23 + .../domain/port/WebhookSecurityStrategy.java | 29 ++ .../port/WebhookTemplateMappingPort.java | 12 + .../EntityTemplateValidationService.java | 71 ++- ...EntityDynamicMappingValidationService.java | 104 ++++ .../webhook/WebhookConnectorService.java | 62 +++ .../WebhookConnectorValidationService.java | 76 +++ .../WebhookTemplateMappingService.java | 51 ++ .../WebhookSecurityValidationService.java | 56 ++ .../configuration/SwaggerConfiguration.java | 8 + .../api/configuration/SwaggerDescription.java | 18 + .../InboundWebhookManagementController.java | 111 ++++ .../api/dto/in/InboundWebhookCreateDtoIn.java | 20 + .../in/InboundWebhookEntityMappingDtoIn.java | 19 + .../dto/in/InboundWebhookMappingDtoIn.java | 12 + .../InboundWebhookSecurityContractDtoIn.java | 16 + .../dto/out/webhook/InboundWebhookDtoOut.java | 13 + .../InboundWebhookEntityMappingDtoOut.java | 13 + .../webhook/InboundWebhookMappingDtoOut.java | 6 + .../webhook/InboundWebhookSecurityDtoOut.java | 12 + .../api/handler/ApiExceptionHandler.java | 130 ++++- .../mapper/webhook/InboundWebhookMapper.java | 101 ++++ .../jslt/JsltEntityMappingValidator.java | 100 ++++ .../EntityDynamicMappingAdaptor.java | 31 ++ .../PostgresWebhookConnectorAdapter.java | 133 +++++ .../WebhookTemplateMappingAdaptor.java | 33 ++ ...EntityDynamicMappingPersistenceMapper.java | 28 + .../WebhookConnectorPersistenceMapper.java | 30 ++ ...bhookTemplateMappingPersistenceMapper.java | 61 +++ .../EntityDynamicMappingJsonbHelper.java | 51 ++ .../common/WebhookConnectorJsonbHelper.java | 46 ++ .../EntityDynamicMappingJpaEntity.java | 49 ++ .../webhook/WebhookConnectorJpaEntity.java | 67 +++ .../WebhookTemplateMappingJpaEntity.java | 65 +++ .../JpaEntityDynamicMappingRepository.java | 21 + .../JpaWebhookConnectorRepository.java | 20 + .../JpaWebhookTemplateMappingRepository.java | 21 + .../webhook/model/BasicAuthConfig.java | 14 + .../adapters/webhook/model/HmacConfig.java | 25 + .../webhook/model/JwtBearerConfig.java | 13 + .../webhook/model/SecurityConfig.java | 14 + .../webhook/model/StaticTokenConfig.java | 14 + .../security/BasicAuthSecurityValidator.java | 31 ++ .../security/HmacSha256SecurityValidator.java | 32 ++ .../security/HmacSignatureValidator.java | 33 ++ .../security/JwtBearerSecurityValidator.java | 36 ++ .../StaticTokenSecurityValidator.java | 28 + .../security/WebhookJwtDecoderProvider.java | 26 + .../WebhookSecurityConfigurationUtils.java | 122 +++++ .../V4_1__create_webhook_connector_table.sql | 18 + ...2__create_entity_dynamic_mapping_table.sql | 12 + ..._create_webhook_template_mapping_table.sql | 20 + .../webhook/WebhookConnectorTest.java | 40 ++ ...tyDynamicMappingValidationServiceTest.java | 478 ++++++++++++++++++ .../webhook/WebhookConnectorServiceTest.java | 289 +++++++++++ ...WebhookConnectorValidationServiceTest.java | 196 +++++++ .../WebhookSecurityValidationServiceTest.java | 124 +++++ ...nboundWebhookManagementControllerTest.java | 425 ++++++++++++++++ .../api/handler/ApiExceptionHandlerTest.java | 320 ++++++++---- .../webhook/InboundWebhookMapperTest.java | 87 ++++ .../BasicAuthSecurityValidatorTest.java | 27 + .../security/HmacSignatureValidatorTest.java | 74 +++ .../JwtBearerSecurityValidatorTest.java | 42 ++ .../StaticTokenSecurityValidatorTest.java | 54 ++ ...hWebhookSecurityCreationValidatorTest.java | 86 ++++ ...6WebhookSecurityCreationValidatorTest.java | 95 ++++ ...rWebhookSecurityCreationValidatorTest.java | 71 +++ ...nWebhookSecurityCreationValidatorTest.java | 86 ++++ .../test/R__3_insert_webhhook_test_data.sql | 52 ++ .../json/webhook/v1/postWebhook_201.json | 33 ++ .../v1/postWebhook_400_identifier_blank.json | 22 + .../postWebhook_400_identifier_missing.json | 21 + .../v1/postWebhook_400_invalid_jslt.json | 29 ++ ...postWebhook_400_invalid_security_type.json | 29 ++ .../v1/postWebhook_400_mappings_empty.json | 11 + ...Webhook_409_identifier_already_exists.json | 33 ++ .../json/webhook/v1/putWebhook_200.json | 32 ++ .../putWebhook_409_title_already_exists.json | 29 ++ 104 files changed, 5390 insertions(+), 155 deletions(-) create mode 100644 docs/src/concepts/webhooks.md create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingConfigurationException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateInUseByWebhookMappingException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameNotFoundEntityTemplatePropertiesException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameNotFoundEntityTemplateRelationsException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookTemplateHasNoPropertiesException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookTemplateMappingService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookEntityMappingDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookMappingDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookTemplateMappingPersistenceMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_mapping/EntityDynamicMappingJpaEntity.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookTemplateMappingJpaEntity.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/BasicAuthConfig.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/HmacConfig.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/JwtBearerConfig.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/SecurityConfig.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/StaticTokenConfig.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java create mode 100644 src/main/resources/db/migration/V4_1__create_webhook_connector_table.sql create mode 100644 src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql create mode 100644 src/main/resources/db/migration/V4_3__create_webhook_template_mapping_table.sql create mode 100644 src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/BasicAuthWebhookSecurityCreationValidatorTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/HmacSha256WebhookSecurityCreationValidatorTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/JwtBearerWebhookSecurityCreationValidatorTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/StaticTokenWebhookSecurityCreationValidatorTest.java create mode 100644 src/test/resources/db/test/R__3_insert_webhhook_test_data.sql create mode 100644 src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json create mode 100644 src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json create mode 100644 src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json create mode 100644 src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json create mode 100644 src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json create mode 100644 src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json create mode 100644 src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json create mode 100644 src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json create mode 100644 src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json diff --git a/docs/src/concepts/index.md b/docs/src/concepts/index.md index 1653c079..3d0de3d3 100644 --- a/docs/src/concepts/index.md +++ b/docs/src/concepts/index.md @@ -49,6 +49,11 @@ graph TB Connections between entities forming a knowledge graph. +- 🌐 **[Webhooks](webhooks.md)** + + --- + + Runtime-configurable connectors that authenticate external events and map payloads to your data model. - 🔍 **[Filtering Entities](entity-filtering.md)** --- @@ -130,3 +135,4 @@ Dive deeper into each concept: - **[Entity Templates](entity-templates.md)** - Learn how to design your data model - **[Properties](properties.md)** - Understand property types and validation - **[Relations](relations.md)** - Connect your entities into a graph +- **[Webhooks](webhooks.md)** - Configure inbound integrations and security strategies diff --git a/docs/src/concepts/webhooks.md b/docs/src/concepts/webhooks.md new file mode 100644 index 00000000..545ee873 --- /dev/null +++ b/docs/src/concepts/webhooks.md @@ -0,0 +1,216 @@ +--- +title: Webhooks +description: Understand webhook connectors, security strategies, and dynamic mappings in IDP-Core +--- + +Webhooks let external systems push JSON events to IDP-Core through a generic HTTP endpoint. You configure a webhook connector at runtime, choose a security strategy, and define mappings that translate incoming payloads into entity data with JSLT expressions. + +## Overview + +A webhook connector combines three concerns: + +- **Connector metadata** - Identifier, title, description, and enabled flag +- **Security** - How IDP-Core authenticates incoming requests +- **Mappings** - How the payload maps to an Entity Template + +```mermaid +flowchart LR + S[External system] --> E[POST /webhooks/{configurationId}] + E --> H[InboundWebhookHandler] + H --> D[Security dispatcher] + D --> C[WebhookConnector] + C --> M[Dynamic mappings] + M --> T[Entity Template] +``` + +## Webhook Connector + +A webhook connector is the runtime configuration stored by IDP-Core for one inbound integration. + +| Field | Type | Description | +| --- | --- | --- | +| `identifier` | String | Stable key used in the webhook URL and management APIs | +| `title` | String | Human-readable name | +| `description` | String | Optional explanation of the connector purpose | +| `enabled` | Boolean | Enables or disables request processing | +| `mappings` | Array | One or more dynamic mapping rules | +| `security` | Object | Authentication strategy and configuration | + +### Example + +```json +{ + "identifier": "github-repositories", + "title": "GitHub repositories", + "description": "Receives repository events from GitHub", + "enabled": true, + "mappings": [ + { + "template": "github_repository", + "filter": ".action == \"created\" or .action == \"edited\"", + "entity": { + "identifier": ".repository.full_name | gsub(\"/\"; \"_\")", + "title": ".repository.name", + "properties": { + "name": ".repository.name", + "url": ".repository.html_url", + "language": ".repository.language // \"Unknown\"" + }, + "relations": { + "owner": ".repository.owner.login" + } + } + } + ], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_WEBHOOK_SECRET", + "prefix": "sha256=" + } + } +} +``` + +## Dynamic Mappings + +Each connector contains at least one dynamic mapping. A mapping targets one Entity Template and describes how to derive entity fields from the incoming JSON payload with a JSLT filter and entity projections. + +| Field | Description | +| --- | --- | +| `template` | Target Entity Template identifier | +| `filter` | Expression that decides whether the mapping applies | +| `entity.identifier` | Expression that generates the entity identifier | +| `entity.title` | Expression that generates the entity title | +| `entity.properties` | Map of template property names to extraction expressions | +| `entity.relations` | Map of template relation names to extraction expressions | + +### Validation Rules + +When you create or update a connector, IDP-Core validates each mapping against the target Entity Template. + +It checks that: + +- The referenced template exists +- Every mapped property exists in the template +- Every required property is mapped +- Every mapped relation exists in the template +- Every required relation is mapped + +This validation keeps the connector configuration aligned with the current data model. + +## Security Strategies + +Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests again at runtime. + +| Type | Required configuration keys | Runtime behavior | +| --- | --- | --- | +| `HMAC_SHA256` | `header_name`, `secret_alias`, `prefix` | Computes the SHA-256 HMAC of the raw body and compares it with the request header | +| `STATIC_TOKEN` | `header_name`, `secret_alias` | Compares a header value with a secret loaded from the environment | +| `BASIC_AUTH` | `username`, `secret_alias` | Compares the `Authorization: Basic ...` header with the configured username and secret | +| `JWT_BEARER` | `jwks_uri` | Validates the bearer token against a JWKS endpoint | +| `NONE` | none | Skips authentication | + +> [!IMPORTANT] +> Security configuration keys accept `snake_case` and `camelCase` variants for the supported fields. +> [!WARNING] +> `secret_alias` must reference an environment variable alias in `UPPER_SNAKE_CASE`. It does not store the raw secret value in the connector configuration. + +### Example Security Configurations + +=== "HMAC_SHA256" + ```json + { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_WEBHOOK_SECRET", + "prefix": "sha256=" + } + } + ``` + +=== "STATIC_TOKEN" + ```json + { + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Webhook-Token", + "secret_alias": "WEBHOOK_SHARED_TOKEN" + } + } + ``` + +=== "BASIC_AUTH" + ```json + { + "type": "BASIC_AUTH", + "config": { + "username": "webhook-user", + "secret_alias": "WEBHOOK_PASSWORD" + } + } + ``` + +=== "JWT_BEARER" + ```json + { + "type": "JWT_BEARER", + "config": { + "jwks_uri": "https://issuer.example.com/.well-known/jwks.json" + } + } + ``` + +## Runtime Flow + +The webhook runtime uses a single generic endpoint: + +```text +POST /webhooks/{configurationId} +``` + +The request flow is: + +1. IDP-Core receives the request on the generic webhook endpoint. +2. The `configurationId` resolves the stored `WebhookConnector`. +3. If the connector is disabled, IDP-Core ignores the event. +4. The security dispatcher selects the matching strategy for the connector security type. +5. The strategy validates the headers and, when needed, the raw request body. +6. After authentication, the event is accepted for downstream processing. + +> [!IMPORTANT] +> The connector model, security validation, management APIs, and mapping validation are implemented now. + +## Management API Methods + +You manage webhook connectors through the inbound webhook management API, which exposes standard CRUD methods. + +| HTTP Method | Endpoint | Purpose | +| --- | --- | --- | +| `POST` | `/api/v1/inbound-webhooks` | Create connector | +| `GET` | `/api/v1/inbound-webhooks` | List connectors | +| `GET` | `/api/v1/inbound-webhooks/{identifier}` | Get connector | +| `PUT` | `/api/v1/inbound-webhooks/{identifier}` | Update connector | +| `DELETE` | `/api/v1/inbound-webhooks/{identifier}` | Delete connector | + +This separation keeps configuration management under versioned API routes while the event ingestion endpoint stays simple for external systems. + +## When to Use Webhooks + +Use webhooks when an external system can push JSON events over HTTP and you want to: + +- Ingest updates without redeploying IDP-Core +- Reuse one generic endpoint for multiple providers +- Apply connector-specific authentication rules +- Map external payloads to your own Entity Templates at runtime + +--- + +## Next Steps + +- **[Entity Templates](entity-templates.md)** - Define the target structures that mappings reference +- **[Entities](entities.md)** - Understand the records produced by successful ingestion +- **[Relations](relations.md)** - Model links that webhook mappings can populate +- **[Data Integration](../features/data-integration.md)** - Explore the broader ingestion roadmap diff --git a/docs/src/features/data-integration.md b/docs/src/features/data-integration.md index 131b2c7d..b2a5d612 100644 --- a/docs/src/features/data-integration.md +++ b/docs/src/features/data-integration.md @@ -14,7 +14,7 @@ The Internal Developer Platform provides flexible data integration, allowing you Data integration in the Internal Developer Platform follows a three-step pattern: 1. **Configure a connector** - Set up a Webhook, Kafka consumer, or Pub/Sub subscription -2. **Define mappings** - Use JQ expressions to transform incoming data +2. **Define mappings** - Use JSLT expressions to transform incoming data 3. **Ingest data** - Data flows automatically, creating and updating entities ```mermaid @@ -55,6 +55,19 @@ flowchart LR Webhooks allow external systems to push data to IDP-Core via HTTP POST requests. +### Methods + +| Method | Endpoint | Purpose | +| ------ | -------- | ------- | +| `POST` | `/webhooks/{configurationId}` | Receive an inbound event for the connector identified in the URL | +| `POST` | `/api/v1/inbound-webhooks` | Create a webhook connector configuration | +| `GET` | `/api/v1/inbound-webhooks` | List webhook connector configurations | +| `GET` | `/api/v1/inbound-webhooks/{identifier}` | Read one webhook connector configuration | +| `PUT` | `/api/v1/inbound-webhooks/{identifier}` | Update one webhook connector configuration | +| `DELETE` | `/api/v1/inbound-webhooks/{identifier}` | Delete one webhook connector configuration | + + These HTTP routes map to the `InboundWebhookManagementController` methods for connector management. + ### Webhook Configuration ```json @@ -83,33 +96,37 @@ Webhooks allow external systems to push data to IDP-Core via HTTP POST requests. } ], "security": { - "signature_header_name": "X-Sonar-Webhook-HMAC-SHA256", - "signature_value": "your-secret-token" + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Sonar-Webhook-HMAC-SHA256", + "secret_alias": "SONAR_WEBHOOK_SECRET", + "prefix": "sha256=" + } } } ``` ### Configuration Fields -| Field | Description | -| ------------- | ---------------------------- | -| `identifier` | Unique key for this webhook | -| `title` | Human-readable name | -| `description` | Purpose of the webhook | -| `enabled` | Toggle ingestion on/off | -| `mappings` | Array of mapping rules | -| `security` | Authentication configuration | +| Field | Description | +|---------------|-----------------------------------------------------------------| +| `identifier` | Unique key for this webhook | +| `title` | Human-readable name | +| `description` | Purpose of the webhook | +| `enabled` | Toggle ingestion on/off | +| `mappings` | Array of mapping rules | +| `security` | Authentication configuration using a `type` + `config` contract | ### Mapping Structure -| Field | Description | -| ------------------- | ------------------------------------------- | -| `template` | Target Entity Template identifier | -| `filter` | JQ expression to filter incoming payloads | -| `entity.identifier` | JQ expression to generate entity identifier | -| `entity.title` | JQ expression for entity title | -| `entity.properties` | Map of property names to JQ expressions | -| `entity.relations` | Map of relation names to JQ expressions | +| Field | Description | +|---------------------|-----------------------------------------------| +| `template` | Target Entity Template identifier | +| `filter` | JSLT expression to filter incoming payloads | +| `entity.identifier` | JSLT expression to generate entity identifier | +| `entity.title` | JSLT expression for entity title | +| `entity.properties` | Map of property names to JSLT expressions | +| `entity.relations` | Map of relation names to JSLT expressions | --- @@ -165,9 +182,9 @@ spring: --- -## JQ Mapping Reference +## JSLT Mapping Reference -The Internal Developer Platform will use [JQ](https://jqlang.github.io/jq/) for data transformation. It will access to the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Please refer to the JQ documentation for detailed usage. +The Internal Developer Platform uses [JSLT](https://github.com/schibsted/jslt) for data transformation. It accesses the entire JSON payload sent to the webhook or consumed from Kafka/Pub-Sub. Refer to the JSLT documentation for detailed usage. --- @@ -201,8 +218,12 @@ Configure a webhook to receive GitHub repository events: } ], "security": { - "signature_header_name": "X-Hub-Signature-256", - "signature_value": "sha256=your-webhook-secret" + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "GITHUB_WEBHOOK_SECRET", + "prefix": "sha256=" + } } } ``` @@ -218,8 +239,11 @@ Webhooks support signature-based authentication: ```json { "security": { - "signature_header_name": "X-Webhook-Signature", - "signature_value": "expected-secret-or-hmac" + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Webhook-Signature", + "secret_alias": "WEBHOOK_SHARED_TOKEN" + } } } ``` diff --git a/docs/src/features/index.md b/docs/src/features/index.md index 32deb947..42a7a559 100644 --- a/docs/src/features/index.md +++ b/docs/src/features/index.md @@ -13,7 +13,7 @@ The Internal Developer Platform provides a comprehensive set of features to buil --- - Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JQ expressions. + Connect to any data source through Webhooks, Kafka, or Pub/Sub. Map incoming data to entities using JSLT expressions. **Status:** 🕐 Planned diff --git a/docs/src/index.md b/docs/src/index.md index 3e7bb9ff..e19bd660 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -131,7 +131,7 @@ Define your own **Entity Templates** that mirror your organization's specific ne ### Multi-Source Data Ingestion -Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JQ expressions. +Connect to any data source through **Webhooks**, **Kafka/Pub-Sub**, or direct API calls. Map incoming data to your entities using JSLT expressions. ### Scorecards & Metrics diff --git a/docs/zensical.toml b/docs/zensical.toml index 4ec20b9e..6709c11d 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 691826e5..1094d6d6 100644 --- a/pom.xml +++ b/pom.xml @@ -225,6 +225,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..2f94b0a2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -15,6 +15,7 @@ public class ValidationMessages { public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; + public static final String TEMPLATE_ALREADY_MAPPED_WEBHOOK = "Cannot delete template because it is currently mapped to '%s' webhook connectors. Please remove the associated webhook mappings before deleting the template."; // Property Definition validation messages public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; @@ -62,6 +63,12 @@ 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"; + // Entity creation validation messages public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingConfigurationException.java new file mode 100644 index 00000000..faa75bf6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingConfigurationException.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.domain.exception.entity_mapping; + +public class EntityDynamicMappingConfigurationException extends RuntimeException { + + public EntityDynamicMappingConfigurationException(String message) { + super(message); + } + + public EntityDynamicMappingConfigurationException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateInUseByWebhookMappingException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateInUseByWebhookMappingException.java new file mode 100644 index 00000000..6f675865 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateInUseByWebhookMappingException.java @@ -0,0 +1,32 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + +/// Domain exception for missing [EntityTemplate] business entities. +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to access an EntityTemplate that doesn't exist in the system. This is a +/// critical business error since entities cannot be created without valid templates. +/// +/// **Exception design rationale:** +/// - Multiple constructors support different lookup scenarios (ID, identifier, field-based) +/// - Meaningful error messages aid in debugging and API error responses +/// - Domain-level exception keeps business logic separate from HTTP concerns +/// +/// **Usage patterns:** +/// - Template validation before entity operations +/// - Template-based entity queries +/// - Template management operations +public class EntityTemplateInUseByWebhookMappingException extends RuntimeException { + + /// Constructs a new exception with a custom error message. + /// + /// **Why this exists:** Allows for specific error messages that provide more + /// context about the search criteria or operation that failed. + /// + /// @param message the detail message explaining what was not found + public EntityTemplateInUseByWebhookMappingException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/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/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/webhook/WebhookAuthenticationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java new file mode 100644 index 00000000..eafbc81a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookAuthenticationException.java @@ -0,0 +1,11 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookAuthenticationException extends RuntimeException { + public WebhookAuthenticationException(String message) { + super(message); + } + + public WebhookAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java new file mode 100644 index 00000000..26b07865 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorAlreadyExistException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_ALREADY_EXIST; + +public class WebhookConnectorAlreadyExistException extends RuntimeException { + + public WebhookConnectorAlreadyExistException(String identifier) { + super(String.format("%s:%s", WEBHOOK_CONNECTOR_ALREADY_EXIST, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java new file mode 100644 index 00000000..8e848a26 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorConfigurationException.java @@ -0,0 +1,7 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookConnectorConfigurationException extends RuntimeException { + public WebhookConnectorConfigurationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java new file mode 100644 index 00000000..bd9fc6bc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorNotFoundException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_IDENTIFIER_NOT_FOUND; + +public class WebhookConnectorNotFoundException extends RuntimeException { + + public WebhookConnectorNotFoundException(String identifier) { + super(String.format(WEBHOOK_IDENTIFIER_NOT_FOUND, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java new file mode 100644 index 00000000..da65c822 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookConnectorTitleAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST; + +public class WebhookConnectorTitleAlreadyExistsException extends RuntimeException { + public WebhookConnectorTitleAlreadyExistsException(String webhookName) { + super(String.format("%s:%s", WEBHOOK_CONNECTOR_TITLE_ALREADY_EXIST, webhookName)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java new file mode 100644 index 00000000..6bc70eb0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookSecurityConfigurationException.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookSecurityConfigurationException extends RuntimeException { + + public WebhookSecurityConfigurationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookTemplateHasNoPropertiesException.java b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookTemplateHasNoPropertiesException.java new file mode 100644 index 00000000..e7b9d6dc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/webhook/WebhookTemplateHasNoPropertiesException.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.exception.webhook; + +public class WebhookTemplateHasNoPropertiesException extends RuntimeException { + + public WebhookTemplateHasNoPropertiesException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java new file mode 100644 index 00000000..257a41c2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java @@ -0,0 +1,43 @@ +package com.decathlon.idp_core.domain.model.entity_mapping; + +import java.util.Map; +import java.util.UUID; + +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingConfigurationException; + +/// Domain model representing dynamic entity mapping configuration. +/// +/// Each mapping defines how to transform inbound webhook events into entity instances, +/// including property/relation mappings and JSLT transformation rules. +/// +/// Note: The technical ID is managed purely at the infrastructure layer +/// (persisted in entity_dynamic_mapping table) and is NOT part of the domain model. +public record EntityDynamicMapping(UUID id, String templateIdentifier, String filter, + String entityIdentifier, String entityTitle, Map properties, + Map relations) { + + public EntityDynamicMapping { + if (isBlank(templateIdentifier)) { + throw new EntityDynamicMappingConfigurationException("Template identifier cannot be empty"); + } + + if (isBlank(filter)) { + throw new EntityDynamicMappingConfigurationException("Filter cannot be empty"); + } + + if (isBlank(entityIdentifier)) { + throw new EntityDynamicMappingConfigurationException("EntityIdentifier cannot be empty"); + } + + if (isBlank(entityTitle)) { + throw new EntityDynamicMappingConfigurationException("EntityTitle cannot be empty"); + } + + properties = properties == null ? Map.of() : Map.copyOf(properties); + relations = relations == null ? Map.of() : Map.copyOf(relations); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java new file mode 100644 index 00000000..992e7dc8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/WebhookSecurityType.java @@ -0,0 +1,16 @@ +package com.decathlon.idp_core.domain.model.enums; + +/// Discriminator for the security validation strategy of a [WebhookConnector]. +/// +/// | Strategy | headerName | secretAlias | prefix | username | jwksUri | +/// |--------------|------------|--------------------|----------|----------|---------| +/// | HMAC_SHA256 | Required | Required (hash key)| Optional | — | — | +/// | JWT_BEARER | — | — | — | — | Required| +/// | STATIC_TOKEN | Required | Required (target) | — | — | — | +/// | BASIC_AUTH | — | Required (password)| — | Required | — | +/// | NONE | — | — | — | — | — | +/// +/// `NONE` means the connector intentionally accepts unauthenticated requests. +public enum WebhookSecurityType { + HMAC_SHA256, JWT_BEARER, STATIC_TOKEN, BASIC_AUTH, NONE +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java new file mode 100644 index 00000000..d50ccaff --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnector.java @@ -0,0 +1,43 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_NAME_MAX_SIZE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY; + +import java.util.List; +import java.util.UUID; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorConfigurationException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +public record WebhookConnector(UUID id, String identifier, String title, String description, + boolean enabled, List mappings, WebhookSecurity security) { + public WebhookConnector { + mappings = mappings == null ? List.of() : List.copyOf(mappings); + + if (security == null) { + throw new WebhookSecurityConfigurationException("Webhook security type is mandatory"); + } + if (mappings.isEmpty()) { + enabled = false; + } + + if (isBlank(identifier)) { + throw new WebhookConnectorConfigurationException(WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY); + } + if (identifier.length() > 255) { + throw new WebhookConnectorConfigurationException("Webhook title is too long"); + } + + if (isBlank(title)) { + throw new WebhookConnectorConfigurationException("Webhook title is mandatory"); + } + if (title.length() > 255) { + throw new WebhookConnectorConfigurationException(TEMPLATE_NAME_MAX_SIZE); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java new file mode 100644 index 00000000..706c9f09 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookSecurity.java @@ -0,0 +1,20 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import java.util.Map; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +public record WebhookSecurity(WebhookSecurityType type, Map config) { + + public WebhookSecurity { + if (type == null) { + throw new WebhookSecurityConfigurationException("Webhook security type is mandatory"); + } + if (config == null) { + throw new WebhookSecurityConfigurationException( + "Webhook security config section is mandatory"); + } + config = Map.copyOf(config); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java new file mode 100644 index 00000000..dffff6f8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookTemplateMapping.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import java.util.UUID; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + +/// Domain model representing the mapping between a webhook event and an entity template. +/// +/// Per the webhook_template_mapping schema: +/// - Links a webhook connector to an entity template for event ingestion +/// - Contains the JSLT filter to apply during transformation +/// - Includes both technical IDs (from persistence) and functional domain objects +/// +/// @param id technical identifier of the mapping record +/// @param webhookConnector domain model of the associated webhook connector +/// @param entityTemplate domain model of the target entity template +/// @param entityDynamicMapping domain model of the dynamic mapping configuration +/// @param jsltFilter JSLT filter expression for event ingestion +public record WebhookTemplateMapping(UUID id, WebhookConnector webhookConnector, + EntityTemplate entityTemplate, EntityDynamicMapping entityDynamicMapping, String jsltFilter) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java new file mode 100644 index 00000000..7c55a86f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMapperValidator.java @@ -0,0 +1,8 @@ +package com.decathlon.idp_core.domain.port; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +public interface EntityDynamicMapperValidator { + + void validate(EntityDynamicMapping mapping); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java new file mode 100644 index 00000000..33b41f72 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java @@ -0,0 +1,11 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.List; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; + +public interface EntityDynamicMappingPort { + + List findByTemplateIdentifier(String templateIdentifier); + Boolean existsByTemplateIdentifier(String templateIdentifier); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java new file mode 100644 index 00000000..fba415ae --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookConnectorRepositoryPort.java @@ -0,0 +1,23 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; + +public interface WebhookConnectorRepositoryPort { + + Optional findByIdentifier(String identifier); + + Page findAll(Pageable pageable); + + boolean existsByIdentifier(String identifier); + + boolean existsByTitle(String title); + + WebhookConnector save(WebhookConnector connector); + + void deleteByIdentifier(String identifier); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java new file mode 100644 index 00000000..3e151508 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookSecurityStrategy.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.Map; + +/// Unified strategy contract for webhook security handling. +/// +/// This interface consolidates two responsibilities that were previously scattered: +/// 1. Validating security configuration at creation/update time +/// 2. Validating incoming webhook requests at runtime +/// +/// Implementations should focus on security logic without side effects. +public interface WebhookSecurityStrategy { + + /// Checks if this strategy supports the given security type. + /// + /// @param securityType the security type to check (e.g., "BASIC_AUTH", + /// "HMAC_SHA256") + /// @return true if this strategy handles this security type + boolean supports(String securityType); + + /// Validates the security configuration provided at creation/update time. + /// + /// @param config the security configuration map (e.g., username, secret_alias) + /// @throws + /// com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException + /// if validation fails + void validateConfiguration(Map config); + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.java new file mode 100644 index 00000000..098d1831 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.List; +import java.util.UUID; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; + +public interface WebhookTemplateMappingPort { + + List findByTemplateId(UUID templateId); + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 4ec24faa..436d0fd6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -1,22 +1,18 @@ package com.decathlon.idp_core.domain.service.entity_template; +import java.util.List; +import java.util.Map; import java.util.Objects; import org.springframework.stereotype.Service; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.*; +import com.decathlon.idp_core.domain.exception.webhook.WebhookTemplateHasNoPropertiesException; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.webhook.WebhookTemplateMappingService; import lombok.RequiredArgsConstructor; @@ -34,6 +30,7 @@ public class EntityTemplateValidationService { private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; private final PropertyDefinitionValidationService propertyDefinitionValidationService; private final RelationDefinitionValidationService relationDefinitionValidationService; + private final WebhookTemplateMappingService webhookTemplateMappingService; /// Validates all business rules before creating a new entity template. /// @@ -139,6 +136,9 @@ public void validateForDeletion(String identifier) { throw new EntityTemplateNotFoundException("identifier", "null"); } validateTemplateExists(identifier); + EntityTemplate template = entityTemplateRepositoryPort.findByIdentifier(identifier) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", identifier)); + webhookTemplateMappingService.validateTemplateNotInUseMapping(template.id()); } /// Checks that the entity template exists. @@ -191,6 +191,29 @@ private void validateTemplateProperties(EntityTemplate entityTemplate) { } } + public void validatePropertiesExistInTemplate(Map mappingProperties, + List templateProperties) { + + if (!mappingProperties.isEmpty() && templateProperties.isEmpty()) { + throw new WebhookTemplateHasNoPropertiesException( + "The mapping defines properties but the target template has no property definitions"); + } + + mappingProperties.keySet() + .forEach(propertyName -> validatePropertyNameAlreadyExistInTemplate(templateProperties, + propertyName)); + } + + public void validateRelationNameAlreadyExistInTemplate( + Map webhookMappingRelations, EntityTemplate entityTemplate) { + if (webhookMappingRelations == null || webhookMappingRelations.isEmpty()) { + return; + } + webhookMappingRelations.keySet() + .forEach(relationName -> validateRelationNameAlreadyExistInTemplate( + entityTemplate.relationsDefinitions(), relationName)); + } + /// Validates all relation definitions within the template for structural and /// referential integrity. /// @@ -213,4 +236,32 @@ private void validateTemplateRelations(EntityTemplate entityTemplate) { .validateTargetTemplatesExist(entityTemplate.relationsDefinitions()); } + public void validatePropertyNameAlreadyExistInTemplate(List properties, + String propertyName) { + if (!isPropertyNameIsOwnedByEntityTemplate(properties, propertyName)) { + throw new PropertyNameNotFoundEntityTemplatePropertiesException( + String.format("Property name %s not found in entity template properties", propertyName)); + } + } + + public void validateRelationNameAlreadyExistInTemplate(List relations, + String relationName) { + if (!isRelationIsOwnedByEntityTemplate(relations, relationName)) { + throw new RelationNameNotFoundEntityTemplateRelationsException( + String.format("Relation name %s not found in entity template relations", relationName)); + } + } + + private boolean isPropertyNameIsOwnedByEntityTemplate(List properties, + String propertyName) { + return properties != null && properties.stream() + .anyMatch(entityTemplateProperty -> entityTemplateProperty.name().equals(propertyName)); + } + + private boolean isRelationIsOwnedByEntityTemplate(List relations, + String relationName) { + return relations != null && relations.stream() + .anyMatch(entityTemplateRelation -> entityTemplateRelation.name().equals(relationName)); + } + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java new file mode 100644 index 00000000..fff59085 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java @@ -0,0 +1,104 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookTemplateHasNoPropertiesException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; +import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + +import lombok.RequiredArgsConstructor; + +/// Validates webhook dynamic mappings against their target entity template. +/// This service ensures the mapping references an existing template, that all +/// mapped properties and relations exist in that template, and that required +/// template elements are provided before the mapping is accepted. +@Service +@Validated +@RequiredArgsConstructor +public class EntityDynamicMappingValidationService { + private final EntityTemplateService entityTemplateService; + private final EntityDynamicMapperValidator entityDynamicMapperValidator; + private final EntityTemplateValidationService entityTemplateValidationService; + + /// Validates all mappings attached to a webhook connector. + /// + /// @param mappings the mappings to validate + /// @throws WebhookTemplateHasNoPropertiesException when one or more mappings + /// are invalid + public void validateWebhookMapping(List mappings) { + mappings.forEach(this::validateMapping); + } + + /// Validates a single [EntityDynamicMapping]: + /// - The referenced EntityTemplate must exist. + /// - Each key in `properties` must match a property defined in the template. + /// - Required properties and relations from the target template are present. + /// - The mapping expression syntax is valid. + /// + /// @param webhookMapping the mapping to validate + private void validateMapping(EntityDynamicMapping webhookMapping) { + String templateIdentifier = webhookMapping.templateIdentifier(); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(templateIdentifier); + entityTemplateValidationService.validatePropertiesExistInTemplate(webhookMapping.properties(), + entityTemplate.propertiesDefinitions()); + validateRequiredPropertiesAreMapped(webhookMapping.properties(), + entityTemplate.propertiesDefinitions()); + entityTemplateValidationService + .validateRelationNameAlreadyExistInTemplate(webhookMapping.relations(), entityTemplate); + validateRequiredRelationDefinitionsAreMapped(webhookMapping.relations(), + entityTemplate.relationsDefinitions()); + entityDynamicMapperValidator.validate(webhookMapping); + } + + /// Validates that all required relation definitions in the target template + /// are provided by the mapping. + /// + /// @param mappingRelations relations declared by the mapping + /// @param templateRelations relation definitions declared by the template + /// @throws WebhookTemplateHasNoPropertiesException when one or more required + /// relations are missing in the mapping + private void validateRequiredRelationDefinitionsAreMapped(Map mappingRelations, + List templateRelations) { + List missingRelations = templateRelations.stream().filter(RelationDefinition::required) + .map(RelationDefinition::name).filter(requiredRelation -> mappingRelations == null + || !mappingRelations.containsKey(requiredRelation)) + .toList(); + if (!missingRelations.isEmpty()) { + throw new WebhookTemplateHasNoPropertiesException( + String.format("The mapping is missing required template relations: %s", + String.join(", ", missingRelations))); + } + } + + /// Validates that all required property definitions in the target template + /// are provided by the mapping. + /// + /// @param mappingProperties properties declared by the mapping + /// @param templateProperties property definitions declared by the template + /// @throws WebhookTemplateHasNoPropertiesException when one or more required + /// properties are missing in the mapping + private void validateRequiredPropertiesAreMapped(Map mappingProperties, + List templateProperties) { + List missingProperties = templateProperties.stream() + .filter(PropertyDefinition::required).map(PropertyDefinition::name) + .filter(requiredName -> !mappingProperties.containsKey(requiredName)).toList(); + + if (!missingProperties.isEmpty()) { + throw new WebhookTemplateHasNoPropertiesException( + String.format("The mapping is missing required template properties: %s", + String.join(", ", missingProperties))); + } + + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java new file mode 100644 index 00000000..ab8953b2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorService.java @@ -0,0 +1,62 @@ +package com.decathlon.idp_core.domain.service.webhook; + +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.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@Validated +@RequiredArgsConstructor +public class WebhookConnectorService { + + private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + private final WebhookConnectorValidationService webhookConnectorValidationService; + + public WebhookConnector getWebhookConnector(String identifier) { + return webhookConnectorRepositoryPort.findByIdentifier(identifier) + .orElseThrow(() -> new WebhookConnectorNotFoundException(identifier)); + } + + @Transactional + public WebhookConnector createWebhookConnector(@Valid WebhookConnector connector) { + webhookConnectorValidationService.validateWebhookConnectorForCreation(connector); + return webhookConnectorRepositoryPort.save(connector); + } + + @Transactional + public WebhookConnector updateWebhookConnector(String identifier, + @Valid WebhookConnector connectorToUpdate) { + WebhookConnector webhookConnectorInDb = getWebhookConnector(identifier); + webhookConnectorValidationService.validateWebhookConnectorForUpdate(webhookConnectorInDb, + connectorToUpdate); + + WebhookConnector mergedConnector = new WebhookConnector(webhookConnectorInDb.id(), + webhookConnectorInDb.identifier(), connectorToUpdate.title(), + connectorToUpdate.description(), connectorToUpdate.enabled(), connectorToUpdate.mappings(), + connectorToUpdate.security()); + + return webhookConnectorRepositoryPort.save(mergedConnector); + } + + @Transactional + public void deleteWebhookConnector(String webhookConnectorIdentifier) { + webhookConnectorValidationService.validateIdentifierExists(webhookConnectorIdentifier); + webhookConnectorRepositoryPort.deleteByIdentifier(webhookConnectorIdentifier); + } + + public Page getAllWebhookConnector(Pageable pageable) { + return webhookConnectorRepositoryPort.findAll(pageable); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java new file mode 100644 index 00000000..e208fb3c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationService.java @@ -0,0 +1,76 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorAlreadyExistException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorTitleAlreadyExistsException; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.domain.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 webhookConnectorMappingValidationService; + private final WebhookSecurityValidationService webhookSecurityValidationService; + + public void validateWebhookConnectorForCreation(WebhookConnector webhookConnector) { + validateIdentifierUniqueness(webhookConnector.identifier()); + validateTitleUniqueness(webhookConnector.title()); + validateMappingsIfPresent(webhookConnector); + webhookSecurityValidationService.validateForCreation(webhookConnector.security()); + + } + + public void validateWebhookConnectorForUpdate(WebhookConnector existingConnector, + WebhookConnector webhookConnectorToUpdate) { + if (!existingConnector.title().equals(webhookConnectorToUpdate.title())) { + validateTitleUniqueness(webhookConnectorToUpdate.title()); + } + validateMappingsIfPresent(webhookConnectorToUpdate); + webhookSecurityValidationService.validateForCreation(webhookConnectorToUpdate.security()); + } + + private void validateMappingsIfPresent(WebhookConnector webhookConnector) { + if (!webhookConnector.mappings().isEmpty()) { + webhookConnectorMappingValidationService.validateWebhookMapping(webhookConnector.mappings()); + } + } + + public void validateTitleUniqueness(String webhookTitle) { + if (webhookConnectorRepositoryPort.existsByTitle(webhookTitle)) { + throw new WebhookConnectorTitleAlreadyExistsException(webhookTitle); + } + + } + + /// Checks that no other [WebhookConnector] exists with the same identifier + /// before allowing creation. + /// + /// @param webhookConnectorIdentifier the webhook connector identifier to check + /// for uniqueness + /// @throws WebhookConnectorAlreadyExistException if a connector with the same + /// identifier already exists + private void validateIdentifierUniqueness(String webhookConnectorIdentifier) { + if (webhookConnectorRepositoryPort.existsByIdentifier(webhookConnectorIdentifier)) { + throw new WebhookConnectorAlreadyExistException(webhookConnectorIdentifier); + } + } + + public void validateIdentifierExists(String webhookConnectorIdentifier) { + if (!webhookConnectorRepositoryPort.existsByIdentifier(webhookConnectorIdentifier)) { + throw new WebhookConnectorNotFoundException(webhookConnectorIdentifier); + } + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookTemplateMappingService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookTemplateMappingService.java new file mode 100644 index 00000000..5c22a9bc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/WebhookTemplateMappingService.java @@ -0,0 +1,51 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_MAPPED_WEBHOOK; + +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateInUseByWebhookMappingException; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.domain.port.WebhookTemplateMappingPort; + +import lombok.RequiredArgsConstructor; + +/// Domain service for webhook template mapping operations. +/// +/// Validates template usage in webhook mappings before deletion. +@Service +@Validated +@RequiredArgsConstructor +public class WebhookTemplateMappingService { + + private final WebhookTemplateMappingPort webhookTemplateMappingPort; + + /// Retrieves all mappings for a given entity template. + /// + /// @param templateId template technical UUID + /// @return list of associated webhook template mappings + public List findByTemplateId(UUID templateId) { + return webhookTemplateMappingPort.findByTemplateId(templateId); + } + + /// Validates that a template is not in use by any webhook mapping. + /// + /// @param entityTemplateId the entity template UUID to check + /// @throws EntityTemplateInUseByWebhookMappingException if template is already + /// in use + public void validateTemplateNotInUseMapping(UUID entityTemplateId) { + List mappings = findByTemplateId(entityTemplateId); + if (!mappings.isEmpty()) { + List webhookIds = mappings.stream().map(WebhookTemplateMapping::webhookConnector) + .filter(webhook -> webhook != null && webhook.id() != null) + .map(WebhookConnector::identifier).distinct().toList(); + throw new EntityTemplateInUseByWebhookMappingException( + TEMPLATE_ALREADY_MAPPED_WEBHOOK.formatted(webhookIds)); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java new file mode 100644 index 00000000..538ff3a2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationService.java @@ -0,0 +1,56 @@ +package com.decathlon.idp_core.domain.service.webhook.security; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +/// Domain service for validating webhook security configuration at creation/update time. +/// +/// This service ensures that the security configuration provided when creating or updating +/// a webhook connector is valid before storing it in the database. +@Service +public class WebhookSecurityValidationService { + + private final List strategies; + + public WebhookSecurityValidationService(List strategies) { + this.strategies = List.copyOf(strategies); + } + + /// Validates webhook security configuration for creation or update. + /// + /// @param security the security configuration to validate + /// @throws WebhookSecurityConfigurationException if the configuration is + /// invalid + public void validateForCreation(WebhookSecurity security) { + if (security == null) { + throw new WebhookSecurityConfigurationException("Webhook security section is mandatory"); + } + + Map config = security.config(); + + if (security.type() == WebhookSecurityType.NONE) { + validateNoSecurityConfig(config); + return; + } + + strategies.stream().filter(strategy -> strategy.supports(security.type().name())).findFirst() + .ifPresentOrElse(strategy -> strategy.validateConfiguration(config), () -> { + throw new WebhookSecurityConfigurationException( + "No validator registered for security type: " + security.type()); + }); + } + + private void validateNoSecurityConfig(Map config) { + if (!config.isEmpty()) { + throw new WebhookSecurityConfigurationException( + "Webhook security config must be empty when type is NONE"); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java index a0e0e7cc..26f06ddc 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 @@ -15,6 +15,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; 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; @@ -96,4 +97,11 @@ 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); + } + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 117d88a8..7de5d604 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 @@ -66,11 +66,24 @@ 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"; + /// API response description constants public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; @@ -96,6 +109,11 @@ public class SwaggerDescription { public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS = "Paginated webhook connector retrieved successfully"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_DELETED = "Webhook connector deleted successfully"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER = "Webhook connector not found with the provided identifier"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_FOUND = "Webhook connector found"; + public static final String RESPONSE_WEBHOOK_CONNECTOR_UPDATED = "Webhook connector updated successfully"; // --- 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/InboundWebhookManagementController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java new file mode 100644 index 00000000..30c206c8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java @@ -0,0 +1,111 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; +import static org.springframework.http.HttpStatus.*; + +import jakarta.validation.Valid; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.webhook.InboundWebhookMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +/// REST controller exposing inbound webhook configuration management endpoints. +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/inbound-webhooks") +@Tag(name = "Inbound Webhook Management", description = "Operations for managing inbound webhook connector configurations") +public class InboundWebhookManagementController { + + private final WebhookConnectorService webhookConnectorService; + private final InboundWebhookMapper inboundWebhookMapper; + + /// Creates a new inbound webhook connector configuration. + /// + /// @param request creation payload + /// @return created connector response + @Operation(summary = "Create inbound webhook configuration", description = "Creates a webhook connector configuration used by the generic inbound webhook endpoint") + @ApiResponse(responseCode = "201", description = "Webhook connector created") + @ApiResponse(responseCode = "400", description = "Invalid request payload") + @ApiResponse(responseCode = "409", description = "Identifier already exists") + @PostMapping + @ResponseStatus(CREATED) + public InboundWebhookDtoOut createInboundWebhook( + @Valid @RequestBody InboundWebhookCreateDtoIn request) { + WebhookConnector webhookConnector = webhookConnectorService + .createWebhookConnector(inboundWebhookMapper.toDomain(request)); + return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector); + } + + @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.WebhookConnectorPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @GetMapping + @ResponseStatus(OK) + public Page getTemplatesPaginated( + @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { + return webhookConnectorService.getAllWebhookConnector(pageable) + .map(inboundWebhookMapper::fromWebhookConnectorToDto); + } + + @Operation(summary = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_DELETED) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ResponseStatus(NO_CONTENT) + @DeleteMapping("/{identifier}") + public void deleteTemplate(@PathVariable String identifier) { + webhookConnectorService.deleteWebhookConnector(identifier); + } + + @Operation(summary = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_WEBHOOK_CONNECTOR_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_FOUND, content = { + @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{identifier}") + @ResponseStatus(OK) + public InboundWebhookDtoOut getWebhookConnectorByIdentifier(@PathVariable String identifier) { + WebhookConnector webhookConnector = webhookConnectorService.getWebhookConnector(identifier); + return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector); + } + + @Operation(summary = ENDPOINT_PUT_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_PUT_WEBHOOK_CONNECTOR_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_UPDATED, content = { + @Content(schema = @Schema(implementation = InboundWebhookDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid request payload", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = CONFLICT_CODE, description = "Webhook connector title already exists", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PutMapping("/{identifier}") + @ResponseStatus(OK) + public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier, + @Valid @RequestBody InboundWebhookCreateDtoIn request) { + return inboundWebhookMapper + .fromWebhookConnectorToDto(webhookConnectorService.updateWebhookConnector(identifier, + inboundWebhookMapper.toDomainForUpdate(identifier, request))); + } +} 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..de6c66c7 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java @@ -0,0 +1,20 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY; + +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + +/// Request payload used to create an inbound webhook connector configuration. +public record InboundWebhookCreateDtoIn( + @NotBlank(message = WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY) String identifier, + @NotBlank(message = "Webhook title is mandatory") String title, String description, + boolean enabled, List<@Valid InboundWebhookMappingDtoIn> mappings, + @Valid InboundWebhookSecurityContractDtoIn security) { + + public InboundWebhookCreateDtoIn { + mappings = mappings != null ? List.copyOf(mappings) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java new file mode 100644 index 00000000..80e29421 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookEntityMappingDtoIn.java @@ -0,0 +1,19 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import java.util.Map; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/// Entity projection section for an inbound webhook mapping. +public record InboundWebhookEntityMappingDtoIn( + @NotBlank(message = "Webhook entity identifier expression is mandatory") String identifier, + @NotBlank(message = "Webhook entity title expression is mandatory") String title, + @NotNull(message = "Webhook entity properties section is mandatory") Map properties, + @NotNull(message = "Webhook entity relations section is mandatory") Map relations) { + + public InboundWebhookEntityMappingDtoIn { + properties = properties != null ? Map.copyOf(properties) : null; + relations = relations != null ? Map.copyOf(relations) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java new file mode 100644 index 00000000..4acd3607 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/// Mapping rule request for inbound webhook transformation. +public record InboundWebhookMappingDtoIn( + @NotBlank(message = "Webhook mapping template is mandatory") String template, + @NotBlank(message = "Webhook mapping filter is mandatory") String filter, + @NotNull(message = "Webhook mapping entity section is mandatory") @Valid InboundWebhookEntityMappingDtoIn entity) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java new file mode 100644 index 00000000..17dc2816 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookSecurityContractDtoIn.java @@ -0,0 +1,16 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import java.util.Map; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/// Security contract request payload represented as `{ type, config }`. +public record InboundWebhookSecurityContractDtoIn( + @NotBlank(message = "Webhook security type is mandatory") String type, + @NotNull(message = "Webhook security config section is mandatory") Map config) { + + public InboundWebhookSecurityContractDtoIn { + config = config != null ? Map.copyOf(config) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/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..788c15f5 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookDtoOut.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +import java.util.List; + +/// Response payload for created inbound webhook connector. +public record InboundWebhookDtoOut(String identifier, String title, String description, + boolean enabled, List mappings, + InboundWebhookSecurityDtoOut security) { + + public InboundWebhookDtoOut { + mappings = mappings != null ? List.copyOf(mappings) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookEntityMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookEntityMappingDtoOut.java new file mode 100644 index 00000000..28e1f21b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookEntityMappingDtoOut.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +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/InboundWebhookMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookMappingDtoOut.java new file mode 100644 index 00000000..4e22bc34 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookMappingDtoOut.java @@ -0,0 +1,6 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +/// Mapping rule returned by the inbound webhook management API. +public record InboundWebhookMappingDtoOut(String template, String filter, + InboundWebhookEntityMappingDtoOut entity) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java new file mode 100644 index 00000000..a02994b1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookSecurityDtoOut.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; + +import java.util.Map; + +/// Security strategy returned for webhook configuration responses. +/// Only returns the strategy type to avoid exposing technical secret references. +public record InboundWebhookSecurityDtoOut(String type, Map config) { + + public InboundWebhookSecurityDtoOut { + config = config != null ? Map.copyOf(config) : null; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index e02c4858..99c86ba3 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 @@ -24,19 +24,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_mapping.EntityDynamicMappingConfigurationException; +import com.decathlon.idp_core.domain.exception.entity_template.*; import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; +import com.decathlon.idp_core.domain.exception.webhook.*; import lombok.AllArgsConstructor; import lombok.Getter; @@ -64,19 +56,6 @@ public class ApiExceptionHandler { private ApiExceptionHandler() { } - /// Handles domain exception when entity templates are not found. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 - /// status - /// with business-meaningful error message for API consumers. - @ExceptionHandler(EntityTemplateNotFoundException.class) - public ResponseEntity handleTemplateNotFoundException( - EntityTemplateNotFoundException ex) { - log.warn("Template not found: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); - } - /// Handles domain exception for malformed filter query strings (`q=` DSL). /// /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad @@ -309,6 +288,47 @@ public ResponseEntity handleHttpMessageNotReadableException( return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); } + /// Handles invalid dynamic mapping expressions (JSLT) provided in webhook + /// configuration. + /// + /// **HTTP mapping:** Maps domain mapping configuration failures to HTTP 400, + /// because clients can fix these expressions and retry. + @ExceptionHandler(EntityDynamicMappingConfigurationException.class) + public ResponseEntity handleEntityDynamicMappingConfigurationException( + EntityDynamicMappingConfigurationException ex) { + log.warn("Invalid entity dynamic mapping configuration: {}", ex.getMessage()); + String errorMessage = "Invalid webhook mapping configuration: " + ex.getMessage(); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + @ExceptionHandler(PropertyNameNotFoundEntityTemplatePropertiesException.class) + public ResponseEntity handlePropertyNameNotFoundEntityTemplatePropertiesException( + PropertyNameNotFoundEntityTemplatePropertiesException ex) { + log.warn("Webhook mapping references unknown property: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(RelationNameNotFoundEntityTemplateRelationsException.class) + public ResponseEntity handleRelationNameNotFoundEntityTemplateRelationsException( + RelationNameNotFoundEntityTemplateRelationsException ex) { + log.warn("Webhook mapping references unknown relation: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(WebhookTemplateHasNoPropertiesException.class) + public ResponseEntity handleWebhookTemplateHasNoPropertiesException( + WebhookTemplateHasNoPropertiesException ex) { + log.warn("Webhook mapping invalid for template without properties: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(WebhookSecurityConfigurationException.class) + public ResponseEntity handleWebhookSecurityConfigurationException( + WebhookSecurityConfigurationException ex) { + log.warn("Invalid webhook security configuration: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles domain exception when entities are not found. /// /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status @@ -449,6 +469,68 @@ 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 webhook connector identifier duplication conflicts. + @ExceptionHandler(WebhookConnectorAlreadyExistException.class) + public ResponseEntity handleWebhookConnectorAlreadyExistException( + WebhookConnectorAlreadyExistException ex) { + log.warn("Webhook connector identifier conflict: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + @ExceptionHandler(EntityTemplateInUseByWebhookMappingException.class) + public ResponseEntity handleTemplateAlreadyMappedInWebhookConfiguration( + EntityTemplateInUseByWebhookMappingException ex) { + log.warn("Entity template in use by webhook mapping conflict: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles webhook connector title duplication conflicts. + @ExceptionHandler(WebhookConnectorTitleAlreadyExistsException.class) + public ResponseEntity handleWebhookConnectorTitleAlreadyExistsException( + WebhookConnectorTitleAlreadyExistsException ex) { + log.warn("Webhook connector title conflict: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity templates are not found. + /// + /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 + /// status + /// with business-meaningful error message for API consumers. + @ExceptionHandler(EntityTemplateNotFoundException.class) + public ResponseEntity handleTemplateNotFoundException( + EntityTemplateNotFoundException ex) { + log.warn("Template not found: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + private static ResponseEntity createErrorResponse(HttpStatus httpStatus, String errorMessage) { return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java new file mode 100644 index 00000000..7e79feca --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java @@ -0,0 +1,101 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.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.InboundWebhookMappingDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookEntityMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookSecurityDtoOut; + +/// Maps inbound webhook API DTOs to domain models and back. +@Component +public class InboundWebhookMapper { + + /// Converts API input payload to the domain aggregate. + /// + /// @param dto inbound webhook creation request + /// @return domain webhook connector + public WebhookConnector toDomain(InboundWebhookCreateDtoIn dto) { + return new WebhookConnector(null, dto.identifier(), dto.title(), dto.description(), + dto.enabled(), safeMappings(dto.mappings()), 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 + /// @return domain webhook connector prepared for update + public WebhookConnector toDomainForUpdate(String identifier, InboundWebhookCreateDtoIn dto) { + var mappings = safeMappings(dto.mappings()); + var security = toDomain(dto.security()); + return new WebhookConnector(null, identifier, dto.title(), dto.description(), dto.enabled(), + mappings, security); + } + + /// Converts domain aggregate to API response payload. + /// + /// @param domain created webhook connector + /// @return response DTO + public InboundWebhookDtoOut fromWebhookConnectorToDto(WebhookConnector domain) { + var mappings = domain.mappings().stream().map(this::fromEntityMappingToDto).toList(); + var security = new InboundWebhookSecurityDtoOut(domain.security().type().name(), + domain.security().config()); + return new InboundWebhookDtoOut(domain.identifier(), domain.title(), domain.description(), + domain.enabled(), mappings, security); + } + + private InboundWebhookMappingDtoOut fromEntityMappingToDto(EntityDynamicMapping mapping) { + return new InboundWebhookMappingDtoOut(mapping.templateIdentifier(), mapping.filter(), + new InboundWebhookEntityMappingDtoOut(mapping.entityIdentifier(), mapping.entityTitle(), + Map.copyOf(mapping.properties()), Map.copyOf(mapping.relations()))); + } + + private EntityDynamicMapping toDomain(InboundWebhookMappingDtoIn mapping) { + return new EntityDynamicMapping(null, mapping.template(), mapping.filter(), + mapping.entity().identifier(), mapping.entity().title(), + safeMap(mapping.entity().properties()), safeMap(mapping.entity().relations())); + } + + private List safeMappings( + java.util.List mappings) { + if (mappings == null || mappings.isEmpty()) { + return java.util.List.of(); + } + return mappings.stream().map(this::toDomain).toList(); + } + + private WebhookSecurity toDomain(InboundWebhookSecurityContractDtoIn security) { + if (security == null) { + return new WebhookSecurity(WebhookSecurityType.NONE, Map.of()); + } + + var type = parseSecurityType(security.type()); + var config = safeMap(security.config()); + + return new WebhookSecurity(type, config); + } + + private WebhookSecurityType parseSecurityType(String typeString) { + try { + return WebhookSecurityType.valueOf(typeString.toUpperCase()); + } catch (IllegalArgumentException _) { + throw new WebhookSecurityConfigurationException("Unsupported security type: " + typeString); + } + } + + private Map safeMap(Map input) { + return input == null ? Map.of() : Map.copyOf(input); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java new file mode 100644 index 00000000..50998d07 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidator.java @@ -0,0 +1,100 @@ +package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingConfigurationException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator; +import com.schibsted.spt.data.jslt.JsltException; +import com.schibsted.spt.data.jslt.Parser; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class JsltEntityMappingValidator implements EntityDynamicMapperValidator { + + private static final Pattern LOCATION_PATTERN = Pattern + .compile("line\\s+(\\d+),\\s+column\\s+(\\d+)"); + private static final Pattern TOKEN_PATTERN = Pattern.compile("Encountered\\s+\"([^\"]+)\""); + + @Override + public void validate(EntityDynamicMapping mapping) { + List errors = new ArrayList<>(); + + checkExpression(errors, "filter", mapping.filter()); + + checkExpression(errors, "entityIdentifier", mapping.entityIdentifier()); + checkExpression(errors, "entityTitle", mapping.entityTitle()); + + if (mapping.properties() != null && !mapping.properties().isEmpty()) { + mapping.properties() + .forEach((key, expr) -> checkExpression(errors, "properties." + key, expr)); + } + if (mapping.relations() != null && !mapping.relations().isEmpty()) { + mapping.relations().forEach((key, expr) -> checkExpression(errors, "relations." + key, expr)); + } + + if (!errors.isEmpty()) { + throw new EntityDynamicMappingConfigurationException(String.format( + "Validation failed with %d errors: %s", errors.size(), String.join(" | ", errors))); + } + } + + private void checkExpression(List errors, String fieldName, String expression) { + if (!StringUtils.hasText(expression)) { + errors.add( + String.format("Field '%s' is required and must contain a JSLT expression.", fieldName)); + return; + } + + try { + new Parser(new StringReader(expression)).compile(); + } catch (JsltException exception) { + errors.add(String.format("Invalid expression for '%s': %s", fieldName, + formatJsltErrorMessage(exception.getMessage()))); + } + } + + private String formatJsltErrorMessage(String rawMessage) { + if (!StringUtils.hasText(rawMessage)) { + return "JSLT syntax error."; + } + + String normalized = rawMessage.replaceAll("\\s+", " ").trim(); + if (normalized.startsWith("Parse error:")) { + normalized = normalized.substring("Parse error:".length()).trim(); + } + + String line = null; + String column = null; + Matcher locationMatcher = LOCATION_PATTERN.matcher(rawMessage); + if (locationMatcher.find()) { + line = locationMatcher.group(1); + column = locationMatcher.group(2); + } + + String token = null; + Matcher tokenMatcher = TOKEN_PATTERN.matcher(rawMessage); + if (tokenMatcher.find()) { + token = tokenMatcher.group(1); + } + + if (line != null && column != null && token != null) { + return String.format("JSLT syntax error at line %s, column %s (unexpected token: %s).", line, + column, token); + } + if (line != null && column != null) { + return String.format("JSLT syntax error at line %s, column %s.", line, column); + } + + return normalized; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java new file mode 100644 index 00000000..9687d71c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/EntityDynamicMappingAdaptor.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter for [EntityDynamicMapping] read operations. +@Component +@RequiredArgsConstructor +public class EntityDynamicMappingAdaptor implements EntityDynamicMappingPort { + private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository; + private final EntityDynamicMappingPersistenceMapper entityDynamicMappingPersistenceMapper; + + @Override + public List findByTemplateIdentifier(String identifier) { + return jpaEntityDynamicMappingRepository.findByTemplateIdentifier(identifier).stream() + .map(entityDynamicMappingPersistenceMapper::toDomain).toList(); + } + + @Override + public Boolean existsByTemplateIdentifier(String templateIdentifier) { + return jpaEntityDynamicMappingRepository.existsByTemplateIdentifier(templateIdentifier); + } +} 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..57999e9e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java @@ -0,0 +1,133 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookTemplateMappingJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookConnectorRepository; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookTemplateMappingRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter implementing [WebhookConnectorRepositoryPort]. +/// +/// Delegates to Spring Data JPA and uses [WebhookConnectorPersistenceMapper] +/// to convert between JPA entities and domain models. +/// +/// Handles the complex persistence of mappings across three tables: +/// - webhook_connector (core connector data) +/// - entity_dynamic_mapping (mapping configurations) +/// - webhook_template_mapping (many-to-many link) +@Component +@RequiredArgsConstructor +public class PostgresWebhookConnectorAdapter implements WebhookConnectorRepositoryPort { + + private final JpaWebhookConnectorRepository jpaWebhookConnectorRepository; + private final JpaWebhookTemplateMappingRepository jpaWebhookTemplateMappingRepository; + private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository; + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final WebhookConnectorPersistenceMapper mapper; + private final EntityDynamicMappingPersistenceMapper mappingMapper; + + @Override + public Optional findByIdentifier(String identifier) { + return jpaWebhookConnectorRepository.findByIdentifier(identifier) + .map(this::loadConnectorWithMappings); + } + + @Override + public Page findAll(Pageable pageable) { + return jpaWebhookConnectorRepository.findAll(pageable).map(this::loadConnectorWithMappings); + } + + @Override + public boolean existsByIdentifier(String identifier) { + return jpaWebhookConnectorRepository.existsByIdentifier(identifier); + } + + @Override + public boolean existsByTitle(String title) { + return jpaWebhookConnectorRepository.existsByTitle(title); + } + + @Override + public WebhookConnector save(WebhookConnector connector) { + var 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( + com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity jpaEntity) { + WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity); + List mappings = loadMappingsForWebhook(jpaEntity.getId()); + + // Since WebhookConnector is a Record, create a new instance with loaded + // mappings + return new WebhookConnector(connectorWithoutMappings.id(), + connectorWithoutMappings.identifier(), connectorWithoutMappings.title(), + connectorWithoutMappings.description(), connectorWithoutMappings.enabled(), mappings, + connectorWithoutMappings.security()); + } + + /// Loads all dynamic mappings associated with a webhook connector. + private List loadMappingsForWebhook(UUID webhookId) { + return jpaWebhookTemplateMappingRepository + .findByWebhookId(webhookId).stream().map(wtm -> jpaEntityDynamicMappingRepository + .findById(wtm.getEntityMappingId()).map(mappingMapper::toDomain).orElse(null)) + .filter(Objects::nonNull).toList(); + } + + /// Persists the webhook's template mappings in the webhook_template_mapping + /// table. + /// This also persists each EntityDynamicMapping if it's new. + private void persistTemplateMappings(UUID webhookId, WebhookConnector connector) { + jpaWebhookTemplateMappingRepository.deleteByWebhookId(webhookId); + + var mappings = connector.mappings().stream() + .map(mapping -> persistAndCreateTemplateMapping(webhookId, mapping)).toList(); + + if (!mappings.isEmpty()) { + jpaWebhookTemplateMappingRepository.saveAll(mappings); + } + } + + /// Persists a single EntityDynamicMapping and creates a + /// WebhookTemplateMappingJpaEntity link. + private WebhookTemplateMappingJpaEntity persistAndCreateTemplateMapping(UUID webhookId, + EntityDynamicMapping mapping) { + var savedMapping = jpaEntityDynamicMappingRepository.save(mappingMapper.toJpa(mapping)); + + EntityTemplate entityTemplate = entityTemplateRepositoryPort + .findByIdentifier(mapping.templateIdentifier()).orElseThrow( + () -> new EntityTemplateNotFoundException("identifier", mapping.templateIdentifier())); + + return WebhookTemplateMappingJpaEntity.builder().webhookId(webhookId) + .templateId(entityTemplate.id()).entityMappingId(savedMapping.getId()) + .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..ea91933e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java @@ -0,0 +1,33 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.domain.port.WebhookTemplateMappingPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookTemplateMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookTemplateMappingRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter for webhook-template mapping read operations. +@Component +@RequiredArgsConstructor +public class WebhookTemplateMappingAdaptor implements WebhookTemplateMappingPort { + + private final JpaWebhookTemplateMappingRepository jpaWebhookTemplateMappingRepository; + private final WebhookTemplateMappingPersistenceMapper webhookTemplateMappingPersistenceMapper; + + /// Finds mappings by template technical id. + /// + /// @param templateId entity template UUID + /// @return mapped domain associations + @Override + public List findByTemplateId(UUID templateId) { + return jpaWebhookTemplateMappingRepository.findByTemplateId(templateId).stream() + .map(webhookTemplateMappingPersistenceMapper::toDomain).toList(); + } + +} 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..541c0122 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java @@ -0,0 +1,28 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import static org.mapstruct.MappingConstants.ComponentModel.SPRING; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.EntityDynamicMappingJsonbHelper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_mapping.EntityDynamicMappingJpaEntity; + +/// MapStruct persistence mapper for [EntityDynamicMapping]. +/// +/// Maps between domain model (EntityDynamicMapping) and JPA entity (EntityDynamicMappingJpaEntity). +/// Handles JSONB columns for properties and relations via the dedicated helper. +@Mapper(componentModel = SPRING, uses = EntityDynamicMappingJsonbHelper.class) +public interface EntityDynamicMappingPersistenceMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "properties", qualifiedByName = "jsonStringToMap") + @Mapping(target = "relations", qualifiedByName = "jsonStringToMap") + EntityDynamicMapping toDomain(EntityDynamicMappingJpaEntity jpa); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "properties", qualifiedByName = "mapToJsonString") + @Mapping(target = "relations", qualifiedByName = "mapToJsonString") + EntityDynamicMappingJpaEntity toJpa(EntityDynamicMapping domain); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java new file mode 100644 index 00000000..4edf7e8f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookConnectorPersistenceMapper.java @@ -0,0 +1,30 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import static org.mapstruct.MappingConstants.ComponentModel.SPRING; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common.WebhookConnectorJsonbHelper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity; + +/// MapStruct persistence mapper for [WebhookConnector]. +/// +/// Maps the connector's direct fields (identifier, title, description, enabled, security). +/// The mappings list is handled separately by +/// [com.decathlon.idp_core.infrastructure.adapters.persistence.PostgresWebhookConnectorAdapter] +/// through the `webhook_template_mapping` table because it requires dedicated persistence +/// for `entity_dynamic_mapping` rows. +@Mapper(componentModel = SPRING, uses = WebhookConnectorJsonbHelper.class) +public interface WebhookConnectorPersistenceMapper { + + @Mapping(target = "mappings", ignore = true) + @Mapping(target = "security", qualifiedByName = "jsonToSecurity") + WebhookConnector toDomain(WebhookConnectorJpaEntity jpa); + + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "security", qualifiedByName = "securityToJson") + WebhookConnectorJpaEntity toJpa(WebhookConnector domain); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookTemplateMappingPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookTemplateMappingPersistenceMapper.java new file mode 100644 index 00000000..a02f33f4 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/WebhookTemplateMappingPersistenceMapper.java @@ -0,0 +1,61 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import java.util.UUID; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; + +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookTemplateMappingJpaEntity; + +/// Persistence mapper for [WebhookTemplateMapping]. +/// +/// Maps the association entity between webhook connector, entity template and +/// dynamic mapping configuration. Foreign keys are managed explicitly by adapters +/// when persisting new links. +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = { + WebhookConnectorPersistenceMapper.class, EntityTemplatePersistenceMapper.class, + EntityDynamicMappingPersistenceMapper.class}) +public interface WebhookTemplateMappingPersistenceMapper { + + /// Maps JPA association data to the domain model. + /// + /// @param jpa persisted association entity + /// @return mapped domain model + @Mapping(target = "id", source = "id") + @Mapping(target = "webhookConnector", source = "webhookConnector") + @Mapping(target = "entityTemplate", source = "entityTemplate") + @Mapping(target = "entityDynamicMapping", source = "entityMapping") + @Mapping(target = "jsltFilter", source = "jsltFilter") + WebhookTemplateMapping toDomain(WebhookTemplateMappingJpaEntity jpa); + + /// Maps domain model to JPA association entity. + /// + /// All technical IDs are preserved from the domain model. + /// + /// @param domain domain mapping object + /// @return fully mapped JPA association entity + @Mapping(target = "id", source = "id") + @Mapping(target = "webhookId", source = "webhookConnector.id") + @Mapping(target = "templateId", source = "entityTemplate.id") + @Mapping(target = "entityMappingId", source = "entityDynamicMapping.id") + @Mapping(target = "jsltFilter", source = "jsltFilter") + @Mapping(target = "webhookConnector", ignore = true) + @Mapping(target = "entityTemplate", ignore = true) + @Mapping(target = "entityMapping", ignore = true) + WebhookTemplateMappingJpaEntity toJpa(WebhookTemplateMapping domain); + + /// Builds a link row with explicit foreign keys. + /// + /// @param webhookId webhook connector technical id + /// @param templateId target entity template technical id + /// @param entityMappingId dynamic mapping technical id + /// @param jsltFilter JSLT filter expression + /// @return link entity ready for persistence + default WebhookTemplateMappingJpaEntity toJpa(UUID webhookId, UUID templateId, + UUID entityMappingId, String jsltFilter) { + return WebhookTemplateMappingJpaEntity.builder().webhookId(webhookId).templateId(templateId) + .entityMappingId(entityMappingId).jsltFilter(jsltFilter).build(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java new file mode 100644 index 00000000..7fb0a383 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/EntityDynamicMappingJsonbHelper.java @@ -0,0 +1,51 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common; + +import java.util.Map; + +import org.mapstruct.Named; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/// Technical helper for JSONB serialization/deserialization in the persistence layer. +/// +/// Provides named conversion methods used by [com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper] +/// via MapStruct's `qualifiedByName` annotation. +/// +/// This is a pure utility class with no Spring dependencies, facilitating testability and reusability. +@Component +public class EntityDynamicMappingJsonbHelper { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /// Converts JSONB string to `Map`. + /// Used when loading from database. + @Named("jsonStringToMap") + public Map toMap(String json) { + if (json == null || json.trim().isEmpty()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid JSON mapping configuration", e); + } + } + + /// Converts `Map` to JSONB string. + /// Used when persisting to database. + @Named("mapToJsonString") + public String toJsonString(Map map) { + if (map == null || map.isEmpty()) { + return "{}"; + } + try { + return OBJECT_MAPPER.writeValueAsString(map); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to serialize mapping configuration", e); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java new file mode 100644 index 00000000..ddddd4ae --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/common/WebhookConnectorJsonbHelper.java @@ -0,0 +1,46 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.common; + +import org.mapstruct.Named; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/// Technical helper for JSONB serialization/deserialization in the persistence layer. +/// +/// Provides named conversion methods used by [WebhookConnectorPersistenceMapper] +/// via MapStruct's `qualifiedByName` annotation. +/// +/// This is a pure utility class with no Spring dependencies, facilitating testability and reusability. +@Component +public class WebhookConnectorJsonbHelper { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /// Converts JSONB string to WebhookSecurity domain model. + /// Defaults to NONE type if JSON is empty. + @Named("jsonToSecurity") + public WebhookSecurity toSecurity(String json) { + if (json == null || json.trim().isEmpty()) { + return new WebhookSecurity(WebhookSecurityType.NONE, java.util.Map.of()); + } + try { + return OBJECT_MAPPER.readValue(json, WebhookSecurity.class); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid webhook connector security JSONB", e); + } + } + + /// Converts WebhookSecurity domain model to JSON string. + @Named("securityToJson") + public String toSecurityJson(WebhookSecurity security) { + try { + return OBJECT_MAPPER.writeValueAsString(security); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to serialize webhook connector security", e); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_mapping/EntityDynamicMappingJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_mapping/EntityDynamicMappingJpaEntity.java new file mode 100644 index 00000000..ee1e6e3b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_mapping/EntityDynamicMappingJpaEntity.java @@ -0,0 +1,49 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_mapping; + +import java.util.UUID; + +import jakarta.persistence.*; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import lombok.*; + +/// JPA entity for the `entity_dynamic_mapping` table. +/// +/// Stores dynamic mapping configurations used by webhook connectors to transform +/// inbound events into entities (JSLT filters, property/relation mappings). +@Entity +@Getter +@Setter +@ToString +@EqualsAndHashCode +@Table(name = "entity_dynamic_mapping") +@NoArgsConstructor +@AllArgsConstructor +public class EntityDynamicMappingJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "template_identifier", nullable = false) + private String templateIdentifier; + + @Column(nullable = false) + private String filter; + + @Column(name = "entity_identifier", nullable = false) + private String entityIdentifier; + + @Column(name = "entity_title", nullable = false) + private String entityTitle; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private String properties; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private String relations; +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java new file mode 100644 index 00000000..80d8ce97 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookConnectorJpaEntity.java @@ -0,0 +1,67 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.*; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import lombok.*; + +/// JPA entity mapping to the `webhook_connector` PostgreSQL table. +/// +/// JSONB columns (mappings, security) are stored as raw JSON strings and deserialized +/// in [WebhookConnectorPersistenceMapper] using Jackson. +/// The webhook security payload follows the generic [SecurityContract] shape at the adapter boundary. +@Entity +@Table(name = "webhook_connector") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WebhookConnectorJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + /// Business key used in the webhook URL: POST /webhooks/{identifier} + @Column(nullable = false, unique = true, length = 255) + private String identifier; + + /// Human-readable name displayed in the management UI + @Column(nullable = false, length = 255) + private String title; + + /// Optional description of the connector purpose + @Column(columnDefinition = "TEXT") + private String description; + + /// When false, the connector rejects all inbound events without processing them + @Column(nullable = false) + private Boolean enabled; + + /// JSONB security configuration — deserialized to WebhookSecurity by the + /// mapper. + /// The "type" discriminator field drives polymorphic deserialization. + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private String security; + + /// Timestamp of connector creation + @CreatedDate + @Column(nullable = false, updatable = false) + private Instant createdAt; + + /// Timestamp of last connector update + @LastModifiedDate + @Column(nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookTemplateMappingJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookTemplateMappingJpaEntity.java new file mode 100644 index 00000000..e81178f1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/webhook/WebhookTemplateMappingJpaEntity.java @@ -0,0 +1,65 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook; + +import java.util.UUID; + +import jakarta.persistence.*; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_mapping.EntityDynamicMappingJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template.EntityTemplateJpaEntity; + +import lombok.*; + +/// JPA entity for the webhook template mapping, representing the association between a webhook connector, +/// Stores the relationship between a webhook connector, an entity template, +/// and the dynamic mapping (with JSLT filter) to apply during ingestion. +@Entity +@Table(name = "webhook_template_mapping") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WebhookTemplateMappingJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + /// Foreign key to the parent webhook connector. + /// Stored as a direct column for query performance. + /// + @Column(name = "webhook_id", nullable = false) + private UUID webhookId; + + /// Foreign key to the entity template. + /// Stored as a direct column for query performance. + @Column(name = "template_id", nullable = false) + private UUID templateId; + + /// Foreign key to the dynamic mapping configuration. + /// Stored as a direct column for query performance. + @Column(name = "entity_mapping_id", nullable = false) + private UUID entityMappingId; + + /// The JSLT filter expression applied during event ingestion. + /// Typically derived from the dynamic mapping configuration, but stored here + /// for direct access and querying. + @Column(name = "jslt_filter", columnDefinition = "TEXT") + private String jsltFilter; + + /// Lazy-loaded relationship to the webhook connector (optional, for + /// navigation). + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "webhook_id", nullable = false, insertable = false, updatable = false) + private WebhookConnectorJpaEntity webhookConnector; + + /// Lazy-loaded relationship to the entity template (optional, for navigation). + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "template_id", nullable = false, insertable = false, updatable = false) + private EntityTemplateJpaEntity entityTemplate; + + /// Lazy-loaded relationship to the dynamic mapping (optional, for navigation). + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "entity_mapping_id", nullable = false, insertable = false, updatable = false) + private EntityDynamicMappingJpaEntity entityMapping; +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java new file mode 100644 index 00000000..72872f48 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_mapping.EntityDynamicMappingJpaEntity; + +/// JPA repository for EntityDynamicMapping persistence. +/// +/// Manages the `entity_dynamic_mapping` table which stores mapping configurations +/// (JSLT filters, property/relation mappings) used by webhook template mappings. +@Repository +public interface JpaEntityDynamicMappingRepository + extends + JpaRepository { + List findByTemplateIdentifier(String templateIdentifier); + Boolean existsByTemplateIdentifier(String templateIdentifier); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java new file mode 100644 index 00000000..7309f523 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookConnectorRepository.java @@ -0,0 +1,20 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity; + +public interface JpaWebhookConnectorRepository + extends + JpaRepository { + Optional findByIdentifier(String identifier); + + boolean existsByIdentifier(String identifier); + + boolean existsByTitle(String title); + + void deleteByIdentifier(String identifier); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java new file mode 100644 index 00000000..05e5ca01 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookTemplateMappingJpaEntity; + +public interface JpaWebhookTemplateMappingRepository + extends + JpaRepository { + + /// Deletes all mappings associated with a webhook connector. + void deleteByWebhookId(UUID webhookId); + + /// Retrieves all mappings associated with a webhook connector. + List findByWebhookId(UUID webhookId); + List findByTemplateId(UUID templateId); + boolean existsByTemplateId(UUID templateId); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/BasicAuthConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/BasicAuthConfig.java new file mode 100644 index 00000000..18ccf7b8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/BasicAuthConfig.java @@ -0,0 +1,14 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.model; + +import jakarta.validation.constraints.NotBlank; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +public record BasicAuthConfig(@NotBlank String username, + @NotBlank String secretAlias) implements SecurityConfig { + + @Override + public WebhookSecurityType type() { + return WebhookSecurityType.BASIC_AUTH; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/HmacConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/HmacConfig.java new file mode 100644 index 00000000..6fb81499 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/HmacConfig.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.model; + +import jakarta.validation.constraints.NotBlank; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +public record HmacConfig(@NotBlank String headerName, @NotBlank String secretAlias, String prefix, + String encoding) implements SecurityConfig { + + public static final String DEFAULT_ENCODING = "hex"; + + public HmacConfig { + if (prefix == null) { + prefix = ""; + } + if (encoding == null || encoding.isBlank()) { + encoding = DEFAULT_ENCODING; + } + } + + @Override + public WebhookSecurityType type() { + return WebhookSecurityType.HMAC_SHA256; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/JwtBearerConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/JwtBearerConfig.java new file mode 100644 index 00000000..345fed07 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/JwtBearerConfig.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.model; + +import jakarta.validation.constraints.NotBlank; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +public record JwtBearerConfig(@NotBlank String jwksUri) implements SecurityConfig { + + @Override + public WebhookSecurityType type() { + return WebhookSecurityType.JWT_BEARER; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/SecurityConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/SecurityConfig.java new file mode 100644 index 00000000..3f01fcd1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/SecurityConfig.java @@ -0,0 +1,14 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.model; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) +@JsonSubTypes({@JsonSubTypes.Type(value = HmacConfig.class, name = "HMAC_SHA256"), + @JsonSubTypes.Type(value = StaticTokenConfig.class, name = "STATIC_TOKEN"), + @JsonSubTypes.Type(value = BasicAuthConfig.class, name = "BASIC_AUTH"), + @JsonSubTypes.Type(value = JwtBearerConfig.class, name = "JWT_BEARER")}) +public interface SecurityConfig { + WebhookSecurityType type(); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/StaticTokenConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/StaticTokenConfig.java new file mode 100644 index 00000000..cc8934df --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/model/StaticTokenConfig.java @@ -0,0 +1,14 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.model; + +import jakarta.validation.constraints.NotBlank; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +public record StaticTokenConfig(@NotBlank String headerName, + @NotBlank String secretAlias) implements SecurityConfig { + + @Override + public WebhookSecurityType type() { + return WebhookSecurityType.STATIC_TOKEN; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java new file mode 100644 index 00000000..acf03c68 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidator.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +import lombok.NoArgsConstructor; + +/// Basic Authentication security strategy for webhooks. +/// +/// Validates Basic Auth credentials at both creation time (configuration validation) +/// and runtime (request authentication). +@Component +@NoArgsConstructor +public class BasicAuthSecurityValidator implements WebhookSecurityStrategy { + + @Override + public boolean supports(String securityType) { + return "BASIC_AUTH".equalsIgnoreCase(securityType); + } + + @Override + public void validateConfiguration(Map config) { + WebhookSecurityConfigurationUtils.required(config, "username"); + String alias = WebhookSecurityConfigurationUtils.required(config, "secret_alias", + "secretAlias"); + WebhookSecurityConfigurationUtils.validateSecretAliasFormat(alias); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java new file mode 100644 index 00000000..268f5c01 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSha256SecurityValidator.java @@ -0,0 +1,32 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +import lombok.NoArgsConstructor; + +/// HMAC SHA256 security strategy for webhooks. +/// +/// Validates HMAC SHA256 signature configuration at creation time and authenticates +/// incoming webhook requests by verifying the signature against a stored secret. +@Component +@NoArgsConstructor +public class HmacSha256SecurityValidator implements WebhookSecurityStrategy { + + @Override + public boolean supports(String securityType) { + return "HMAC_SHA256".equalsIgnoreCase(securityType); + } + + @Override + public void validateConfiguration(Map config) { + WebhookSecurityConfigurationUtils.required(config, "header_name", "headerName"); + String alias = WebhookSecurityConfigurationUtils.required(config, "secret_alias", + "secretAlias"); + WebhookSecurityConfigurationUtils.validateSecretAliasFormat(alias); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java new file mode 100644 index 00000000..c7f2cfe7 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidator.java @@ -0,0 +1,33 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.nio.charset.StandardCharsets; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; + +@Component +public class HmacSignatureValidator { + + public String computeHexSha256(byte[] payload, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] digest = mac.doFinal(payload); + return toHex(digest); + } catch (Exception _) { + throw new WebhookAuthenticationException("Unable to compute HMAC signature"); + } + } + + private String toHex(byte[] input) { + StringBuilder sb = new StringBuilder(input.length * 2); + for (byte value : input) { + sb.append(String.format("%02x", value)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java new file mode 100644 index 00000000..237a2714 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidator.java @@ -0,0 +1,36 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +import lombok.NoArgsConstructor; + +/// JWT Bearer security strategy for webhooks. +/// +/// Validates JWT Bearer configuration at creation time and authenticates incoming +/// webhook requests by verifying the JWT token against a JWKS endpoint. +@Component +@NoArgsConstructor +public class JwtBearerSecurityValidator implements WebhookSecurityStrategy { + + @Override + public boolean supports(String securityType) { + return "JWT_BEARER".equalsIgnoreCase(securityType); + } + + @Override + public void validateConfiguration(Map config) { + String jwksUriValue = WebhookSecurityConfigurationUtils.required(config, "jwks_uri", "jwksUri"); + if (jwksUriValue.isBlank()) { + throw new WebhookSecurityConfigurationException("Invalid jwks_uri for JWT_BEARER security"); + } + + if (WebhookSecurityConfigurationUtils.isEnvironmentReference(jwksUriValue)) { + WebhookSecurityConfigurationUtils.validateSecretAliasFormat(jwksUriValue); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java new file mode 100644 index 00000000..f237b904 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidator.java @@ -0,0 +1,28 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +/// Static Token security strategy for webhooks. +/// +/// Validates static token configuration at creation time and authenticates incoming +@Component +public class StaticTokenSecurityValidator implements WebhookSecurityStrategy { + + @Override + public boolean supports(String securityType) { + return "STATIC_TOKEN".equalsIgnoreCase(securityType); + } + + @Override + public void validateConfiguration(Map config) { + WebhookSecurityConfigurationUtils.required(config, "header_name", "headerName"); + String alias = WebhookSecurityConfigurationUtils.required(config, "secret_alias", + "secretAlias"); + WebhookSecurityConfigurationUtils.validateSecretAliasFormat(alias); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java new file mode 100644 index 00000000..70d8086d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookJwtDecoderProvider.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.stereotype.Component; + +/// Builds and caches JwtDecoder instances keyed by jwks_uri. +@Component +public class WebhookJwtDecoderProvider { + + private final ConcurrentMap decodersByJwksUri = new ConcurrentHashMap<>(); + + public JwtDecoder get(String jwksUri) { + return decodersByJwksUri.computeIfAbsent(jwksUri, this::createDecoder); + } + + private JwtDecoder createDecoder(String jwksUri) { + var decoder = NimbusJwtDecoder.withJwkSetUri(jwksUri).build(); + decoder.setJwtValidator(JwtValidators.createDefault()); + return decoder; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java new file mode 100644 index 00000000..81b39cc1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/WebhookSecurityConfigurationUtils.java @@ -0,0 +1,122 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.StringUtils; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; + +/// Common utilities for webhook security validation. +/// +/// Provides shared methods for extracting and validating configuration keys across all security strategies. +/// This eliminates duplication between creation-time and runtime validation logic. +public final class WebhookSecurityConfigurationUtils { + + private static final Pattern BRACED_ENV_REFERENCE = Pattern.compile("^\\$\\{([A-Z0-9_]+)}$"); + private static final Pattern ENV_ALIAS = Pattern.compile("^[A-Z0-9_]+$"); + + private WebhookSecurityConfigurationUtils() { + } + + /// Retrieves a required configuration value, checking multiple key variants + /// (snake_case and camelCase). + /// + /// @param config the configuration map + /// @param keys the keys to check in order (e.g., "secret_alias", "secretAlias") + /// @return the first non-blank value found + /// @throws WebhookSecurityConfigurationException if no value is found (at + /// creation time) + /// @throws WebhookAuthenticationException if no value is found (at runtime) + public static String required(Map config, String... keys) { + return required(config, false, keys); + } + + /// Retrieves an optional configuration value, returning a default if not found. + /// + /// @param config the configuration map + /// @param key the key to look up + /// @param defaultValue the value to return if key is not found + /// @return the configuration value or the default + public static String optional(Map config, String key, String defaultValue) { + String value = config.get(key); + return value == null ? defaultValue : value; + } + + /// Validates that a secret alias follows the UPPER_SNAKE_CASE convention. + /// + /// @param alias the alias to validate + /// @throws WebhookSecurityConfigurationException if the alias format is invalid + /// (at creation time) + public static void validateSecretAliasFormat(String alias) { + String normalizedAlias = normalizeEnvironmentAlias(alias); + if (!ENV_ALIAS.matcher(normalizedAlias).matches()) { + throw new WebhookSecurityConfigurationException( + "Invalid 'secret_alias'. Use UPPER_SNAKE_CASE or an environment reference (${MY_SECRET} or env:MY_SECRET)"); + } + } + + /// Determines whether a configuration value references an environment variable. + /// + /// Supported formats: `${MY_VAR}` and `env:MY_VAR`. + /// + /// @param value configuration value + /// @return true when value is an environment variable reference + public static boolean isEnvironmentReference(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + String trimmed = value.trim(); + return trimmed.startsWith("env:") || BRACED_ENV_REFERENCE.matcher(trimmed).matches(); + } + + private static String normalizeEnvironmentAlias(String aliasOrReference) { + if (!StringUtils.hasText(aliasOrReference)) { + return aliasOrReference; + } + + String trimmed = aliasOrReference.trim(); + if (trimmed.startsWith("env:")) { + return trimmed.substring("env:".length()).trim(); + } + + Matcher matcher = BRACED_ENV_REFERENCE.matcher(trimmed); + if (matcher.matches()) { + return matcher.group(1); + } + + return trimmed; + } + + private static String required(Map config, boolean isRuntime, String... keys) { + for (String key : keys) { + String value = config.get(key); + if (StringUtils.hasText(value)) { + return value; + } + + // Priority 2: Environment suffix support (_env or Env) + // Example: if key is "secret_alias", we also check for "secret_alias_env" or + // "secret_aliasEnv" + String envValue = config.get(key + "_env"); + if (!StringUtils.hasText(envValue)) { + envValue = config.get(key + "Env"); + } + + if (StringUtils.hasText(envValue)) { + return "env:" + envValue; + } + } + + String keysStr = String.join(", ", keys); + if (isRuntime) { + throw new WebhookAuthenticationException( + "Missing security config key. Expected one of: " + keysStr); + } else { + throw new WebhookSecurityConfigurationException( + "Missing required security config key. Expected one of: " + keysStr); + } + } +} diff --git a/src/main/resources/db/migration/V4_1__create_webhook_connector_table.sql b/src/main/resources/db/migration/V4_1__create_webhook_connector_table.sql new file mode 100644 index 00000000..f6a62b12 --- /dev/null +++ b/src/main/resources/db/migration/V4_1__create_webhook_connector_table.sql @@ -0,0 +1,18 @@ +-- Purpose: introduce webhook connector and relational mapping model to entity template and dynamic mapping for flexible webhook configuration and management +CREATE TABLE webhook_connector +( + id UUID PRIMARY KEY, + identifier VARCHAR(255) NOT NULL, + title VARCHAR(255) UNIQUE NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + security JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +--create index for better performance +CREATE UNIQUE INDEX idx_webhook_connector_identifier ON webhook_connector (identifier); + +--create index for better performance on security field which is JSONB type, using GIN index for efficient querying +CREATE INDEX idx_webhook_security_type ON webhook_connector USING GIN (security); diff --git a/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql b/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql new file mode 100644 index 00000000..f57a8014 --- /dev/null +++ b/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql @@ -0,0 +1,12 @@ +-- This migration creates the 'entity_dynamic_mapping' table to store dynamic mappings of entities based on templates and filters. + +CREATE TABLE entity_dynamic_mapping +( + id UUID PRIMARY KEY, + template_identifier VARCHAR(255) NOT NULL, + filter VARCHAR(255) NOT NULL, + entity_identifier VARCHAR(255) NOT NULL, + entity_title VARCHAR(255) NOT NULL, + properties JSONB NOT NULL DEFAULT '{}'::jsonb, + relations JSONB NOT NULL DEFAULT '{}'::jsonb +); diff --git a/src/main/resources/db/migration/V4_3__create_webhook_template_mapping_table.sql b/src/main/resources/db/migration/V4_3__create_webhook_template_mapping_table.sql new file mode 100644 index 00000000..e76f7128 --- /dev/null +++ b/src/main/resources/db/migration/V4_3__create_webhook_template_mapping_table.sql @@ -0,0 +1,20 @@ +--- Create the webhook_template_mapping table to link webhook connectors with entity templates and dynamic mappings. +CREATE TABLE webhook_template_mapping +( + id UUID PRIMARY KEY, + webhook_id UUID NOT NULL, + template_id UUID NOT NULL, + entity_mapping_id UUID NOT NULL, + jslt_filter TEXT, + + CONSTRAINT fk_webhook_connector FOREIGN KEY (webhook_id) REFERENCES webhook_connector (id) ON DELETE CASCADE, + CONSTRAINT fk_entity_template FOREIGN KEY (template_id) REFERENCES entity_template (id) ON DELETE RESTRICT, + CONSTRAINT fk_entity_mapping FOREIGN KEY (entity_mapping_id) REFERENCES entity_dynamic_mapping (id) ON DELETE CASCADE + +); + +CREATE INDEX idx_webhook_template_mapping_webhook ON webhook_template_mapping (webhook_id); + +CREATE INDEX idx_webhook_template_mapping_template ON webhook_template_mapping (template_id); + +CREATE INDEX idx_webhook_template_mapping_entity_mapping ON webhook_template_mapping (entity_mapping_id); diff --git a/src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java b/src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java new file mode 100644 index 00000000..b07f499c --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/model/inbound_connectors/webhook/WebhookConnectorTest.java @@ -0,0 +1,40 @@ +package com.decathlon.idp_core.domain.model.inbound_connectors.webhook; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; + +/** + * Unit tests for {@link WebhookConnector} invariants. + */ +@DisplayName("WebhookConnector Tests") +class WebhookConnectorTest { + + @Test + @DisplayName("Should default mappings to empty list and disable connector when mappings are null") + void shouldDefaultMappingsToEmptyListAndDisableWhenMappingsAreNull() { + WebhookConnector connector = new WebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", true, null, new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + + assertThat(connector.mappings()).isEmpty(); + assertThat(connector.enabled()).isFalse(); + } + + @Test + @DisplayName("Should disable connector when mappings are empty") + void shouldDisableWhenMappingsAreEmpty() { + WebhookConnector connector = new WebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", true, List.of(), + new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + + assertThat(connector.mappings()).isEmpty(); + assertThat(connector.enabled()).isFalse(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java new file mode 100644 index 00000000..ce40a70e --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java @@ -0,0 +1,478 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookTemplateHasNoPropertiesException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.port.EntityDynamicMapperValidator; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + +/** + * Unit tests for {@link EntityDynamicMappingValidationService}. + */ +@DisplayName("EntityDynamicMappingValidationService Tests") +@ExtendWith(MockitoExtension.class) +class EntityDynamicMappingValidationServiceTest { + + @Mock + private EntityTemplateService entityTemplateService; + + @Mock + private EntityDynamicMapperValidator entityDynamicMapperValidator; + + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + + private EntityDynamicMappingValidationService service; + + @BeforeEach + void setUp() { + service = new EntityDynamicMappingValidationService(entityTemplateService, + entityDynamicMapperValidator, entityTemplateValidationService); + } + + private EntityDynamicMapping buildMapping(String templateIdentifier, + Map properties, Map relations) { + return new EntityDynamicMapping(null, templateIdentifier, ".eventType == \"DEPLOYED\"", ".id", + ".name", properties, relations); + } + + private EntityTemplate buildEntityTemplate(List properties, + List relations) { + return new EntityTemplate(UUID.randomUUID(), "deployment", "Deployment", + "A deployment template", properties, relations); + } + + private PropertyDefinition buildProperty(String name, boolean required) { + return new PropertyDefinition(UUID.randomUUID(), name, name + " description", + PropertyType.STRING, required, null); + } + + private RelationDefinition buildRelation(String name, boolean required) { + return new RelationDefinition(UUID.randomUUID(), name, "service", required, false); + } + + @Nested + @DisplayName("validateWebhookMapping - happy paths") + class ValidateWebhookMappingHappyPathTests { + + @Test + @DisplayName("Should pass with valid mapping having matching properties") + void shouldPassWithValidMappingMatchingProperties() { + PropertyDefinition property = buildProperty("environment", false); + EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of("environment", ".env"), + Map.of()); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate( + Map.of("environment", ".env"), template.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of(), template); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should pass with empty properties mapping and empty template properties") + void shouldPassWithEmptyPropertiesAndEmptyTemplateProperties() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of(), template); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should pass with null relations (no relations in mapping)") + void shouldPassWithNullRelations() { + PropertyDefinition property = buildProperty("env", false); + EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of("env", ".env"), null); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate( + Map.of("env", ".env"), template.propertiesDefinitions()); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + } + + @Test + @DisplayName("Should pass with empty relations in mapping and no required relations in template") + void shouldPassWithEmptyRelationsAndNoRequiredRelations() { + RelationDefinition relation = buildRelation("service", false); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of(), template); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + } + + @Test + @DisplayName("Should validate each mapping in the list") + void shouldValidateEachMappingInList() { + PropertyDefinition property1 = buildProperty("env", false); + EntityTemplate template1 = buildEntityTemplate(List.of(property1), List.of()); + EntityDynamicMapping mapping1 = buildMapping("deployment", Map.of("env", ".env"), Map.of()); + + PropertyDefinition property2 = buildProperty("version", false); + EntityTemplate template2 = buildEntityTemplate(List.of(property2), List.of()); + EntityDynamicMapping mapping2 = new EntityDynamicMapping(null, "service", + ".type == \"SERVICE\"", ".id", ".name", Map.of("version", ".ver"), Map.of()); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + doNothing().when(entityTemplateValidationService).validateTemplateExists("service"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template1); + when(entityTemplateService.getEntityTemplateByIdentifier("service")).thenReturn(template2); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate( + Map.of("env", ".env"), template1.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate( + Map.of("version", ".ver"), template2.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of(), template1); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of(), template2); + + assertThatNoException() + .isThrownBy(() -> service.validateWebhookMapping(List.of(mapping1, mapping2))); + + verify(entityDynamicMapperValidator).validate(mapping1); + verify(entityDynamicMapperValidator).validate(mapping2); + } + + @Test + @DisplayName("Should pass when mapping has valid relations matching template") + void shouldPassWithValidRelations() { + RelationDefinition relation = buildRelation("owner", true); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), + Map.of("owner", ".owner")); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of("owner", ".owner"), template); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - template existence") + class ValidateTemplateExistenceTests { + + @Test + @DisplayName("Should throw EntityTemplateNotFoundException when template does not exist") + void shouldThrowWhenTemplateDoesNotExist() { + EntityDynamicMapping mapping = buildMapping("unknown-template", Map.of(), Map.of()); + List mappings = List.of(mapping); + + doThrow(new EntityTemplateNotFoundException("identifier", "unknown-template")) + .when(entityTemplateValidationService).validateTemplateExists("unknown-template"); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(EntityTemplateNotFoundException.class); + + verify(entityTemplateService, never()).getEntityTemplateByIdentifier("unknown-template"); + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - properties validation") + class ValidatePropertiesTests { + + @Test + @DisplayName("Should throw WebhookTemplateHasNoPropertiesException when mapping has properties but template has none") + void shouldThrowWhenMappingHasPropertiesButTemplateHasNone() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of("env", ".env"), Map.of()); + var mappings = List.of(mapping); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new WebhookTemplateHasNoPropertiesException( + "The mapping defines properties but the target template has no property definitions")) + .when(entityTemplateValidationService) + .validatePropertiesExistInTemplate(Map.of("env", ".env"), List.of()); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(WebhookTemplateHasNoPropertiesException.class) + .hasMessageContaining("no property definitions"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should throw PropertyNameNotFoundEntityTemplatePropertiesException when property not in template") + void shouldThrowWhenPropertyNotFoundInTemplate() { + PropertyDefinition property = buildProperty("environment", false); + EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of("unknown-prop", ".x"), + Map.of()); + var mappings = List.of(mapping); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doThrow(new PropertyNameNotFoundEntityTemplatePropertiesException( + "Property name unknown-prop not found in entity template properties")) + .when(entityTemplateValidationService).validatePropertiesExistInTemplate( + Map.of("unknown-prop", ".x"), template.propertiesDefinitions()); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(PropertyNameNotFoundEntityTemplatePropertiesException.class) + .hasMessageContaining("unknown-prop"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should throw WebhookTemplateHasNoPropertiesException when required property is missing from mapping") + void shouldThrowWhenRequiredPropertyMissingFromMapping() { + PropertyDefinition requiredProp = buildProperty("env", true); + EntityTemplate template = buildEntityTemplate(List.of(requiredProp), List.of()); + // mapping does not include the required property "env" + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + var mappings = List.of(mapping); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(WebhookTemplateHasNoPropertiesException.class).hasMessageContaining("env"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should pass when all required properties are mapped") + void shouldPassWhenAllRequiredPropertiesMapped() { + PropertyDefinition requiredProp = buildProperty("env", true); + EntityTemplate template = buildEntityTemplate(List.of(requiredProp), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of("env", ".environment"), + Map.of()); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate( + Map.of("env", ".environment"), template.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of(), template); + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should throw when multiple required properties are missing from mapping") + void shouldThrowWhenMultipleRequiredPropertiesMissing() { + PropertyDefinition prop1 = buildProperty("env", true); + PropertyDefinition prop2 = buildProperty("version", true); + EntityTemplate template = buildEntityTemplate(List.of(prop1, prop2), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + var mappings = List.of(mapping); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(WebhookTemplateHasNoPropertiesException.class).hasMessageContaining("env") + .hasMessageContaining("version"); + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - relations validation") + class ValidateRelationsTests { + + @Test + @DisplayName("Should throw RelationNameNotFoundEntityTemplateRelationsException when relation not in template") + void shouldThrowWhenRelationNotFoundInTemplate() { + RelationDefinition relation = buildRelation("owner", false); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), + Map.of("unknown-relation", ".x")); + var mappings = List.of(mapping); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + doThrow(new RelationNameNotFoundEntityTemplateRelationsException( + "Relation name unknown-relation not found in entity template relations")) + .when(entityTemplateValidationService).validateRelationNameAlreadyExistInTemplate( + Map.of("unknown-relation", ".x"), template); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(RelationNameNotFoundEntityTemplateRelationsException.class) + .hasMessageContaining("unknown-relation"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should throw WebhookTemplateHasNoPropertiesException when required relation is missing from mapping") + void shouldThrowWhenRequiredRelationMissingFromMapping() { + RelationDefinition requiredRelation = buildRelation("owner", true); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(requiredRelation)); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + var mappings = List.of(mapping); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of(), template); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(WebhookTemplateHasNoPropertiesException.class) + .hasMessageContaining("owner"); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + + @Test + @DisplayName("Should pass when required relation is mapped") + void shouldPassWhenRequiredRelationMapped() { + RelationDefinition requiredRelation = buildRelation("owner", true); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(requiredRelation)); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), + Map.of("owner", ".ownerId")); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of("owner", ".ownerId"), template); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should throw when multiple required relations are missing from mapping") + void shouldThrowWhenMultipleRequiredRelationsMissing() { + RelationDefinition rel1 = buildRelation("owner", true); + RelationDefinition rel2 = buildRelation("team", true); + EntityTemplate template = buildEntityTemplate(List.of(), List.of(rel1, rel2)); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + var mappings = List.of(mapping); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of(), template); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(WebhookTemplateHasNoPropertiesException.class).hasMessageContaining("owner") + .hasMessageContaining("team"); + } + + @Test + @DisplayName("Should skip relation validation when relations map is null") + void shouldSkipRelationValidationWhenNull() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), null); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + + assertThatNoException().isThrownBy(() -> service.validateWebhookMapping(List.of(mapping))); + + verify(entityDynamicMapperValidator).validate(mapping); + } + } + + @Nested + @DisplayName("validateWebhookMapping - mapper validator delegation") + class MapperValidatorDelegationTests { + + @Test + @DisplayName("Should delegate to entityDynamicMapperValidator after all domain checks pass") + void shouldDelegateToMapperValidator() { + EntityTemplate template = buildEntityTemplate(List.of(), List.of()); + EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + + doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); + when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); + doNothing().when(entityTemplateValidationService).validatePropertiesExistInTemplate(Map.of(), + template.propertiesDefinitions()); + doNothing().when(entityTemplateValidationService) + .validateRelationNameAlreadyExistInTemplate(Map.of(), template); + + service.validateWebhookMapping(List.of(mapping)); + + verify(entityDynamicMapperValidator).validate(mapping); + } + + @Test + @DisplayName("Should NOT call entityDynamicMapperValidator when domain check throws") + void shouldNotCallMapperValidatorWhenDomainCheckFails() { + EntityDynamicMapping mapping = buildMapping("bad-template", Map.of(), Map.of()); + var mappings = List.of(mapping); + + doThrow(new EntityTemplateNotFoundException("identifier", "bad-template")) + .when(entityTemplateValidationService).validateTemplateExists("bad-template"); + + assertThatThrownBy(() -> service.validateWebhookMapping(mappings)) + .isInstanceOf(EntityTemplateNotFoundException.class); + + verify(entityDynamicMapperValidator, never()).validate(mapping); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java new file mode 100644 index 00000000..cfe724f2 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java @@ -0,0 +1,289 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; + +@DisplayName("WebhookConnectorService Tests") +@ExtendWith(MockitoExtension.class) +class WebhookConnectorServiceTest { + + @Mock + private WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + + @Mock + private WebhookConnectorValidationService webhookConnectorValidationService; + + private WebhookConnectorService service; + + @BeforeEach + void setUp() { + service = new WebhookConnectorService(webhookConnectorRepositoryPort, + webhookConnectorValidationService); + } + + @Nested + @DisplayName("getWebhookConnector") + class GetWebhookConnectorTests { + + @Test + @DisplayName("Should return connector when it exists") + void shouldReturnConnectorWhenExists() { + WebhookConnector existing = buildWebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", true); + when(webhookConnectorRepositoryPort.findByIdentifier("github-dora")) + .thenReturn(Optional.of(existing)); + + WebhookConnector result = service.getWebhookConnector("github-dora"); + + assertThat(result).isEqualTo(existing); + } + + @Test + @DisplayName("Should throw WebhookConnectorNotFoundException when not found") + void shouldThrowWhenConnectorNotFound() { + when(webhookConnectorRepositoryPort.findByIdentifier("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getWebhookConnector("unknown")) + .isInstanceOf(WebhookConnectorNotFoundException.class).hasMessageContaining("unknown"); + } + } + + @Nested + @DisplayName("createWebhookConnector") + class CreateWebhookConnectorTests { + + @Test + @DisplayName("Should validate then save and return the connector") + void shouldValidateAndSave() { + WebhookConnector toCreate = buildWebhookConnector(null, "github-dora", "GitHub DORA", "desc", + true); + WebhookConnector saved = buildWebhookConnector(UUID.randomUUID(), "github-dora", + "GitHub DORA", "desc", true); + when(webhookConnectorRepositoryPort.save(any())).thenReturn(saved); + + WebhookConnector result = service.createWebhookConnector(toCreate); + + verify(webhookConnectorValidationService).validateWebhookConnectorForCreation(toCreate); + verify(webhookConnectorRepositoryPort).save(toCreate); + assertThat(result.id()).isNotNull(); + assertThat(result.identifier()).isEqualTo("github-dora"); + } + + @Test + @DisplayName("Should NOT save when validation throws") + void shouldNotSaveWhenValidationFails() { + WebhookConnector toCreate = buildWebhookConnector(null, "github-dora", "GitHub DORA", "desc", + true); + org.mockito.Mockito.doThrow(new RuntimeException("validation error")) + .when(webhookConnectorValidationService).validateWebhookConnectorForCreation(toCreate); + + assertThatThrownBy(() -> service.createWebhookConnector(toCreate)) + .hasMessageContaining("validation error"); + + verify(webhookConnectorRepositoryPort, never()).save(any()); + } + } + + @Nested + @DisplayName("updateWebhookConnector") + class UpdateWebhookConnectorTests { + + private static final UUID EXISTING_ID = UUID.randomUUID(); + private static final String IDENTIFIER = "github-dora"; + + @Test + @DisplayName("Should preserve id and identifier from the stored connector") + void shouldPreserveIdAndIdentifier() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old title", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, "ignored-from-body", "New title", + "New desc", false); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + WebhookConnector result = service.updateWebhookConnector(IDENTIFIER, incoming); + + assertThat(result.id()).isEqualTo(EXISTING_ID); + assertThat(result.identifier()).isEqualTo(IDENTIFIER); + } + + @Test + @DisplayName("Should apply updated fields from the incoming connector") + void shouldApplyIncomingFields() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old title", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New title", "New desc", + false); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + WebhookConnector result = service.updateWebhookConnector(IDENTIFIER, incoming); + + assertThat(result.title()).isEqualTo("New title"); + assertThat(result.description()).isEqualTo("New desc"); + assertThat(result.enabled()).isFalse(); + } + + @Test + @DisplayName("Should delegate validation before saving") + void shouldDelegateValidationBeforeSave() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old title", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New title", "New desc", + false); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateWebhookConnector(IDENTIFIER, incoming); + + InOrder order = org.mockito.Mockito.inOrder(webhookConnectorValidationService, + webhookConnectorRepositoryPort); + order.verify(webhookConnectorValidationService).validateWebhookConnectorForUpdate(existing, + incoming); + order.verify(webhookConnectorRepositoryPort).save(any()); + } + + @Test + @DisplayName("Should save the merged connector with correct fields") + void shouldSaveMergedConnector() { + WebhookConnector existing = buildWebhookConnector(EXISTING_ID, IDENTIFIER, "Old title", + "Old desc", true); + WebhookConnector incoming = buildWebhookConnector(null, IDENTIFIER, "New title", "New desc", + false); + + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.of(existing)); + when(webhookConnectorRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateWebhookConnector(IDENTIFIER, incoming); + + var captor = ArgumentCaptor.forClass(WebhookConnector.class); + verify(webhookConnectorRepositoryPort).save(captor.capture()); + var saved = captor.getValue(); + + assertThat(saved.id()).isEqualTo(EXISTING_ID); + assertThat(saved.identifier()).isEqualTo(IDENTIFIER); + assertThat(saved.title()).isEqualTo("New title"); + assertThat(saved.description()).isEqualTo("New desc"); + assertThat(saved.enabled()).isFalse(); + } + + @Test + @DisplayName("Should throw WebhookConnectorNotFoundException when connector is missing") + void shouldThrowWhenConnectorMissing() { + var incoming = buildWebhookConnector(null, IDENTIFIER, "New title", "New desc", true); + when(webhookConnectorRepositoryPort.findByIdentifier(IDENTIFIER)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateWebhookConnector(IDENTIFIER, incoming)) + .isInstanceOf(WebhookConnectorNotFoundException.class).hasMessageContaining(IDENTIFIER); + + verify(webhookConnectorValidationService, never()).validateWebhookConnectorForUpdate(any(), + any()); + verify(webhookConnectorRepositoryPort, never()).save(any()); + } + } + + @Nested + @DisplayName("deleteWebhookConnector") + class DeleteWebhookConnectorTests { + + @Test + @DisplayName("Should validate existence then delete") + void shouldValidateAndDelete() { + service.deleteWebhookConnector("github-dora"); + + var order = org.mockito.Mockito.inOrder(webhookConnectorValidationService, + webhookConnectorRepositoryPort); + order.verify(webhookConnectorValidationService).validateIdentifierExists("github-dora"); + order.verify(webhookConnectorRepositoryPort).deleteByIdentifier("github-dora"); + } + + @Test + @DisplayName("Should NOT delete when validation throws") + void shouldNotDeleteWhenValidationFails() { + org.mockito.Mockito.doThrow(new WebhookConnectorNotFoundException("github-dora not found")) + .when(webhookConnectorValidationService).validateIdentifierExists("github-dora"); + + assertThatThrownBy(() -> service.deleteWebhookConnector("github-dora")) + .isInstanceOf(WebhookConnectorNotFoundException.class); + + verify(webhookConnectorRepositoryPort, never()).deleteByIdentifier(any()); + } + } + + @Nested + @DisplayName("getAllWebhookConnector") + class GetAllWebhookConnectorTests { + + @Test + @DisplayName("Should return paginated connectors from repository") + void shouldReturnPaginatedConnectors() { + PageRequest pageable = PageRequest.of(0, 10); + WebhookConnector c1 = buildWebhookConnector(UUID.randomUUID(), "connector-a", "A", "desc", + true); + WebhookConnector c2 = buildWebhookConnector(UUID.randomUUID(), "connector-b", "B", "desc", + false); + var page = new PageImpl<>(List.of(c1, c2), pageable, 2); + when(webhookConnectorRepositoryPort.findAll(pageable)).thenReturn(page); + + Page result = service.getAllWebhookConnector(pageable); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @Test + @DisplayName("Should return empty page when no connectors exist") + void shouldReturnEmptyPage() { + Pageable pageable = PageRequest.of(0, 10); + when(webhookConnectorRepositoryPort.findAll(pageable)) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + + Page result = service.getAllWebhookConnector(pageable); + + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + } + } + + private WebhookConnector buildWebhookConnector(UUID id, String identifier, String title, + String description, boolean enabled) { + WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, + Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")); + return new WebhookConnector(id, identifier, title, description, enabled, List.of(), security); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java new file mode 100644 index 00000000..654d67b8 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java @@ -0,0 +1,196 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorAlreadyExistException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorTitleAlreadyExistsException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService; + +@DisplayName("WebhookConnectorValidationService Tests") +@ExtendWith(MockitoExtension.class) +class WebhookConnectorValidationServiceTest { + + @Mock + private WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + + @Mock + private WebhookSecurityValidationService webhookSecurityValidationService; + + @Mock + private EntityDynamicMappingValidationService webhookConnectorMappingValidationService; + + private WebhookConnectorValidationService service; + + @BeforeEach + void setUp() { + service = new WebhookConnectorValidationService(webhookConnectorRepositoryPort, + webhookConnectorMappingValidationService, webhookSecurityValidationService); + } + + @Nested + @DisplayName("validateWebhookConnectorForCreation") + class ValidateWebhookConnectorForCreationTests { + + @Test + @DisplayName("Should validate uniqueness and security for creation") + void shouldValidateAllChecksForCreation() { + WebhookConnector connector = buildWebhookConnector("github-dora", "GitHub DORA"); + when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(false); + when(webhookConnectorRepositoryPort.existsByTitle("GitHub DORA")).thenReturn(false); + + service.validateWebhookConnectorForCreation(connector); + + var order = inOrder(webhookConnectorRepositoryPort, webhookSecurityValidationService); + order.verify(webhookConnectorRepositoryPort).existsByIdentifier("github-dora"); + order.verify(webhookConnectorRepositoryPort).existsByTitle("GitHub DORA"); + order.verify(webhookSecurityValidationService).validateForCreation(connector.security()); + } + + @Test + @DisplayName("Should throw when identifier already exists and stop validation chain") + void shouldThrowWhenIdentifierAlreadyExists() { + WebhookConnector connector = buildWebhookConnector("github-dora", "GitHub DORA"); + when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(true); + + assertThatThrownBy(() -> service.validateWebhookConnectorForCreation(connector)) + .isInstanceOf(WebhookConnectorAlreadyExistException.class) + .hasMessageContaining("github-dora"); + + verify(webhookConnectorRepositoryPort, never()).existsByTitle(any()); + verify(webhookSecurityValidationService, never()).validateForCreation(any()); + } + + @Test + @DisplayName("Should throw when title already exists and skip security validation") + void shouldThrowWhenTitleAlreadyExists() { + WebhookConnector connector = buildWebhookConnector("github-dora", "GitHub DORA"); + when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(false); + when(webhookConnectorRepositoryPort.existsByTitle("GitHub DORA")).thenReturn(true); + + assertThatThrownBy(() -> service.validateWebhookConnectorForCreation(connector)) + .isInstanceOf(WebhookConnectorTitleAlreadyExistsException.class) + .hasMessageContaining("GitHub DORA"); + + verify(webhookSecurityValidationService, never()).validateForCreation(any()); + } + } + + @Nested + @DisplayName("validateWebhookConnectorForUpdate") + class ValidateWebhookConnectorForUpdateTests { + + @Test + @DisplayName("Should validate title uniqueness when title changes") + void shouldValidateTitleUniquenessWhenTitleChanges() { + WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Old title"); + WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "New title"); + when(webhookConnectorRepositoryPort.existsByTitle("New title")).thenReturn(false); + + service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate); + + verify(webhookConnectorRepositoryPort).existsByTitle("New title"); + verify(webhookSecurityValidationService).validateForCreation(connectorToUpdate.security()); + } + + @Test + @DisplayName("Should skip title uniqueness check when title is unchanged") + void shouldSkipTitleUniquenessWhenTitleIsUnchanged() { + WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Same title"); + WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Same title"); + + service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate); + + verify(webhookConnectorRepositoryPort, never()).existsByTitle(any()); + verify(webhookSecurityValidationService).validateForCreation(connectorToUpdate.security()); + } + + @Test + @DisplayName("Should throw when changed title already exists and stop validation chain") + void shouldThrowWhenChangedTitleAlreadyExists() { + WebhookConnector existingConnector = buildWebhookConnector("github-dora", "Old title"); + WebhookConnector connectorToUpdate = buildWebhookConnector("github-dora", "Taken title"); + when(webhookConnectorRepositoryPort.existsByTitle("Taken title")).thenReturn(true); + + assertThatThrownBy( + () -> service.validateWebhookConnectorForUpdate(existingConnector, connectorToUpdate)) + .isInstanceOf(WebhookConnectorTitleAlreadyExistsException.class) + .hasMessageContaining("Taken title"); + + verify(webhookSecurityValidationService, never()).validateForCreation(any()); + } + } + + @Nested + @DisplayName("validateTitleUniqueness") + class ValidateTitleUniquenessTests { + + @Test + @DisplayName("Should pass when title is unique") + void shouldPassWhenTitleIsUnique() { + when(webhookConnectorRepositoryPort.existsByTitle("GitHub DORA")).thenReturn(false); + + assertThatCode(() -> service.validateTitleUniqueness("GitHub DORA")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw when title already exists") + void shouldThrowWhenTitleAlreadyExists() { + when(webhookConnectorRepositoryPort.existsByTitle("GitHub DORA")).thenReturn(true); + + assertThatThrownBy(() -> service.validateTitleUniqueness("GitHub DORA")) + .isInstanceOf(WebhookConnectorTitleAlreadyExistsException.class) + .hasMessageContaining("GitHub DORA"); + } + } + + @Nested + @DisplayName("validateIdentifierExists") + class ValidateIdentifierExistsTests { + + @Test + @DisplayName("Should pass when identifier exists") + void shouldPassWhenIdentifierExists() { + when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(true); + + assertThatCode(() -> service.validateIdentifierExists("github-dora")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw when identifier does not exist") + void shouldThrowWhenIdentifierDoesNotExist() { + when(webhookConnectorRepositoryPort.existsByIdentifier("github-dora")).thenReturn(false); + + assertThatThrownBy(() -> service.validateIdentifierExists("github-dora")) + .isInstanceOf(WebhookConnectorNotFoundException.class) + .hasMessageContaining("github-dora"); + } + } + + private WebhookConnector buildWebhookConnector(String identifier, String title) { + WebhookSecurity security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, + Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")); + return new WebhookConnector(UUID.randomUUID(), identifier, title, "desc", true, List.of(), + security); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java new file mode 100644 index 00000000..a1357ae7 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/security/WebhookSecurityValidationServiceTest.java @@ -0,0 +1,124 @@ +package com.decathlon.idp_core.domain.service.webhook.security; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.WebhookSecurityStrategy; + +@DisplayName("WebhookSecurityValidationService Tests") +@ExtendWith(MockitoExtension.class) +class WebhookSecurityValidationServiceTest { + + @Mock + private WebhookSecurityStrategy hmacCreationValidator; + + private WebhookSecurityValidationService service; + + @BeforeEach + void setUp() { + lenient().when(hmacCreationValidator.supports("HMAC_SHA256")).thenReturn(true); + service = new WebhookSecurityValidationService(List.of(hmacCreationValidator)); + } + + @Nested + @DisplayName("validateForCreation — null/blank guards") + class NullBlankGuards { + + @Test + @DisplayName("Should throw when security is null") + void shouldThrowWhenSecurityIsNull() { + assertThatThrownBy(() -> service.validateForCreation(null)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("mandatory"); + } + + @Test + @DisplayName("Should throw when security type is null") + void shouldThrowWhenTypeIsNull() { + Map config = Map.of("k", "v"); + assertThatThrownBy(() -> new WebhookSecurity(null, config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("type is mandatory"); + } + + @Test + @DisplayName("Should throw when config is null") + void shouldThrowWhenConfigIsNull() { + assertThatThrownBy(() -> new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, null)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("config section is mandatory"); + } + } + + @Nested + @DisplayName("validateForCreation — known type delegation") + class KnownTypeDelegation { + + @Test + @DisplayName("Should delegate to the matching creation validator for HMAC_SHA256") + void shouldDelegateToHmacValidator() { + var config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET"); + var security = new WebhookSecurity(WebhookSecurityType.HMAC_SHA256, config); + + assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException(); + + verify(hmacCreationValidator).validateConfiguration(config); + } + } + + @Nested + @DisplayName("validateForCreation — NONE type") + class NoneTypeValidation { + + @Test + @DisplayName("Should pass for NONE type with empty config") + void shouldPassForNoneTypeWithEmptyConfig() { + var security = new WebhookSecurity(WebhookSecurityType.NONE, Map.of()); + + assertThatCode(() -> service.validateForCreation(security)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw for NONE type with non-empty config") + void shouldThrowForNoneTypeWithNonEmptyConfig() { + var security = new WebhookSecurity(WebhookSecurityType.NONE, Map.of("header_name", "X-Test")); + + assertThatThrownBy(() -> service.validateForCreation(security)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("must be empty when type is NONE"); + } + } + + @Nested + @DisplayName("validateForCreation — unregistered type") + class UnregisteredType { + + @Test + @DisplayName("Should throw when no validator is registered for the given type") + void shouldThrowForUnregisteredType() { + lenient().when(hmacCreationValidator.supports("JWT_BEARER")).thenReturn(false); + var security = new WebhookSecurity(WebhookSecurityType.JWT_BEARER, + Map.of("jwks_uri", "https://example.com/.well-known/jwks")); + + assertThatThrownBy(() -> service.validateForCreation(security)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("No validator registered"); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java new file mode 100644 index 00000000..7ceefdcb --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java @@ -0,0 +1,425 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import com.decathlon.idp_core.AbstractIntegrationTest; +import com.decathlon.idp_core.domain.constant.ValidationMessages; + +import lombok.extern.slf4j.Slf4j; + +/// Integration tests for InboundWebhookManagementController, covering all CRUD operations on inbound webhook connectors. +/// +/// Covers the full HTTP contract (status codes, response shape, validation errors) +/// for all CRUD operations on inbound webhook connectors. +@DisplayName("InboundWebhookManagementController Integration Tests") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@Sql(statements = {"DELETE FROM webhook_template_mapping", "DELETE FROM webhook_connector", + "DELETE FROM entity_dynamic_mapping"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", + "/db/test/R__3_insert_webhhook_test_data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Slf4j +class InboundWebhookManagementControllerTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private static final String WEBHOOK_PATH = "/api/v1/inbound-webhooks"; + private static final String JSON_PATH = "integration_test/json/webhook/v1/"; + + private void createWebhookConnector(String identifier, String title) throws Exception { + var payload = """ + { + "identifier": "%s", + "title": "%s", + "description": "test connector", + "enabled": true, + "mappings": [ + { + "template": "microservice", + "filter": "true", + "entity": { + "identifier": ".id", + "title": ".name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.login", + "environment": ".deployment.environment", + "version": ".deployment.sha", + "port": "8080", + "programmingLanguage": ".language" + }, + "relations": {} + } + } + ], + "security": { + "type": "NONE", + "config": {} + } + } + """.formatted(identifier, title); + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()); + } + + private String buildPutPayload(String title, boolean enabled) { + return """ + { + "identifier": "ignored-by-put", + "title": "%s", + "description": "updated description", + "enabled": %s, + "mappings": [ + { + "template": "microservice", + "filter": "true", + "entity": { + "identifier": ".id", + "title": ".name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.login", + "environment": ".deployment.environment", + "version": ".deployment.sha", + "port": "8080", + "programmingLanguage": ".language" + }, + "relations": {} + } + } + ], + "security": { + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Token", + "secret_alias": "MY_TOKEN" + } + } + } + """.formatted(title, enabled); + } + + @Nested + @DisplayName("GET /api/v1/inbound-webhooks") + @Order(1) + class GetWebhooksPaginatedTests { + + @Test + @DisplayName("Should return 401 without authentication") + void getWebhooks_401_without_user_token() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with page containing connectors from test data") + void getWebhooks_200_with_data() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH).accept(APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page.total_elements").value(3)) + .andExpect(jsonPath("$.content[0].identifier").value("github-dora-connector")) + .andExpect(jsonPath("$.content[1].identifier").value("public-connector")) + .andExpect(jsonPath("$.content[2].identifier").value("token-connector")); + } + } + + @Nested + @DisplayName("POST /api/v1/inbound-webhooks - Create connector") + @Order(2) + class PostWebhookTests { + + @Test + @DisplayName("Should return 401 without authentication") + void postWebhook_401_without_user_token() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(JSON_PATH + "postWebhook_201.json"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should create connector and return 201") + void postWebhook_201() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(JSON_PATH + "postWebhook_201.json"))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("github-dora-connector-test")) + .andExpect(jsonPath("$.title").value("GitHub DORA Connector test")) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")); + + assertWebhookTemplateMapping("github-dora-connector-test", "microservice", + ".action == \"deployment\""); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when identifier already exists") + void postWebhook_409_identifier_already_exists() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + JSON_PATH + "postWebhook_409_identifier_already_exists.json"))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description") + .value(containsString(ValidationMessages.WEBHOOK_CONNECTOR_ALREADY_EXIST))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when identifier is missing") + void postWebhook_400_identifier_missing() throws Exception { + var result = postBadRequestAndAssertEquals(WEBHOOK_PATH, + JSON_PATH + "postWebhook_400_identifier_missing.json", + ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY); + assertNotNull(result); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when identifier is blank") + void postWebhook_400_identifier_blank() throws Exception { + var result = postBadRequestAndAssertEquals(WEBHOOK_PATH, + JSON_PATH + "postWebhook_400_identifier_blank.json", + ValidationMessages.WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY); + assertNotNull(result); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when security type is unknown") + void postWebhook_400_invalid_security_type() throws Exception { + var result = postBadRequestAndAssertContains(WEBHOOK_PATH, + JSON_PATH + "postWebhook_400_invalid_security_type.json", "UNKNOWN_TYPE"); + assertNotNull(result); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when JSLT expression is invalid") + void postWebhook_400_invalid_jslt() throws Exception { + var result = postBadRequestAndAssertContains(WEBHOOK_PATH, + JSON_PATH + "postWebhook_400_invalid_jslt.json", "Invalid webhook mapping configuration"); + assertNotNull(result); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when security config is missing required fields (HMAC_SHA256)") + void postWebhook_400_missing_security_config_fields() throws Exception { + var payload = """ + { + "identifier": "missing-security-fields", + "title": "Missing Security Fields", + "enabled": true, + "mappings": [], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256" + } + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("secret_alias"))); + } + } + + @Nested + @DisplayName("GET /api/v1/inbound-webhooks/{identifier} - Get by identifier") + @Order(3) + class GetWebhookByIdentifierTests { + + @Test + @WithMockUser + @DisplayName("Should return 200 with connector details from test data") + void getWebhook_200() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("github-dora-connector")) + .andExpect(jsonPath("$.title").value("GitHub Connector")) + .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")) + .andExpect(jsonPath("$.mappings[0].template").value("microservice")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when connector does not exist") + void getWebhook_404_not_found() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/non-existent-connector").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @DisplayName("Should return 401 without authentication") + void getWebhook_401_without_user_token() throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/github-dora-connector").accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("PUT /api/v1/inbound-webhooks/{identifier} - Update connector") + @Order(4) + class PutWebhookTests { + + @Test + @DisplayName("Should return 401 without authentication") + void putWebhook_401_without_user_token() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/github-dora-connector") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(JSON_PATH + "putWebhook_200.json"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should update connector and return 200") + void putWebhook_200() throws Exception { + createWebhookConnector("connector-put-200", "Connector Put Initial Title"); + + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-200") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildPutPayload("Connector Put Updated Title", false))) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("connector-put-200")) + .andExpect(jsonPath("$.title").value("Connector Put Updated Title")) + .andExpect(jsonPath("$.enabled").value(false)) + .andExpect(jsonPath("$.security.type").value("STATIC_TOKEN")); + + mockMvc.perform(get(WEBHOOK_PATH + "/connector-put-200").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Connector Put Updated Title")) + .andExpect(jsonPath("$.enabled").value(false)); + + assertWebhookTemplateMapping("connector-put-200", "microservice", "true"); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when updating title to an existing one") + void putWebhook_409_title_already_exists() throws Exception { + createWebhookConnector("connector-put-409-a", "Connector A Title"); + createWebhookConnector("connector-put-409-b", "Connector B Title"); + + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-409-b") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildPutPayload("Connector A Title", true))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString( + "Webhook Connector already exist with the same name:Connector A Title"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when connector does not exist") + void putWebhook_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/non-existent-connector") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildPutPayload("Updated Title", false))) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + } + + @Nested + @DisplayName("DELETE /api/v1/inbound-webhooks/{identifier} - Delete connector") + @Order(5) + class DeleteWebhookTests { + + @Test + @DisplayName("Should return 401 without authentication") + void deleteWebhook_401_without_user_token() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/github-dora-connector") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should delete connector and return 204") + void deleteWebhook_204() throws Exception { + createWebhookConnector("connector-delete-204", "Connector To Delete"); + + mockMvc.perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/connector-delete-204") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + mockMvc.perform(get(WEBHOOK_PATH + "/connector-delete-204").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")); + + assertWebhookTemplateMappingCount("connector-delete-204", 0L); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when connector does not exist") + void deleteWebhook_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/non-existent-connector") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + } + + private void assertWebhookTemplateMappingCount(String identifier, long expectedCount) { + Long count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM webhook_template_mapping wtm + JOIN webhook_connector wc ON wc.id = wtm.webhook_id + WHERE wc.identifier = ? + """, Long.class, identifier); + + org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount); + } + + private void assertWebhookTemplateMapping(String identifier, String templateIdentifier, + String filter) { + assertWebhookTemplateMappingCount(identifier, 1L); + + var row = jdbcTemplate.queryForMap(""" + SELECT et.identifier AS template_identifier, wtm.jslt_filter AS jslt_filter + FROM idp_core.webhook_template_mapping wtm + JOIN idp_core.webhook_connector wc ON wc.id = wtm.webhook_id + JOIN idp_core.entity_template et ON et.id = wtm.template_id + WHERE wc.identifier = ? + """, identifier); + + org.assertj.core.api.Assertions.assertThat(row).containsEntry("template_identifier", + templateIdentifier); + org.assertj.core.api.Assertions.assertThat(row).containsEntry("jslt_filter", filter); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 8dff1a38..b4a71a18 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -1,8 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.api.handler; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -29,11 +27,14 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingConfigurationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.domain.exception.webhook.WebhookTemplateHasNoPropertiesException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; /// Comprehensive unit tests for [ApiExceptionHandler]. @@ -160,94 +161,227 @@ void shouldHandleEntityValidationException() { assertEquals(exception.getMessage(), body.getErrorDescription()); } - /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNameAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and description - @Test - @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateNameAlreadyExistsException() { - // Given - String name = "Duplicate Name"; - EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException( - name); - - // When - ResponseEntity response = exceptionHandler - .handleEntityTemplateNameAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityNotFoundException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the entity-specific context message - @Test - @DisplayName("Should handle EntityNotFoundException with 404 status") - void shouldHandleEntityNotFoundException() { - // Given - EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); - - // When - ResponseEntity response = exceptionHandler - .handleEntityNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - } - - @Nested - @DisplayName("Validation Exception Handling") - class ValidationExceptionTests { - - /// Tests the handling of [ConstraintViolationException] with a single - /// validation violation. - /// - /// **This test verifies that:** - /// - ConstraintViolationException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Single violation message is correctly extracted and returned - /// - Error response format matches expected structure - @Test - @DisplayName("Should handle ConstraintViolationException with single violation") - void shouldHandleConstraintViolationExceptionSingleViolation() { - // Given - ConstraintViolation violation = createMockConstraintViolation( - "Field must not be null"); - Set> violations = Set.of(violation); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", - violations); - - // When - ResponseEntity response = exceptionHandler - .handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Field must not be null", body.getErrorDescription()); + @Nested + @DisplayName("Validation Exception Handling") + class ValidationExceptionTests { + + @Test + @DisplayName("Should handle EntityDynamicMappingConfigurationException with 400 status") + void shouldHandleEntityDynamicMappingConfigurationException() { + String details = "Syntax Error in 'properties.deployment_id': Parse error"; + EntityDynamicMappingConfigurationException exception = new EntityDynamicMappingConfigurationException( + details); + + ResponseEntity response = exceptionHandler + .handleEntityDynamicMappingConfigurationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Invalid webhook mapping configuration: " + details, + body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle PropertyNameNotFoundEntityTemplatePropertiesException with 400 status") + void shouldHandlePropertyNameNotFoundEntityTemplatePropertiesException() { + String details = "Property name additionalProp3 not found in entity template properties"; + PropertyNameNotFoundEntityTemplatePropertiesException exception = new PropertyNameNotFoundEntityTemplatePropertiesException( + details); + + ResponseEntity response = exceptionHandler + .handlePropertyNameNotFoundEntityTemplatePropertiesException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(details, body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle RelationNameNotFoundEntityTemplateRelationsException with 400 status") + void shouldHandleRelationNameNotFoundEntityTemplateRelationsException() { + // Given + String details = "Relation name github_repository not found in entity template relations"; + RelationNameNotFoundEntityTemplateRelationsException exception = new RelationNameNotFoundEntityTemplateRelationsException( + details); + + // When + ResponseEntity response = exceptionHandler + .handleRelationNameNotFoundEntityTemplateRelationsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(details, body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle WebhookTemplateHasNoPropertiesException with 400 status") + void shouldHandleWebhookTemplateHasNoPropertiesException() { + String details = "The mapping defines properties but the target template has no property definitions"; + WebhookTemplateHasNoPropertiesException exception = new WebhookTemplateHasNoPropertiesException( + details); + + ResponseEntity response = exceptionHandler + .handleWebhookTemplateHasNoPropertiesException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(details, body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle WebhookSecurityConfigurationException with 400 status") + void shouldHandleWebhookSecurityConfigurationException() { + String details = "Webhook security type is mandatory"; + WebhookSecurityConfigurationException exception = new WebhookSecurityConfigurationException( + details); + + ResponseEntity response = exceptionHandler + .handleWebhookSecurityConfigurationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(details, body.getErrorDescription()); + } + + /// Tests the handling of [ConstraintViolationException] with a single + /// validation violation. + /// + /// **This test verifies that:** + /// - ConstraintViolationException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Single violation message is correctly extracted and returned + /// - Error response format matches expected structure + @Test + @DisplayName("Should handle ConstraintViolationException with single violation") + void shouldHandleConstraintViolationExceptionSingleViolation() { + // Given + ConstraintViolation violation = createMockConstraintViolation( + "Field must not be null"); + Set> violations = Set.of(violation); + ConstraintViolationException exception = new ConstraintViolationException( + "Validation failed", violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Field must not be null", body.getErrorDescription()); + } + + /// Tests the handling of [ConstraintViolationException] with multiple + /// validation violations. + /// + /// **This test verifies that:** + /// - ConstraintViolationException with multiple violations is properly handled + /// - HTTP 400 Bad Request status is returned + /// - All violation messages are concatenated with comma separation + /// - Error response contains all validation error messages + @Test + @DisplayName("Should handle ConstraintViolationException with multiple violations") + void shouldHandleConstraintViolationExceptionMultipleViolations() { + // Given + ConstraintViolation violation1 = createMockConstraintViolation( + "Field1 must not be null"); + ConstraintViolation violation2 = createMockConstraintViolation( + "Field2 must not be blank"); + Set> violations = Set.of(violation1, violation2); + ConstraintViolationException exception = new ConstraintViolationException( + "Validation failed", violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 must not be null")); + assertTrue(errorDescription.contains("Field2 must not be blank")); + assertTrue(errorDescription.contains(", ")); + } + + /// Tests the handling of [MethodArgumentNotValidException] with field + /// validation errors. + /// + /// **This test verifies that:** + /// - MethodArgumentNotValidException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Field error messages from binding result are extracted and concatenated + /// - All field validation errors are included in the response with comma + /// separation + /// + /// @throws Exception if reflection fails during test setup + @Test + @DisplayName("Should handle MethodArgumentNotValidException with field errors") + void shouldHandleMethodArgumentNotValidException() throws Exception { + // Given + Object target = new Object(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, + "testObject"); + bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); + bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); + + // Create a proper MethodParameter mock with required methods + MethodParameter methodParameter = mock(MethodParameter.class); + when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException( + methodParameter, bindingResult); + + // When + ResponseEntity response = exceptionHandler + .handleMethodArgumentNotValidException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 is required")); + assertTrue(errorDescription.contains("Field2 must be valid")); + assertTrue(errorDescription.contains(", ")); + } + + // Helper method for mocking + public void testMethod() { + // Empty method for testing purposes + } + + @SuppressWarnings("unchecked") + private ConstraintViolation createMockConstraintViolation(String message) { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn(message); + return violation; + } } /// Tests the handling of [ConstraintViolationException] with multiple diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java new file mode 100644 index 00000000..07411231 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java @@ -0,0 +1,87 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.webhook; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookEntityMappingDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookMappingDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; + +@DisplayName("InboundWebhookMapper Tests") +class InboundWebhookMapperTest { + + private final InboundWebhookMapper mapper = new InboundWebhookMapper(); + + @Test + @DisplayName("Should use path identifier for update mapping") + void shouldUsePathIdentifierForUpdateMapping() { + var request = new InboundWebhookCreateDtoIn("identifier_from_body", "GitHub DORA", + "Collect deployment events", true, + List.of(new InboundWebhookMappingDtoIn("deployment", ".eventType == \"DEPLOYED\"", + new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of("environment", ".env"), + Map.of("service", ".service")))), + new InboundWebhookSecurityContractDtoIn("HMAC_SHA256", Map.of("header_name", + "X-Hub-Signature-256", "secret_alias", "MY_SECRET", "prefix", "sha256="))); + + WebhookConnector domain = mapper.toDomainForUpdate("identifier_from_path", request); + + assertThat(domain.id()).isNull(); + assertThat(domain.identifier()).isEqualTo("identifier_from_path"); + assertThat(domain.title()).isEqualTo("GitHub DORA"); + assertThat(domain.mappings()).hasSize(1); + assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.HMAC_SHA256); + assertThat(domain.security().config()).containsEntry("prefix", "sha256="); + } + + @Test + @DisplayName("Should throw for unknown security type") + void shouldThrowForUnknownSecurityType() { + var request = new InboundWebhookCreateDtoIn("my-connector", "Custom Security", + "Uses custom security", true, + List.of(new InboundWebhookMappingDtoIn("deployment", "true", + new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of(), Map.of()))), + new InboundWebhookSecurityContractDtoIn("CUSTOM_UNKNOWN_TYPE", + Map.of("customKey", "customValue"))); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> mapper.toDomain(request)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("CUSTOM_UNKNOWN_TYPE"); + } + + @Test + @DisplayName("Should map NONE security type explicitly") + void shouldMapNoneSecurityTypeExplicitly() { + var request = new InboundWebhookCreateDtoIn("my-connector", "No Auth", + "Webhook without authentication", true, + List.of(new InboundWebhookMappingDtoIn("deployment", "true", + new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of(), Map.of()))), + new InboundWebhookSecurityContractDtoIn("NONE", Map.of())); + + var domain = mapper.toDomain(request); + + assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE); + assertThat(domain.security().config()).isEmpty(); + } + + @Test + @DisplayName("Should default to NONE when security section is missing") + void shouldDefaultToNoneWhenSecurityIsMissing() { + var request = new InboundWebhookCreateDtoIn("my-connector", "No Auth", + "Webhook without authentication", true, List.of(new InboundWebhookMappingDtoIn("deployment", + "true", new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of(), Map.of()))), + null); + + var domain = mapper.toDomain(request); + + assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE); + assertThat(domain.security().config()).isEmpty(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java new file mode 100644 index 00000000..4a3bab7e --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/BasicAuthSecurityValidatorTest.java @@ -0,0 +1,27 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Runtime Security: Basic Auth Validator") +class BasicAuthSecurityValidatorTest { + + private BasicAuthSecurityValidator validator; + + @BeforeEach + void setUp() { + validator = new BasicAuthSecurityValidator(); + } + + @Test + @DisplayName("supports() -> True for 'BASIC_AUTH' (case-insensitive), False for others") + void shouldReturnTrueWhenTypeIsBasicAuth() { + assertThat(validator.supports("BASIC_AUTH")).isTrue(); + assertThat(validator.supports("basic_auth")).isTrue(); + assertThat(validator.supports("HMAC_SHA256")).isFalse(); + } + +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java new file mode 100644 index 00000000..42b631f7 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/HmacSignatureValidatorTest.java @@ -0,0 +1,74 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookAuthenticationException; + +@DisplayName("HmacSignatureValidator Tests") +class HmacSignatureValidatorTest { + + private static final String SECRET = "super-secret-key"; + private static final String BODY = "{\"action\":\"closed\"}"; + + private HmacSignatureValidator validator; + + @BeforeEach + void setUp() { + validator = new HmacSignatureValidator(); + } + + @Test + @DisplayName("Should compute a valid hex-encoded HMAC-SHA256 digest") + void shouldComputeValidHmacSha256() throws Exception { + var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + var expected = HexFormat.of().formatHex(mac.doFinal(BODY.getBytes(StandardCharsets.UTF_8))); + + var actual = validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), SECRET); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("Should produce different digests for different payloads") + void shouldProduceDifferentDigestsForDifferentPayloads() { + var digest1 = validator.computeHexSha256("payload1".getBytes(StandardCharsets.UTF_8), SECRET); + var digest2 = validator.computeHexSha256("payload2".getBytes(StandardCharsets.UTF_8), SECRET); + + assertThat(digest1).isNotEqualTo(digest2); + } + + @Test + @DisplayName("Should produce different digests for different secrets") + void shouldProduceDifferentDigestsForDifferentSecrets() { + assertThat(validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), "secret-a")) + .isNotEqualTo( + validator.computeHexSha256(BODY.getBytes(StandardCharsets.UTF_8), "secret-b")); + } + + @Test + @DisplayName("Should throw WebhookAuthenticationException on internal crypto error") + void shouldThrowOnCryptoError() { + assertThat(validator.computeHexSha256(new byte[0], SECRET)).isNotBlank(); + } + + @Test + @DisplayName("Should throw WebhookAuthenticationException when secret is empty string") + void shouldThrowWhenSecretIsEmpty() { + byte[] bodyBytes = BODY.getBytes(StandardCharsets.UTF_8); + assertThatThrownBy(() -> validator.computeHexSha256(bodyBytes, "")) + .isInstanceOf(WebhookAuthenticationException.class) + .hasMessageContaining("Unable to compute HMAC signature"); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java new file mode 100644 index 00000000..2bdc26bf --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/JwtBearerSecurityValidatorTest.java @@ -0,0 +1,42 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("JwtBearerSecurityValidator Tests") +@ExtendWith(MockitoExtension.class) +class JwtBearerSecurityValidatorTest { + + private final JwtBearerSecurityValidator validator = new JwtBearerSecurityValidator(); + + @Test + @DisplayName("Should support JWT_BEARER (case-insensitive)") + void shouldSupportJwtBearer() { + assertThat(validator.supports("JWT_BEARER")).isTrue(); + assertThat(validator.supports("jwt_bearer")).isTrue(); + assertThat(validator.supports("STATIC_TOKEN")).isFalse(); + } + + @Nested + @DisplayName("validateConfiguration — missing config keys") + class MissingConfigKeys { + + @Test + @DisplayName("Should throw when jwks_uri is missing from config") + void shouldThrowWhenJwksUriMissing() { + Map config = Map.of("other_key", "value"); + + assertThatThrownBy(() -> validator.validateConfiguration(config)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("jwks_uri"); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java new file mode 100644 index 00000000..0560ce8d --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/StaticTokenSecurityValidatorTest.java @@ -0,0 +1,54 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("StaticTokenSecurityValidator Tests") +class StaticTokenSecurityValidatorTest { + + private StaticTokenSecurityValidator validator; + + @BeforeEach + void setUp() { + validator = new StaticTokenSecurityValidator(); + } + + @Test + @DisplayName("Should support STATIC_TOKEN (case-insensitive)") + void shouldSupportStaticToken() { + assertThat(validator.supports("STATIC_TOKEN")).isTrue(); + assertThat(validator.supports("static_token")).isTrue(); + assertThat(validator.supports("HMAC_SHA256")).isFalse(); + } + + @Nested + @DisplayName("validateConfiguration — missing config keys") + class MissingConfigKeys { + + @Test + @DisplayName("Should throw when header_name is missing from config") + void shouldThrowWhenHeaderNameMissing() { + Map config = Map.of("secret_alias", "MY_ALIAS"); + + assertThatThrownBy(() -> validator.validateConfiguration(config)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("header_name"); + } + + @Test + @DisplayName("Should throw when secret_alias is missing from config") + void shouldThrowWhenSecretAliasMissing() { + Map config = Map.of("header_name", "X-Token"); + assertThatThrownBy(() -> validator.validateConfiguration(config)).isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + .hasMessageContaining("secret_alias"); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/BasicAuthWebhookSecurityCreationValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/BasicAuthWebhookSecurityCreationValidatorTest.java new file mode 100644 index 00000000..de3be905 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/BasicAuthWebhookSecurityCreationValidatorTest.java @@ -0,0 +1,86 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.webhook.security.BasicAuthSecurityValidator; + +@DisplayName("Security Validator: Basic Auth Creation") +class BasicAuthWebhookSecurityCreationValidatorTest { + + private BasicAuthSecurityValidator validator; + + @BeforeEach + void setUp() { + validator = new BasicAuthSecurityValidator(); + } + + @Test + @DisplayName("supports() -> True for 'BASIC_AUTH' (case-insensitive), False for others") + void shouldReturnTrueWhenTypeIsBasicAuth() { + assertThat(validator.supports("BASIC_AUTH")).isTrue(); + assertThat(validator.supports("basic_auth")).isTrue(); + assertThat(validator.supports("HMAC_SHA256")).isFalse(); + } + + @Nested + @DisplayName("Valid Configurations") + class ValidConfigurations { + + @Test + @DisplayName("validateConfiguration() -> Passes when both username and UPPER_SNAKE_CASE secret_alias are present") + void shouldPassWhenAllRequiredFieldsAreValid() { + Map config = Map.of("username", "webhook-user", "secret_alias", + "TEST_CREDENTIAL_ALIAS"); + assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("validateConfiguration() -> Passes when secret key is provided as camelCase 'secretAlias'") + void shouldPassWhenSecretKeyIsCamelCase() { + Map config = Map.of("username", "webhook-user", "secretAlias", + "TEST_CREDENTIAL_ALIAS"); + assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Invalid Configurations (Missing or Malformed Fields)") + class InvalidConfigurations { + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'username' is completely missing") + void shouldThrowExceptionWhenUsernameIsMissing() { + Map config = Map.of("secret_alias", "TEST_CREDENTIAL_ALIAS"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("username"); + } + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' is completely missing") + void shouldThrowExceptionWhenSecretAliasIsMissing() { + Map config = Map.of("username", "webhook-user"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("secret_alias"); + } + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' contains lowercase letters") + void shouldThrowExceptionWhenSecretAliasIsNotUpperSnakeCase() { + Map config = Map.of("username", "webhook-user", "secret_alias", + "not_upper_snake"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("UPPER_SNAKE_CASE"); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/HmacSha256WebhookSecurityCreationValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/HmacSha256WebhookSecurityCreationValidatorTest.java new file mode 100644 index 00000000..b54e7f19 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/HmacSha256WebhookSecurityCreationValidatorTest.java @@ -0,0 +1,95 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.webhook.security.HmacSha256SecurityValidator; + +@DisplayName("Security Validator: HMAC SHA-256 Creation") +class HmacSha256WebhookSecurityCreationValidatorTest { + + private HmacSha256SecurityValidator validator; + + @BeforeEach + void setUp() { + validator = new HmacSha256SecurityValidator(); + } + + @Test + @DisplayName("supports() -> True for 'HMAC_SHA256' (case-insensitive), False for others") + void shouldReturnTrueWhenTypeIsHmacSha256() { + assertThat(validator.supports("HMAC_SHA256")).isTrue(); + assertThat(validator.supports("hmac_sha256")).isTrue(); + assertThat(validator.supports("STATIC_TOKEN")).isFalse(); + } + + @Nested + @DisplayName("Valid Configurations") + class ValidConfigurations { + + @Test + @DisplayName("validateConfiguration() -> Passes when header_name and UPPER_SNAKE_CASE secret_alias are present") + void shouldPassWhenAllRequiredFieldsAreValidWithSnakeCase() { + Map config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", + "MY_GITHUB_SECRET"); + assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("validateConfiguration() -> Passes when keys are provided as camelCase (headerName, secretAlias)") + void shouldPassWhenRequiredFieldsAreCamelCase() { + Map config = Map.of("headerName", "X-Hub-Signature-256", "secretAlias", + "MY_GITHUB_SECRET"); + assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("validateConfiguration() -> Passes when secret_alias is provided as an environment reference") + void shouldPassWhenSecretAliasUsesEnvironmentReference() { + Map config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", + "${MY_GITHUB_SECRET}"); + assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException(); + } + + } + + @Nested + @DisplayName("Invalid Configurations (Missing or Malformed Fields)") + class InvalidConfigurations { + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'header_name' is completely missing") + void shouldThrowExceptionWhenHeaderNameIsMissing() { + Map config = Map.of("secret_alias", "MY_SECRET"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("header_name"); + } + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' is completely missing") + void shouldThrowExceptionWhenSecretAliasIsMissing() { + Map config = Map.of("header_name", "X-Hub-Signature-256"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("secret_alias"); + } + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' contains lowercase letters or invalid characters") + void shouldThrowExceptionWhenSecretAliasIsNotUpperSnakeCase() { + Map config = Map.of("header_name", "X-Hub-Signature-256", "secret_alias", + "my-raw-secret-value"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("UPPER_SNAKE_CASE"); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/JwtBearerWebhookSecurityCreationValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/JwtBearerWebhookSecurityCreationValidatorTest.java new file mode 100644 index 00000000..d667370c --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/JwtBearerWebhookSecurityCreationValidatorTest.java @@ -0,0 +1,71 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.webhook.security.JwtBearerSecurityValidator; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Security Validator: JWT Bearer Creation") +class JwtBearerWebhookSecurityCreationValidatorTest { + + private final JwtBearerSecurityValidator validator = new JwtBearerSecurityValidator(); + + @Test + @DisplayName("supports() -> True for 'JWT_BEARER' (case-insensitive), False for others") + void shouldReturnTrueWhenTypeIsJwtBearer() { + assertThat(validator.supports("JWT_BEARER")).isTrue(); + assertThat(validator.supports("jwt_bearer")).isTrue(); + assertThat(validator.supports("HMAC_SHA256")).isFalse(); + assertThat(validator.supports("BASIC_AUTH")).isFalse(); + } + + @Nested + @DisplayName("Valid Configurations") + class ValidConfigurations { + + @Test + @DisplayName("validateConfiguration() -> Passes when 'jwks_uri' is provided in snake_case") + void shouldPassWhenJwksUriIsValidWithSnakeCase() { + Map config = Map.of("jwks_uri", "https://example.com/.well-known/jwks.json"); + assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("validateConfiguration() -> Passes when 'jwksUri' is provided as camelCase") + void shouldPassWhenJwksUriIsValidWithCamelCase() { + Map config = Map.of("jwksUri", "https://example.com/.well-known/jwks.json"); + assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Invalid Configurations (Missing or Malformed Fields)") + class InvalidConfigurations { + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'jwks_uri' is completely missing") + void shouldThrowExceptionWhenJwksUriIsMissing() { + Map config = Map.of("other_key", "some-value"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("jwks_uri"); + } + + @Test + @DisplayName("validateConfiguration() -> Throws exception when the configuration map is empty") + void shouldThrowExceptionWhenConfigIsEmpty() { + Map config = Map.of(); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/StaticTokenWebhookSecurityCreationValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/StaticTokenWebhookSecurityCreationValidatorTest.java new file mode 100644 index 00000000..ae9c3599 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/webhook/security/creation/StaticTokenWebhookSecurityCreationValidatorTest.java @@ -0,0 +1,86 @@ +package com.decathlon.idp_core.infrastructure.adapters.webhook.security.creation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.webhook.security.StaticTokenSecurityValidator; + +@DisplayName("Security Validator: Static Token Creation") +class StaticTokenWebhookSecurityCreationValidatorTest { + + private StaticTokenSecurityValidator validator; + + @BeforeEach + void setUp() { + validator = new StaticTokenSecurityValidator(); + } + + @Test + @DisplayName("supports() -> True for 'STATIC_TOKEN' (case-insensitive), False for others") + void shouldReturnTrueWhenTypeIsStaticToken() { + assertThat(validator.supports("STATIC_TOKEN")).isTrue(); + assertThat(validator.supports("static_token")).isTrue(); + assertThat(validator.supports("HMAC_SHA256")).isFalse(); + } + + @Nested + @DisplayName("Valid Configurations") + class ValidConfigurations { + + @Test + @DisplayName("validateConfiguration() -> Passes when header_name and UPPER_SNAKE_CASE secret_alias are present") + void shouldPassWhenAllRequiredFieldsAreValidWithSnakeCase() { + Map config = Map.of("header_name", "X-Webhook-Token", "secret_alias", + "WEBHOOK_TOKEN_SECRET"); + assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("validateConfiguration() -> Passes when keys are provided as camelCase (headerName, secretAlias)") + void shouldPassWhenRequiredFieldsAreCamelCase() { + Map config = Map.of("headerName", "X-Webhook-Token", "secretAlias", + "WEBHOOK_TOKEN_SECRET"); + assertThatCode(() -> validator.validateConfiguration(config)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Invalid Configurations (Missing or Malformed Fields)") + class InvalidConfigurations { + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'header_name' is completely missing") + void shouldThrowExceptionWhenHeaderNameIsMissing() { + Map config = Map.of("secret_alias", "TOKEN_SECRET"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("header_name"); + } + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' is completely missing") + void shouldThrowExceptionWhenSecretAliasIsMissing() { + Map config = Map.of("header_name", "X-Webhook-Token"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("secret_alias"); + } + + @Test + @DisplayName("validateConfiguration() -> Throws exception when 'secret_alias' contains lowercase letters or invalid characters") + void shouldThrowExceptionWhenSecretAliasIsNotUpperSnakeCase() { + Map config = Map.of("header_name", "X-Webhook-Token", "secret_alias", + "plainTextSecret"); + assertThatThrownBy(() -> validator.validateConfiguration(config)) + .isInstanceOf(WebhookSecurityConfigurationException.class) + .hasMessageContaining("UPPER_SNAKE_CASE"); + } + } +} diff --git a/src/test/resources/db/test/R__3_insert_webhhook_test_data.sql b/src/test/resources/db/test/R__3_insert_webhhook_test_data.sql new file mode 100644 index 00000000..45eb1e4e --- /dev/null +++ b/src/test/resources/db/test/R__3_insert_webhhook_test_data.sql @@ -0,0 +1,52 @@ +-- Test data for Webhook Connectors +-- Purpose: provide pre-configured webhook connectors for integration testing + +-- Clear existing data +DELETE FROM webhook_template_mapping; +DELETE FROM webhook_connector; +DELETE FROM entity_dynamic_mapping; + +-- Webhook Connector 1: GitHub Connector (HMAC_SHA256) +INSERT INTO webhook_connector (id, identifier, title, description, enabled, security) +VALUES ('770e8400-e29b-41d4-a716-446655440001', + 'github-dora-connector', + 'GitHub Connector', + 'Receives events from GitHub with HMAC validation', + true, + '{"type": "HMAC_SHA256", "config": {"header_name": "X-Hub-Signature-256", "secret_alias": "GITHUB_SECRET", "prefix": "sha256="}}'::jsonb); + +-- Webhook Connector 2: Simple Token Connector (STATIC_TOKEN) +INSERT INTO webhook_connector (id, identifier, title, description, enabled, security) +VALUES ('770e8400-e29b-41d4-a716-446655440002', + 'token-connector', + 'Token Connector', + 'Simple connector with static token', + true, + '{"type": "STATIC_TOKEN", "config": {"header_name": "X-Auth-Token", "secret_alias": "WEBHOOK_TOKEN"}}'::jsonb); + +-- Webhook Connector 3: Public Connector (NONE) +INSERT INTO webhook_connector (id, identifier, title, description, enabled, security) +VALUES ('770e8400-e29b-41d4-a716-446655440003', + 'public-connector', + 'Public Connector', + 'Open connector for testing', + true, + '{"type": "NONE", "config": {}}'::jsonb); + +-- Dynamic Mapping for GitHub Connector +INSERT INTO entity_dynamic_mapping (id, template_identifier, filter, entity_identifier, entity_title, properties, relations) +VALUES ('880e8400-e29b-41d4-a716-446655440001', + 'microservice', + '.action == "pushed"', + '.repository.full_name', + '.repository.name', + '{"applicationName": ".repository.name", "programmingLanguage": ".repository.language"}', + '{}'); + +-- Webhook Template Mapping for GitHub Connector +INSERT INTO webhook_template_mapping (id, webhook_id, template_id, entity_mapping_id, jslt_filter) +VALUES ('990e8400-e29b-41d4-a716-446655440001', + '770e8400-e29b-41d4-a716-446655440001', + '550e8400-e29b-41d4-a716-446655440071', -- microservice template + '880e8400-e29b-41d4-a716-446655440001', + '.action == "pushed"'); diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json new file mode 100644 index 00000000..680c7e9c --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json @@ -0,0 +1,33 @@ +{ + "identifier": "github-dora-connector-test", + "title": "GitHub DORA Connector test", + "description": "Collects deployment events from GitHub", + "enabled": true, + "mappings": [ + { + "template": "microservice", + "filter": ".action == \"deployment\"", + "entity": { + "identifier": ".repository.name", + "title": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.login + \"@example.com\"", + "environment": ".deployment.environment", + "version": ".deployment.sha", + "port": "8080", + "programmingLanguage": "\"Java\"" + }, + "relations": {} + } + } + ], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "MY_WEBHOOK_SECRET", + "prefix": "sha256=" + } + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json new file mode 100644 index 00000000..77d7fb0a --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json @@ -0,0 +1,22 @@ +{ + "identifier": " ", + "title": "Blank Identifier", + "description": "Should fail", + "enabled": true, + "mappings": [ + { + "template": "microservice", + "filter": "true", + "entity": { + "identifier": ".id", + "title": ".name", + "properties": {}, + "relations": {} + } + } + ], + "security": { + "type": "NONE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json new file mode 100644 index 00000000..23929880 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json @@ -0,0 +1,21 @@ +{ + "title": "Missing Identifier", + "description": "Should fail", + "enabled": true, + "mappings": [ + { + "template": "microservice", + "filter": "true", + "entity": { + "identifier": ".id", + "title": ".name", + "properties": {}, + "relations": {} + } + } + ], + "security": { + "type": "NONE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json new file mode 100644 index 00000000..2b021198 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json @@ -0,0 +1,29 @@ +{ + "identifier": "invalid-jslt-connector", + "title": "Invalid JSLT Connector", + "description": "Should fail due to invalid JSLT filter", + "enabled": true, + "mappings": [ + { + "template": "microservice", + "filter": "@@@ INVALID JSLT @@@", + "entity": { + "identifier": ".id", + "title": ".name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.login + \"@example.com\"", + "environment": ".deployment.environment", + "version": ".deployment.sha", + "port": "8080", + "programmingLanguage": "\"Java\"" + }, + "relations": {} + } + } + ], + "security": { + "type": "NONE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json new file mode 100644 index 00000000..c58f6b92 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json @@ -0,0 +1,29 @@ +{ + "identifier": "invalid-security-type-connector", + "title": "Invalid Security Type", + "description": "Should fail", + "enabled": true, + "mappings": [ + { + "template": "microservice", + "filter": "true", + "entity": { + "identifier": ".id", + "title": ".name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.login + \"@example.com\"", + "environment": ".deployment.environment", + "version": ".deployment.sha", + "port": "8080", + "programmingLanguage": "\"Java\"" + }, + "relations": {} + } + } + ], + "security": { + "type": "UNKNOWN_TYPE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json new file mode 100644 index 00000000..a96b1d2f --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json @@ -0,0 +1,11 @@ +{ + "identifier": "no-mappings-connector", + "title": "No Mappings", + "description": "Should fail", + "enabled": true, + "mappings": [], + "security": { + "type": "NONE", + "config": {} + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json new file mode 100644 index 00000000..430dc099 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json @@ -0,0 +1,33 @@ +{ + "identifier": "github-dora-connector", + "title": "GitHub DORA Connector", + "description": "Collects deployment events from GitHub", + "enabled": true, + "mappings": [ + { + "template": "microservice", + "filter": ".action == \"deployment\"", + "entity": { + "identifier": ".repository.name", + "title": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.login + \"@example.com\"", + "environment": ".deployment.environment", + "version": ".deployment.sha", + "port": "8080", + "programmingLanguage": "\"Java\"" + }, + "relations": {} + } + } + ], + "security": { + "type": "HMAC_SHA256", + "config": { + "header_name": "X-Hub-Signature-256", + "secret_alias": "MY_WEBHOOK_SECRET", + "prefix": "sha256=" + } + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json b/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json new file mode 100644 index 00000000..2d8c1a78 --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json @@ -0,0 +1,32 @@ +{ + "identifier": "connector-to-update", + "title": "Updated Title", + "description": "Updated description", + "enabled": false, + "mappings": [ + { + "template": "microservice", + "filter": "true", + "entity": { + "identifier": ".id", + "title": ".name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.login + \"@example.com\"", + "environment": ".deployment.environment", + "version": ".deployment.sha", + "port": "8080", + "programmingLanguage": "\"Java\"" + }, + "relations": {} + } + } + ], + "security": { + "type": "STATIC_TOKEN", + "config": { + "header_name": "X-Token", + "secret_alias": "MY_TOKEN" + } + } +} diff --git a/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json b/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json new file mode 100644 index 00000000..08f7761b --- /dev/null +++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json @@ -0,0 +1,29 @@ +{ + "identifier": "connector-to-update", + "title": "GitHub DORA Connector", + "description": "Duplicate title", + "enabled": true, + "mappings": [ + { + "template": "microservice", + "filter": "true", + "entity": { + "identifier": ".id", + "title": ".name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.login + \"@example.com\"", + "environment": ".deployment.environment", + "version": ".deployment.sha", + "port": "8080", + "programmingLanguage": "\"Java\"" + }, + "relations": {} + } + } + ], + "security": { + "type": "NONE", + "config": {} + } +} From dc7d4fba9d1df079b38a8022ad7606a55ae11788 Mon Sep 17 00:00:00 2001 From: foukou19 Date: Thu, 18 Jun 2026 15:50:39 +0200 Subject: [PATCH 2/2] feat(core): add endpoints to manage entity dynamic mapping Signed-off-by: foukou19 --- .../domain/constant/ValidationMessages.java | 3 + ...yDynamicMappingAlreadyExistsException.java | 10 + ...tyDynamicMappingAlreadyInUseException.java | 12 + ...EntityDynamicMappingNotFoundException.java | 10 + .../entity_mapping/EntityDynamicMapping.java | 8 +- .../domain/port/EntityDynamicMappingPort.java | 16 + .../port/WebhookTemplateMappingPort.java | 2 + .../webhook/DynamicMappingService.java | 112 +++++ ...EntityDynamicMappingValidationService.java | 20 +- .../webhook/WebhookConnectorService.java | 32 +- .../WebhookConnectorValidationService.java | 3 +- .../configuration/SwaggerConfiguration.java | 11 + .../api/configuration/SwaggerDescription.java | 26 +- .../EntityDynamicMappingController.java | 113 +++++ .../InboundWebhookManagementController.java | 15 +- .../in/EntityDynamicMappingCreateDtoIn.java | 22 + ...ntityDynamicMappingDtoInCommonFields.java} | 8 +- .../in/EntityDynamicMappingUpdateDtoIn.java | 21 + .../api/dto/in/InboundWebhookCreateDtoIn.java | 13 +- ...t.java => EntityDynamicMappingDtoOut.java} | 2 +- .../dto/out/webhook/InboundWebhookDtoOut.java | 2 +- .../api/handler/ApiExceptionHandler.java | 51 +++ .../connector/DynamicMappingMapper.java | 63 +++ .../webhook/InboundWebhookMapper.java | 55 ++- .../EntityDynamicMappingAdaptor.java | 37 +- .../PostgresWebhookConnectorAdapter.java | 23 +- .../WebhookTemplateMappingAdaptor.java | 11 + ...EntityDynamicMappingPersistenceMapper.java | 1 - .../EntityDynamicMappingJpaEntity.java | 3 + .../JpaEntityDynamicMappingRepository.java | 9 + .../JpaWebhookTemplateMappingRepository.java | 3 +- ...2__create_entity_dynamic_mapping_table.sql | 1 + .../webhook/DynamicMappingServiceTest.java | 81 ++++ ...tyDynamicMappingValidationServiceTest.java | 66 +-- .../webhook/WebhookConnectorServiceTest.java | 52 ++- ...WebhookConnectorValidationServiceTest.java | 7 +- .../EntityDynamicMappingControllerTest.java | 403 ++++++++++++++++++ ...nboundWebhookManagementControllerTest.java | 162 ++++--- .../webhook/InboundWebhookMapperTest.java | 44 +- .../db/test/R__1_Insert_test_data.sql | 4 + .../test/R__3_insert_webhhook_test_data.sql | 3 +- .../json/webhook/v1/postWebhook_201.json | 20 +- .../v1/postWebhook_400_identifier_blank.json | 13 +- .../postWebhook_400_identifier_missing.json | 13 +- .../v1/postWebhook_400_invalid_jslt.json | 20 +- ...postWebhook_400_invalid_security_type.json | 20 +- .../v1/postWebhook_400_mappings_empty.json | 2 +- ...Webhook_409_identifier_already_exists.json | 20 +- .../json/webhook/v1/putWebhook_200.json | 20 +- .../putWebhook_409_title_already_exists.json | 20 +- 50 files changed, 1342 insertions(+), 346 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyExistsException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyInUseException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingNotFoundException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingService.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/{InboundWebhookMappingDtoIn.java => EntityDynamicMappingDtoInCommonFields.java} (60%) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/{InboundWebhookMappingDtoOut.java => EntityDynamicMappingDtoOut.java} (67%) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/DynamicMappingMapper.java rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/{ => connector}/webhook/InboundWebhookMapper.java (65%) create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java 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 2f94b0a2..c17fdcbd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -68,6 +68,9 @@ public class ValidationMessages { 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'"; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyExistsException.java new file mode 100644 index 00000000..ce428598 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyExistsException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.entity_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS; + +public class EntityDynamicMappingAlreadyExistsException extends RuntimeException { + + public EntityDynamicMappingAlreadyExistsException(String identifier) { + super(String.format(ENTITY_DYNAMIC_MAPPING_ALREADY_EXISTS, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyInUseException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyInUseException.java new file mode 100644 index 00000000..bd8d3d50 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingAlreadyInUseException.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.domain.exception.entity_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE; + +import java.util.List; + +public class EntityDynamicMappingAlreadyInUseException extends RuntimeException { + + public EntityDynamicMappingAlreadyInUseException(List identifier) { + super(ENTITY_DYNAMIC_MAPPING_ALREADY_IN_USE.formatted(identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingNotFoundException.java new file mode 100644 index 00000000..c14206fd --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_mapping/EntityDynamicMappingNotFoundException.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.exception.entity_mapping; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_NOT_FOUND; + +public class EntityDynamicMappingNotFoundException extends RuntimeException { + + public EntityDynamicMappingNotFoundException(String identifier) { + super(String.format(ENTITY_DYNAMIC_MAPPING_NOT_FOUND, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_mapping/EntityDynamicMapping.java index 257a41c2..51da60ce 100644 --- 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 @@ -12,11 +12,15 @@ /// /// 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 templateIdentifier, String filter, - String entityIdentifier, String entityTitle, Map properties, +public record EntityDynamicMapping(UUID id, String identifier, String templateIdentifier, + String filter, String entityIdentifier, String entityTitle, Map properties, Map relations) { public EntityDynamicMapping { + if (isBlank(identifier)) { + throw new EntityDynamicMappingConfigurationException( + "Entity dynamic mapping identifier cannot be empty"); + } if (isBlank(templateIdentifier)) { throw new EntityDynamicMappingConfigurationException("Template identifier cannot be empty"); } 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 index 33b41f72..8a46efaf 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityDynamicMappingPort.java @@ -1,11 +1,27 @@ package com.decathlon.idp_core.domain.port; import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; public interface EntityDynamicMappingPort { List findByTemplateIdentifier(String templateIdentifier); + Boolean existsByTemplateIdentifier(String templateIdentifier); + + boolean existsByIdentifier(String identifier); + + Optional findByIdentifier(String identifier); + + EntityDynamicMapping save(EntityDynamicMapping entityDynamicMapping); + + Page findAll(Pageable pageable); + + void deleteByIdentifier(String identifier); + } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.java b/src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.java index 098d1831..7536578f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/WebhookTemplateMappingPort.java @@ -8,5 +8,7 @@ public interface WebhookTemplateMappingPort { List findByTemplateId(UUID templateId); + boolean existsByEntityMappingId(UUID id); + List findByEntityMappingId(UUID id); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingService.java new file mode 100644 index 00000000..0f3ac722 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingService.java @@ -0,0 +1,112 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import java.util.List; +import java.util.UUID; + +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingAlreadyInUseException; +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookTemplateMapping; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookTemplateMappingPort; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@Validated +@RequiredArgsConstructor +public class DynamicMappingService { + + private final EntityDynamicMappingPort entityDynamicMappingPort; + private final WebhookTemplateMappingPort webhookTemplateMappingPort; + private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService; + + @Transactional + public EntityDynamicMapping createEntityDynamicMapping( + EntityDynamicMapping entityDynamicMapping) { + validateIdentifierUniqueness(entityDynamicMapping.identifier()); + webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping); + return entityDynamicMappingPort.save(entityDynamicMapping); + } + + public Page getAllEntityDynamicMapping(Pageable pageable) { + return entityDynamicMappingPort.findAll(pageable); + } + + @Transactional + public void deleteEntityDynamicMapping(String entityDynamicMappingIdentifier) { + validateIdentifierExists(entityDynamicMappingIdentifier); + UUID dynamicMappingIdentifier = entityDynamicMappingPort + .findByIdentifier(entityDynamicMappingIdentifier).orElseThrow( + () -> new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier)) + .id(); + validateIsNotInUse(dynamicMappingIdentifier); + entityDynamicMappingPort.deleteByIdentifier(entityDynamicMappingIdentifier); + } + + private void validateIsNotInUse(UUID entityDynamicMappingId) { + if (webhookTemplateMappingPort.existsByEntityMappingId(entityDynamicMappingId)) { + List webhookTemplateMappingList = webhookTemplateMappingPort + .findByEntityMappingId(entityDynamicMappingId); + List webhookIdentifiers = webhookTemplateMappingList.stream() + .map(WebhookTemplateMapping::webhookConnector) + .filter(webhook -> webhook != null && webhook.identifier() != null) + .map(WebhookConnector::identifier).distinct().toList(); + throw new EntityDynamicMappingAlreadyInUseException(webhookIdentifiers); + } + } + + private void validateIdentifierExists(String entityDynamicMappingIdentifier) { + if (!entityDynamicMappingPort.existsByIdentifier(entityDynamicMappingIdentifier)) { + throw new EntityDynamicMappingNotFoundException(entityDynamicMappingIdentifier); + } + } + + /// Ensures no other dynamic mapping already uses the provided identifier. + /// + /// This enforces the `entity_dynamic_mapping_identifier_key` unique constraint + /// at the domain level, returning a meaningful conflict instead of letting the + /// database raise a low-level integrity violation. + /// + /// @param identifier the candidate mapping identifier + /// @throws EntityDynamicMappingAlreadyExistsException when the identifier is + /// already used + private void validateIdentifierUniqueness(String identifier) { + if (entityDynamicMappingPort.existsByIdentifier(identifier)) { + throw new EntityDynamicMappingAlreadyExistsException(identifier); + } + } + + public EntityDynamicMapping getEntityDynamicMapping(String identifier) { + return entityDynamicMappingPort.findByIdentifier(identifier) + .orElseThrow(() -> new EntityDynamicMappingNotFoundException(identifier)); + } + + @Transactional + public EntityDynamicMapping updateEntityDynamicMapping(String identifier, + @Valid EntityDynamicMapping entityDynamicMapping) { + EntityDynamicMapping existingMapping = getEntityDynamicMapping(identifier); + webhookConnectorMappingValidationService.validateMapping(entityDynamicMapping); + + EntityDynamicMapping mergedMapping = new EntityDynamicMapping(existingMapping.id(), + existingMapping.identifier(), entityDynamicMapping.templateIdentifier(), + entityDynamicMapping.filter(), entityDynamicMapping.entityIdentifier(), + entityDynamicMapping.entityTitle(), entityDynamicMapping.properties(), + entityDynamicMapping.relations()); + + return entityDynamicMappingPort.save(mergedMapping); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java index fff59085..c1bb356b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationService.java @@ -44,21 +44,21 @@ public void validateWebhookMapping(List mappings) { /// - Required properties and relations from the target template are present. /// - The mapping expression syntax is valid. /// - /// @param webhookMapping the mapping to validate - private void validateMapping(EntityDynamicMapping webhookMapping) { - String templateIdentifier = webhookMapping.templateIdentifier(); + /// @param entityDynamicMapping the mapping to validate + protected void validateMapping(EntityDynamicMapping entityDynamicMapping) { + String templateIdentifier = entityDynamicMapping.templateIdentifier(); entityTemplateValidationService.validateTemplateExists(templateIdentifier); EntityTemplate entityTemplate = entityTemplateService .getEntityTemplateByIdentifier(templateIdentifier); - entityTemplateValidationService.validatePropertiesExistInTemplate(webhookMapping.properties(), + entityTemplateValidationService.validatePropertiesExistInTemplate( + entityDynamicMapping.properties(), entityTemplate.propertiesDefinitions()); + validateRequiredPropertiesAreMapped(entityDynamicMapping.properties(), entityTemplate.propertiesDefinitions()); - validateRequiredPropertiesAreMapped(webhookMapping.properties(), - entityTemplate.propertiesDefinitions()); - entityTemplateValidationService - .validateRelationNameAlreadyExistInTemplate(webhookMapping.relations(), entityTemplate); - validateRequiredRelationDefinitionsAreMapped(webhookMapping.relations(), + entityTemplateValidationService.validateRelationNameAlreadyExistInTemplate( + entityDynamicMapping.relations(), entityTemplate); + validateRequiredRelationDefinitionsAreMapped(entityDynamicMapping.relations(), entityTemplate.relationsDefinitions()); - entityDynamicMapperValidator.validate(webhookMapping); + entityDynamicMapperValidator.validate(entityDynamicMapping); } /// Validates that all required relation definitions in the target template 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 index ab8953b2..3b7fc35f 100644 --- 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 @@ -1,5 +1,7 @@ package com.decathlon.idp_core.domain.service.webhook; +import java.util.List; + import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -8,8 +10,11 @@ import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingNotFoundException; import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; import lombok.RequiredArgsConstructor; @@ -23,6 +28,31 @@ public class WebhookConnectorService { private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; private final WebhookConnectorValidationService webhookConnectorValidationService; + private final EntityDynamicMappingPort entityDynamicMappingPort; + + /// Resolves a list of entity dynamic mapping identifiers into their existing + /// domain models. + /// + /// Each identifier is validated against the persisted dynamic mappings. This + /// guarantees a webhook connector can only reference mappings that were + /// previously created through the `/api/v1/inbound-dynamic-mapping` endpoint. + /// + /// @param mappingIdentifiers the referenced mapping identifiers (may be null or + /// empty) + /// @return the resolved mappings, in the same order as the provided identifiers + /// @throws EntityDynamicMappingNotFoundException when an identifier does not + /// match any existing mapping + public List resolveAndValidateMappings(List mappingIdentifiers) { + if (mappingIdentifiers == null || mappingIdentifiers.isEmpty()) { + return List.of(); + } + return mappingIdentifiers.stream().map(this::resolveMappingOrThrow).toList(); + } + + private EntityDynamicMapping resolveMappingOrThrow(String identifier) { + return entityDynamicMappingPort.findByIdentifier(identifier) + .orElseThrow(() -> new EntityDynamicMappingNotFoundException(identifier)); + } public WebhookConnector getWebhookConnector(String identifier) { return webhookConnectorRepositoryPort.findByIdentifier(identifier) @@ -30,7 +60,7 @@ public WebhookConnector getWebhookConnector(String identifier) { } @Transactional - public WebhookConnector createWebhookConnector(@Valid WebhookConnector connector) { + public WebhookConnector createWebhookConnector(WebhookConnector connector) { webhookConnectorValidationService.validateWebhookConnectorForCreation(connector); return webhookConnectorRepositoryPort.save(connector); } 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 index e208fb3c..a71890d9 100644 --- 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 @@ -8,6 +8,7 @@ import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorTitleAlreadyExistsException; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.domain.port.WebhookTemplateMappingPort; import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService; import lombok.RequiredArgsConstructor; @@ -21,13 +22,13 @@ public class WebhookConnectorValidationService { private final WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + private final WebhookTemplateMappingPort webhookTemplateMappingPort; private final EntityDynamicMappingValidationService webhookConnectorMappingValidationService; private final WebhookSecurityValidationService webhookSecurityValidationService; public void validateWebhookConnectorForCreation(WebhookConnector webhookConnector) { validateIdentifierUniqueness(webhookConnector.identifier()); validateTitleUniqueness(webhookConnector.title()); - validateMappingsIfPresent(webhookConnector); webhookSecurityValidationService.validateForCreation(webhookConnector.security()); } 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 26f06ddc..e366973e 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 @@ -15,6 +15,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template.EntityTemplateDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.EntityDynamicMappingDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; import io.swagger.v3.core.converter.ModelConverters; @@ -104,4 +105,14 @@ public WebhookConnectorPageResponse(List content, Pageable 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 7de5d604..0706dadb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -40,9 +40,6 @@ public class SwaggerDescription { public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; @@ -84,6 +81,24 @@ public class SwaggerDescription { public static final String ENDPOINT_PUT_WEBHOOK_CONNECTOR_SUMMARY = "Update an existing webhook connector by identifier"; public static final String ENDPOINT_PUT_WEBHOOK_CONNECTOR_DESCRIPTION = "Update the details of an existing webhook connector identified by its unique string identifier"; + public static final String ENDPOINT_POST_WEBHOOK_CONNECTOR_SUMMARY = "Create a new webhook connector configuration"; + public static final String ENDPOINT_POST_WEBHOOK_CONNECTOR_DESCRIPTION = "Creates a webhook connector configuration used by the generic inbound webhook endpoint"; + + public static final String ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Create entity dynamic mapping"; + public static final String ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Creates a new entity dynamic mapping used by the generic inbound webhook endpoint"; + + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY = "Get paginated entity dynamic mappings"; + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entity dynamic mappings with optional sorting"; + + public static final String ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Delete an entity dynamic mapping by identifier"; + public static final String ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Remove an entity dynamic from the system using its unique identifier"; + + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_SUMMARY = "Get an entity dynamic mapping by identifier"; + public static final String ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_DESCRIPTION = "Retrieve an entity dynamic mapping using its string identifier"; + + public static final String ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_SUMMARY = "Update an existing entity dynamic mapping by identifier"; + public static final String ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_DESCRIPTION = "Update the details of an existing entity dynamic mapping identified by its unique string identifier"; + /// API response description constants public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; @@ -114,6 +129,11 @@ public class SwaggerDescription { public static final String RESPONSE_WEBHOOK_CONNECTOR_NOT_FOUND_IDENTIFIER = "Webhook connector not found with the provided identifier"; public static final String RESPONSE_WEBHOOK_CONNECTOR_FOUND = "Webhook connector found"; public static final String RESPONSE_WEBHOOK_CONNECTOR_UPDATED = "Webhook connector updated successfully"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS = "Paginated entity dynamic mapping retrieved successfully"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED = "Entity dynamic mapping deleted successfully"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER = "Entity dynamic mapping not found with the provided identifier"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND = "Entity dynamic mapping found"; + public static final String RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED = "Entity dynamic mapping updated successfully"; // --- Schema (class) descriptions --- public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java new file mode 100644 index 00000000..697768d5 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java @@ -0,0 +1,113 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; +import static org.springframework.http.HttpStatus.*; + +import jakarta.validation.Valid; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.service.webhook.DynamicMappingService; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.EntityDynamicMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.DynamicMappingMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/entity-dynamic-mappings") +@Tag(name = "Entity dynamic mapping", description = "Operations related to entity dynamic mapping management") +public class EntityDynamicMappingController { + + private final DynamicMappingMapper dynamicMappingMapper; + private final DynamicMappingService dynamicMappingService; + + /// + /// + /// @param inboundWebhookMappingDtoIn entity dynamic mapping creation payload + /// @return created entity dynamic mapping response + @Operation(summary = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = "Entity dynamic mapping created") + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Invalid request payload") + @ApiResponse(responseCode = CONFLICT_CODE, description = "Identifier already exists") + @PostMapping + @ResponseStatus(CREATED) + public EntityDynamicMappingDtoOut createDynamicMapping( + @Valid @RequestBody EntityDynamicMappingCreateDtoIn inboundWebhookMappingDtoIn) { + EntityDynamicMapping entityDynamicMapping = dynamicMappingService + .createEntityDynamicMapping(dynamicMappingMapper.toDomain(inboundWebhookMappingDtoIn)); + return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping); + } + + @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = SwaggerConfiguration.EntityDynamicMappingPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @GetMapping + @ResponseStatus(OK) + public Page getEntityDynamicMappingPaginated( + @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { + return dynamicMappingService.getAllEntityDynamicMapping(pageable) + .map(dynamicMappingMapper::fromEntityMappingToDto); + } + + @Operation(summary = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DELETED) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ResponseStatus(NO_CONTENT) + @DeleteMapping("/{identifier}") + public void deleteTemplate(@PathVariable String identifier) { + dynamicMappingService.deleteEntityDynamicMapping(identifier); + } + + @Operation(summary = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_DYNAMIC_MAPPING_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_FOUND, content = { + @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{identifier}") + @ResponseStatus(OK) + public EntityDynamicMappingDtoOut getWebhookConnectorByIdentifier( + @PathVariable String identifier) { + EntityDynamicMapping entityDynamicMapping = dynamicMappingService + .getEntityDynamicMapping(identifier); + return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping); + } + + @Operation(summary = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_PUT_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_UPDATED, content = { + @Content(schema = @Schema(implementation = EntityDynamicMappingDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = CONFLICT_CODE, description = "", content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PutMapping("/{identifier}") + @ResponseStatus(OK) + public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable String identifier, + @Valid @RequestBody EntityDynamicMappingUpdateDtoIn entityDynamicMappingDtoIn) { + return dynamicMappingMapper + .fromEntityMappingToDto(dynamicMappingService.updateEntityDynamicMapping(identifier, + dynamicMappingMapper.toDomainForUpdate(identifier, entityDynamicMappingDtoIn))); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java index 30c206c8..339ebb8d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementController.java @@ -16,7 +16,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; -import com.decathlon.idp_core.infrastructure.adapters.api.mapper.webhook.InboundWebhookMapper; +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; @@ -41,16 +41,17 @@ public class InboundWebhookManagementController { /// /// @param request creation payload /// @return created connector response - @Operation(summary = "Create inbound webhook configuration", description = "Creates a webhook connector configuration used by the generic inbound webhook endpoint") + @Operation(summary = ENDPOINT_POST_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_POST_WEBHOOK_CONNECTOR_DESCRIPTION) @ApiResponse(responseCode = "201", description = "Webhook connector created") @ApiResponse(responseCode = "400", description = "Invalid request payload") @ApiResponse(responseCode = "409", description = "Identifier already exists") @PostMapping @ResponseStatus(CREATED) public InboundWebhookDtoOut createInboundWebhook( - @Valid @RequestBody InboundWebhookCreateDtoIn request) { + @Valid @RequestBody InboundWebhookCreateDtoIn request) {// remove jakarta WebhookConnector webhookConnector = webhookConnectorService - .createWebhookConnector(inboundWebhookMapper.toDomain(request)); + .createWebhookConnector(inboundWebhookMapper.toDomain(request, + webhookConnectorService.resolveAndValidateMappings(request.mappingIdentifiers()))); return inboundWebhookMapper.fromWebhookConnectorToDto(webhookConnector); } @@ -69,7 +70,7 @@ public Page getTemplatesPaginated( .map(inboundWebhookMapper::fromWebhookConnectorToDto); } - @Operation(summary = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_DESCRIPTION) + @Operation(summary = ENDPOINT_DELETE_WEBHOOK_CONNECTOR_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DYNAMIC_MAPPING_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))}) @@ -104,8 +105,10 @@ public InboundWebhookDtoOut getWebhookConnectorByIdentifier(@PathVariable String @ResponseStatus(OK) public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier, @Valid @RequestBody InboundWebhookCreateDtoIn request) { + var resolvedMappings = webhookConnectorService + .resolveAndValidateMappings(request.mappingIdentifiers()); return inboundWebhookMapper .fromWebhookConnectorToDto(webhookConnectorService.updateWebhookConnector(identifier, - inboundWebhookMapper.toDomainForUpdate(identifier, request))); + 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..315dd587 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingCreateDtoIn.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Mapping rule request for inbound webhook transformation. +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record EntityDynamicMappingCreateDtoIn( + @NotBlank(message = "Entity dynamic mapping identifier is mandatory") String identifier, + @NotBlank(message = "Webhook mapping template is mandatory") String template, + @NotBlank(message = "Webhook mapping filter is mandatory") String filter, + @NotNull(message = "Webhook mapping entity section is mandatory") @Valid InboundWebhookEntityMappingDtoIn entity) { + + /// Returns a CommonFields view for compatibility with the mapper. + public EntityDynamicMappingDtoInCommonFields commonFields() { + return new EntityDynamicMappingDtoInCommonFields(template, filter, entity); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java similarity index 60% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java index 4acd3607..41f5ff79 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookMappingDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingDtoInCommonFields.java @@ -4,8 +4,12 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -/// Mapping rule request for inbound webhook transformation. -public record InboundWebhookMappingDtoIn( +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Common fields for entity dynamic mapping requests (create and update). +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record EntityDynamicMappingDtoInCommonFields( @NotBlank(message = "Webhook mapping template is mandatory") String template, @NotBlank(message = "Webhook mapping filter is mandatory") String filter, @NotNull(message = "Webhook mapping entity section is mandatory") @Valid InboundWebhookEntityMappingDtoIn entity) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java new file mode 100644 index 00000000..6d28822d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDynamicMappingUpdateDtoIn.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Mapping rule request for inbound webhook update. +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record EntityDynamicMappingUpdateDtoIn( + @NotBlank(message = "Webhook mapping template is mandatory") String template, + @NotBlank(message = "Webhook mapping filter is mandatory") String filter, + @NotNull(message = "Webhook mapping entity section is mandatory") @Valid InboundWebhookEntityMappingDtoIn entity) { + + /// Returns a CommonFields view for compatibility with the mapper. + public EntityDynamicMappingDtoInCommonFields commonFields() { + return new EntityDynamicMappingDtoInCommonFields(template, filter, entity); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/InboundWebhookCreateDtoIn.java index de6c66c7..fdb4b5a6 100644 --- 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 @@ -7,14 +7,23 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + /// Request payload used to create an inbound webhook connector configuration. +/// +/// Mappings are no longer embedded in the connector payload. They are created +/// independently through the `/api/v1/inbound-dynamic-mapping` endpoint and +/// referenced here by their identifiers. Each referenced mapping existence is +/// validated in the domain layer before the connector is persisted. +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record InboundWebhookCreateDtoIn( @NotBlank(message = WEBHOOK_CONNECTOR_IDENTIFIER_MANDATORY) String identifier, @NotBlank(message = "Webhook title is mandatory") String title, String description, - boolean enabled, List<@Valid InboundWebhookMappingDtoIn> mappings, + boolean enabled, List mappingIdentifiers, @Valid InboundWebhookSecurityContractDtoIn security) { public InboundWebhookCreateDtoIn { - mappings = mappings != null ? List.copyOf(mappings) : null; + mappingIdentifiers = mappingIdentifiers != null ? List.copyOf(mappingIdentifiers) : List.of(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookMappingDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/EntityDynamicMappingDtoOut.java similarity index 67% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookMappingDtoOut.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/EntityDynamicMappingDtoOut.java index 4e22bc34..b1c29a13 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/InboundWebhookMappingDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/webhook/EntityDynamicMappingDtoOut.java @@ -1,6 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook; /// Mapping rule returned by the inbound webhook management API. -public record InboundWebhookMappingDtoOut(String template, String filter, +public record EntityDynamicMappingDtoOut(String identifier, String template, String filter, InboundWebhookEntityMappingDtoOut entity) { } 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 index 788c15f5..bda50125 100644 --- 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 @@ -4,7 +4,7 @@ /// Response payload for created inbound webhook connector. public record InboundWebhookDtoOut(String identifier, String title, String description, - boolean enabled, List mappings, + boolean enabled, List mappings, InboundWebhookSecurityDtoOut security) { public InboundWebhookDtoOut { 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 99c86ba3..0f5d5068 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -10,6 +10,7 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -24,7 +25,10 @@ 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_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingAlreadyInUseException; import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingConfigurationException; +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingNotFoundException; import com.decathlon.idp_core.domain.exception.entity_template.*; import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; @@ -492,6 +496,53 @@ public ResponseEntity handleWebhookConnectorNotFoundException( 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( diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/DynamicMappingMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/DynamicMappingMapper.java new file mode 100644 index 00000000..0aa6cfc6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/DynamicMappingMapper.java @@ -0,0 +1,63 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.EntityDynamicMappingDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookEntityMappingDtoOut; + +@Component +public class DynamicMappingMapper { + + public EntityDynamicMapping toDomain(EntityDynamicMappingCreateDtoIn mapping) { + // Map each DTO field explicitly to its matching domain field. The + // EntityDynamicMapping + // constructor order is (id, identifier, templateIdentifier, filter, + // entityIdentifier, + // entityTitle, properties, relations); keeping this alignment prevents the + // template + // identifier and the filter expression from being swapped. + var fields = mapping.commonFields(); + return new EntityDynamicMapping(null, // id (assigned by persistence layer) + mapping.identifier(), // identifier + fields.template(), // templateIdentifier + fields.filter(), // filter + fields.entity().identifier(), // entityIdentifier + fields.entity().title(), // entityTitle + safeMap(fields.entity().properties()), // properties + safeMap(fields.entity().relations())); // relations + } + + public EntityDynamicMappingDtoOut fromEntityMappingToDto(EntityDynamicMapping mapping) { + return new EntityDynamicMappingDtoOut(mapping.identifier(), mapping.templateIdentifier(), + mapping.filter(), + new InboundWebhookEntityMappingDtoOut(mapping.entityIdentifier(), mapping.entityTitle(), + Map.copyOf(mapping.properties()), Map.copyOf(mapping.relations()))); + } + + /// Converts an update DTO to domain model, using the identifier from the path. + /// + /// @param identifier the mapping identifier from the URL path + /// @param dto the update request body + /// @return the domain model for update + public EntityDynamicMapping toDomainForUpdate(String identifier, + EntityDynamicMappingUpdateDtoIn dto) { + var fields = dto.commonFields(); + return new EntityDynamicMapping(null, // id (will be set from existing entity) + identifier, // identifier from path + fields.template(), // templateIdentifier + fields.filter(), // filter + fields.entity().identifier(), // entityIdentifier + fields.entity().title(), // entityTitle + safeMap(fields.entity().properties()), // properties + safeMap(fields.entity().relations())); // relations + } + + private Map safeMap(Map input) { + return input == null ? Map.of() : Map.copyOf(input); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java similarity index 65% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java index 7e79feca..5d533592 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/connector/webhook/InboundWebhookMapper.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.mapper.webhook; +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook; import java.util.List; import java.util.Map; @@ -11,24 +11,31 @@ 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.InboundWebhookMappingDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.EntityDynamicMappingDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookEntityMappingDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookMappingDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.webhook.InboundWebhookSecurityDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.DynamicMappingMapper; + +import lombok.AllArgsConstructor; /// Maps inbound webhook API DTOs to domain models and back. @Component +@AllArgsConstructor public class InboundWebhookMapper { + private final DynamicMappingMapper dynamicMappingMapper; + /// Converts API input payload to the domain aggregate. /// /// @param dto inbound webhook creation request + /// @param resolvedMappings the existing dynamic mappings referenced by the + /// request, already resolved and validated by the domain layer /// @return domain webhook connector - public WebhookConnector toDomain(InboundWebhookCreateDtoIn dto) { + public WebhookConnector toDomain(InboundWebhookCreateDtoIn dto, + List resolvedMappings) { return new WebhookConnector(null, dto.identifier(), dto.title(), dto.description(), - dto.enabled(), safeMappings(dto.mappings()), toDomain(dto.security())); + dto.enabled(), safeMappings(resolvedMappings), toDomain(dto.security())); } /// Converts API update payload to domain aggregate using the path identifier as @@ -36,12 +43,13 @@ public WebhookConnector toDomain(InboundWebhookCreateDtoIn dto) { /// /// @param identifier webhook connector identifier from URL path /// @param dto inbound webhook update request body + /// @param resolvedMappings the existing dynamic mappings referenced by the + /// request, already resolved and validated by the domain layer /// @return domain webhook connector prepared for update - public WebhookConnector toDomainForUpdate(String identifier, InboundWebhookCreateDtoIn dto) { - var mappings = safeMappings(dto.mappings()); - var security = toDomain(dto.security()); + public WebhookConnector toDomainForUpdate(String identifier, InboundWebhookCreateDtoIn dto, + List resolvedMappings) { return new WebhookConnector(null, identifier, dto.title(), dto.description(), dto.enabled(), - mappings, security); + safeMappings(resolvedMappings), toDomain(dto.security())); } /// Converts domain aggregate to API response payload. @@ -49,31 +57,16 @@ public WebhookConnector toDomainForUpdate(String identifier, InboundWebhookCreat /// @param domain created webhook connector /// @return response DTO public InboundWebhookDtoOut fromWebhookConnectorToDto(WebhookConnector domain) { - var mappings = domain.mappings().stream().map(this::fromEntityMappingToDto).toList(); - var security = new InboundWebhookSecurityDtoOut(domain.security().type().name(), - domain.security().config()); + List mappings = domain.mappings().stream() + .map(dynamicMappingMapper::fromEntityMappingToDto).toList(); + InboundWebhookSecurityDtoOut security = new InboundWebhookSecurityDtoOut( + domain.security().type().name(), domain.security().config()); return new InboundWebhookDtoOut(domain.identifier(), domain.title(), domain.description(), domain.enabled(), mappings, security); } - private InboundWebhookMappingDtoOut fromEntityMappingToDto(EntityDynamicMapping mapping) { - return new InboundWebhookMappingDtoOut(mapping.templateIdentifier(), mapping.filter(), - new InboundWebhookEntityMappingDtoOut(mapping.entityIdentifier(), mapping.entityTitle(), - Map.copyOf(mapping.properties()), Map.copyOf(mapping.relations()))); - } - - private EntityDynamicMapping toDomain(InboundWebhookMappingDtoIn mapping) { - return new EntityDynamicMapping(null, mapping.template(), mapping.filter(), - mapping.entity().identifier(), mapping.entity().title(), - safeMap(mapping.entity().properties()), safeMap(mapping.entity().relations())); - } - - private List safeMappings( - java.util.List mappings) { - if (mappings == null || mappings.isEmpty()) { - return java.util.List.of(); - } - return mappings.stream().map(this::toDomain).toList(); + private List safeMappings(List mappings) { + return mappings == null ? List.of() : List.copyOf(mappings); } private WebhookSecurity toDomain(InboundWebhookSecurityContractDtoIn security) { 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 index 9687d71c..ddd93849 100644 --- 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 @@ -1,17 +1,21 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence; import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_mapping.EntityDynamicMappingJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; import lombok.RequiredArgsConstructor; -/// Persistence adapter for [EntityDynamicMapping] read operations. +/// Persistence adapter for [EntityDynamicMapping] read and write operations. @Component @RequiredArgsConstructor public class EntityDynamicMappingAdaptor implements EntityDynamicMappingPort { @@ -28,4 +32,35 @@ public List findByTemplateIdentifier(String identifier) { public Boolean existsByTemplateIdentifier(String templateIdentifier) { return jpaEntityDynamicMappingRepository.existsByTemplateIdentifier(templateIdentifier); } + + @Override + public boolean existsByIdentifier(String identifier) { + return jpaEntityDynamicMappingRepository.existsByIdentifier(identifier); + } + + @Override + public Optional findByIdentifier(String identifier) { + return jpaEntityDynamicMappingRepository.findByIdentifier(identifier) + .map(entityDynamicMappingPersistenceMapper::toDomain); + } + + @Override + public EntityDynamicMapping save(EntityDynamicMapping entityDynamicMapping) { + EntityDynamicMappingJpaEntity entityToPersist = entityDynamicMappingPersistenceMapper + .toJpa(entityDynamicMapping); + EntityDynamicMappingJpaEntity persistedEntity = jpaEntityDynamicMappingRepository + .save(entityToPersist); + return entityDynamicMappingPersistenceMapper.toDomain(persistedEntity); + } + + @Override + public Page findAll(Pageable pageable) { + return jpaEntityDynamicMappingRepository.findAll(pageable) + .map(entityDynamicMappingPersistenceMapper::toDomain); + } + + @Override + public void deleteByIdentifier(String identifier) { + jpaEntityDynamicMappingRepository.deleteByIdentifier(identifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresWebhookConnectorAdapter.java index 57999e9e..2abb2608 100644 --- 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 @@ -9,14 +9,17 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingNotFoundException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityDynamicMappingPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.WebhookConnectorPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookTemplateMappingJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityDynamicMappingRepository; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaWebhookConnectorRepository; @@ -41,6 +44,7 @@ public class PostgresWebhookConnectorAdapter implements WebhookConnectorReposito private final JpaWebhookTemplateMappingRepository jpaWebhookTemplateMappingRepository; private final JpaEntityDynamicMappingRepository jpaEntityDynamicMappingRepository; private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final EntityDynamicMappingPort entityDynamicMappingPort; private final WebhookConnectorPersistenceMapper mapper; private final EntityDynamicMappingPersistenceMapper mappingMapper; @@ -67,7 +71,8 @@ public boolean existsByTitle(String title) { @Override public WebhookConnector save(WebhookConnector connector) { - var savedConnector = jpaWebhookConnectorRepository.save(mapper.toJpa(connector)); + WebhookConnectorJpaEntity savedConnector = jpaWebhookConnectorRepository + .save(mapper.toJpa(connector)); persistTemplateMappings(savedConnector.getId(), connector); return loadConnectorWithMappings(savedConnector); } @@ -81,8 +86,7 @@ public void deleteByIdentifier(String identifier) { /// webhook_template_mapping table. /// Since WebhookConnector is a Record (immutable), we create a new instance /// with the loaded mappings. - private WebhookConnector loadConnectorWithMappings( - com.decathlon.idp_core.infrastructure.adapters.persistence.model.webhook.WebhookConnectorJpaEntity jpaEntity) { + private WebhookConnector loadConnectorWithMappings(WebhookConnectorJpaEntity jpaEntity) { WebhookConnector connectorWithoutMappings = mapper.toDomain(jpaEntity); List mappings = loadMappingsForWebhook(jpaEntity.getId()); @@ -107,7 +111,6 @@ private List loadMappingsForWebhook(UUID webhookId) { /// 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(); @@ -118,16 +121,22 @@ private void persistTemplateMappings(UUID webhookId, WebhookConnector connector) /// Persists a single EntityDynamicMapping and creates a /// WebhookTemplateMappingJpaEntity link. + /// + /// The mapping is expected to already exist because it is created through the + /// dedicated inbound dynamic mapping endpoint. This method only creates the + /// association row in webhook_template_mapping. private WebhookTemplateMappingJpaEntity persistAndCreateTemplateMapping(UUID webhookId, EntityDynamicMapping mapping) { - var savedMapping = jpaEntityDynamicMappingRepository.save(mappingMapper.toJpa(mapping)); - EntityTemplate entityTemplate = entityTemplateRepositoryPort .findByIdentifier(mapping.templateIdentifier()).orElseThrow( () -> new EntityTemplateNotFoundException("identifier", mapping.templateIdentifier())); + EntityDynamicMapping entityDynamicMapping = entityDynamicMappingPort + .findByIdentifier(mapping.identifier()) + .orElseThrow(() -> new EntityDynamicMappingNotFoundException(mapping.identifier())); + return WebhookTemplateMappingJpaEntity.builder().webhookId(webhookId) - .templateId(entityTemplate.id()).entityMappingId(savedMapping.getId()) + .templateId(entityTemplate.id()).entityMappingId(entityDynamicMapping.id()) .jsltFilter(mapping.filter()).build(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/WebhookTemplateMappingAdaptor.java index ea91933e..96d436a9 100644 --- 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 @@ -30,4 +30,15 @@ public List findByTemplateId(UUID templateId) { .map(webhookTemplateMappingPersistenceMapper::toDomain).toList(); } + @Override + public boolean existsByEntityMappingId(UUID id) { + return jpaWebhookTemplateMappingRepository.existsByEntityMappingId(id); + } + + @Override + public List findByEntityMappingId(UUID id) { + return jpaWebhookTemplateMappingRepository.findByEntityMappingId(id).stream() + .map(webhookTemplateMappingPersistenceMapper::toDomain).toList(); + } + } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityDynamicMappingPersistenceMapper.java index 541c0122..569307aa 100644 --- 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 @@ -16,7 +16,6 @@ @Mapper(componentModel = SPRING, uses = EntityDynamicMappingJsonbHelper.class) public interface EntityDynamicMappingPersistenceMapper { - @Mapping(target = "id", ignore = true) @Mapping(target = "properties", qualifiedByName = "jsonStringToMap") @Mapping(target = "relations", qualifiedByName = "jsonStringToMap") EntityDynamicMapping toDomain(EntityDynamicMappingJpaEntity jpa); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_mapping/EntityDynamicMappingJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_mapping/EntityDynamicMappingJpaEntity.java index ee1e6e3b..2e099d61 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_mapping/EntityDynamicMappingJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_mapping/EntityDynamicMappingJpaEntity.java @@ -27,6 +27,9 @@ public class EntityDynamicMappingJpaEntity { @GeneratedValue(strategy = GenerationType.UUID) private UUID id; + @Column(name = "identifier", nullable = false, unique = true) + private String identifier; + @Column(name = "template_identifier", nullable = false) private String templateIdentifier; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java index 72872f48..b1fcd49a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityDynamicMappingRepository.java @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.repository; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; @@ -17,5 +18,13 @@ public interface JpaEntityDynamicMappingRepository extends JpaRepository { List findByTemplateIdentifier(String templateIdentifier); + Boolean existsByTemplateIdentifier(String templateIdentifier); + + boolean existsByIdentifier(String identifier); + + Optional findByIdentifier(String identifier); + + void deleteByIdentifier(String identifier); + } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java index 05e5ca01..82e4312d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaWebhookTemplateMappingRepository.java @@ -17,5 +17,6 @@ public interface JpaWebhookTemplateMappingRepository /// Retrieves all mappings associated with a webhook connector. List findByWebhookId(UUID webhookId); List findByTemplateId(UUID templateId); - boolean existsByTemplateId(UUID templateId); + boolean existsByEntityMappingId(UUID entityMappingId); + List findByEntityMappingId(UUID entityMappingId); } diff --git a/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql b/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql index f57a8014..956ee84e 100644 --- a/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql +++ b/src/main/resources/db/migration/V4_2__create_entity_dynamic_mapping_table.sql @@ -3,6 +3,7 @@ CREATE TABLE entity_dynamic_mapping ( id UUID PRIMARY KEY, + identifier VARCHAR(255) UNIQUE NOT NULL, template_identifier VARCHAR(255) NOT NULL, filter VARCHAR(255) NOT NULL, entity_identifier VARCHAR(255) NOT NULL, diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java new file mode 100644 index 00000000..a4736c4c --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/DynamicMappingServiceTest.java @@ -0,0 +1,81 @@ +package com.decathlon.idp_core.domain.service.webhook; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingAlreadyExistsException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; +import com.decathlon.idp_core.domain.port.WebhookTemplateMappingPort; + +@DisplayName("DynamicMappingService Tests") +@ExtendWith(MockitoExtension.class) +class DynamicMappingServiceTest { + + @Mock + private EntityDynamicMappingPort entityDynamicMappingPort; + + @Mock + private WebhookTemplateMappingPort webhookTemplateMappingPort; + + @Mock + private EntityDynamicMappingValidationService entityDynamicMappingValidationService; + + private DynamicMappingService service; + + private static final String MAPPING_IDENTIFIER = "github_deployment_status mapping"; + + @BeforeEach + void setUp() { + service = new DynamicMappingService(entityDynamicMappingPort, webhookTemplateMappingPort, + entityDynamicMappingValidationService); + } + + @Test + @DisplayName("Should validate uniqueness, validate mapping then save") + void shouldValidateThenSave() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(false); + when(entityDynamicMappingPort.save(mapping)).thenReturn(mapping); + + EntityDynamicMapping result = service.createEntityDynamicMapping(mapping); + + assertThat(result).isEqualTo(mapping); + verify(entityDynamicMappingValidationService).validateMapping(mapping); + verify(entityDynamicMappingPort).save(mapping); + } + + @Test + @DisplayName("Should throw conflict and not save when identifier already exists") + void shouldThrowWhenIdentifierAlreadyExists() { + EntityDynamicMapping mapping = buildMapping(); + when(entityDynamicMappingPort.existsByIdentifier(MAPPING_IDENTIFIER)).thenReturn(true); + + assertThatThrownBy(() -> service.createEntityDynamicMapping(mapping)) + .isInstanceOf(EntityDynamicMappingAlreadyExistsException.class) + .hasMessageContaining(MAPPING_IDENTIFIER); + + verify(entityDynamicMappingValidationService, never()).validateMapping(any()); + verify(entityDynamicMappingPort, never()).save(any()); + } + + private EntityDynamicMapping buildMapping() { + return new EntityDynamicMapping(UUID.randomUUID(), MAPPING_IDENTIFIER, + "github_deployment_status", ".deployment_status != null", ".id", ".name", Map.of(), + Map.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java index ce40a70e..43396f19 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/EntityDynamicMappingValidationServiceTest.java @@ -54,9 +54,10 @@ void setUp() { } private EntityDynamicMapping buildMapping(String templateIdentifier, - Map properties, Map relations) { - return new EntityDynamicMapping(null, templateIdentifier, ".eventType == \"DEPLOYED\"", ".id", - ".name", properties, relations); + String entityDynamicMappingIdentifier, Map properties, + Map relations) { + return new EntityDynamicMapping(null, entityDynamicMappingIdentifier, templateIdentifier, + ".eventType == \"DEPLOYED\"", ".id", ".name", properties, relations); } private EntityTemplate buildEntityTemplate(List properties, @@ -83,8 +84,8 @@ class ValidateWebhookMappingHappyPathTests { void shouldPassWithValidMappingMatchingProperties() { PropertyDefinition property = buildProperty("environment", false); EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of("environment", ".env"), - Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("environment", ".env"), Map.of()); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); @@ -102,7 +103,8 @@ void shouldPassWithValidMappingMatchingProperties() { @DisplayName("Should pass with empty properties mapping and empty template properties") void shouldPassWithEmptyPropertiesAndEmptyTemplateProperties() { EntityTemplate template = buildEntityTemplate(List.of(), List.of()); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); @@ -121,7 +123,8 @@ void shouldPassWithEmptyPropertiesAndEmptyTemplateProperties() { void shouldPassWithNullRelations() { PropertyDefinition property = buildProperty("env", false); EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of("env", ".env"), null); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("env", ".env"), null); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); @@ -136,7 +139,8 @@ void shouldPassWithNullRelations() { void shouldPassWithEmptyRelationsAndNoRequiredRelations() { RelationDefinition relation = buildRelation("service", false); EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); @@ -153,11 +157,12 @@ void shouldPassWithEmptyRelationsAndNoRequiredRelations() { void shouldValidateEachMappingInList() { PropertyDefinition property1 = buildProperty("env", false); EntityTemplate template1 = buildEntityTemplate(List.of(property1), List.of()); - EntityDynamicMapping mapping1 = buildMapping("deployment", Map.of("env", ".env"), Map.of()); + EntityDynamicMapping mapping1 = buildMapping("deployment", "deployment_mapping", + Map.of("env", ".env"), Map.of()); PropertyDefinition property2 = buildProperty("version", false); EntityTemplate template2 = buildEntityTemplate(List.of(property2), List.of()); - EntityDynamicMapping mapping2 = new EntityDynamicMapping(null, "service", + EntityDynamicMapping mapping2 = new EntityDynamicMapping(null, "service_mapping", "service", ".type == \"SERVICE\"", ".id", ".name", Map.of("version", ".ver"), Map.of()); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); @@ -185,7 +190,7 @@ void shouldValidateEachMappingInList() { void shouldPassWithValidRelations() { RelationDefinition relation = buildRelation("owner", true); EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), Map.of("owner", ".owner")); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); @@ -208,7 +213,8 @@ class ValidateTemplateExistenceTests { @Test @DisplayName("Should throw EntityTemplateNotFoundException when template does not exist") void shouldThrowWhenTemplateDoesNotExist() { - EntityDynamicMapping mapping = buildMapping("unknown-template", Map.of(), Map.of()); + EntityDynamicMapping mapping = buildMapping("unknown-template", "unknown_mapping", Map.of(), + Map.of()); List mappings = List.of(mapping); doThrow(new EntityTemplateNotFoundException("identifier", "unknown-template")) @@ -230,7 +236,8 @@ class ValidatePropertiesTests { @DisplayName("Should throw WebhookTemplateHasNoPropertiesException when mapping has properties but template has none") void shouldThrowWhenMappingHasPropertiesButTemplateHasNone() { EntityTemplate template = buildEntityTemplate(List.of(), List.of()); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of("env", ".env"), Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("env", ".env"), Map.of()); var mappings = List.of(mapping); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); @@ -252,8 +259,8 @@ void shouldThrowWhenMappingHasPropertiesButTemplateHasNone() { void shouldThrowWhenPropertyNotFoundInTemplate() { PropertyDefinition property = buildProperty("environment", false); EntityTemplate template = buildEntityTemplate(List.of(property), List.of()); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of("unknown-prop", ".x"), - Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("unknown-prop", ".x"), Map.of()); var mappings = List.of(mapping); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); @@ -276,7 +283,8 @@ void shouldThrowWhenRequiredPropertyMissingFromMapping() { PropertyDefinition requiredProp = buildProperty("env", true); EntityTemplate template = buildEntityTemplate(List.of(requiredProp), List.of()); // mapping does not include the required property "env" - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); var mappings = List.of(mapping); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); @@ -295,8 +303,8 @@ void shouldThrowWhenRequiredPropertyMissingFromMapping() { void shouldPassWhenAllRequiredPropertiesMapped() { PropertyDefinition requiredProp = buildProperty("env", true); EntityTemplate template = buildEntityTemplate(List.of(requiredProp), List.of()); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of("env", ".environment"), - Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", + Map.of("env", ".environment"), Map.of()); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); @@ -315,7 +323,8 @@ void shouldThrowWhenMultipleRequiredPropertiesMissing() { PropertyDefinition prop1 = buildProperty("env", true); PropertyDefinition prop2 = buildProperty("version", true); EntityTemplate template = buildEntityTemplate(List.of(prop1, prop2), List.of()); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); var mappings = List.of(mapping); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); @@ -338,7 +347,7 @@ class ValidateRelationsTests { void shouldThrowWhenRelationNotFoundInTemplate() { RelationDefinition relation = buildRelation("owner", false); EntityTemplate template = buildEntityTemplate(List.of(), List.of(relation)); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), Map.of("unknown-relation", ".x")); var mappings = List.of(mapping); @@ -363,7 +372,8 @@ void shouldThrowWhenRelationNotFoundInTemplate() { void shouldThrowWhenRequiredRelationMissingFromMapping() { RelationDefinition requiredRelation = buildRelation("owner", true); EntityTemplate template = buildEntityTemplate(List.of(), List.of(requiredRelation)); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); var mappings = List.of(mapping); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); @@ -385,7 +395,7 @@ void shouldThrowWhenRequiredRelationMissingFromMapping() { void shouldPassWhenRequiredRelationMapped() { RelationDefinition requiredRelation = buildRelation("owner", true); EntityTemplate template = buildEntityTemplate(List.of(), List.of(requiredRelation)); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), Map.of("owner", ".ownerId")); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); @@ -406,7 +416,8 @@ void shouldThrowWhenMultipleRequiredRelationsMissing() { RelationDefinition rel1 = buildRelation("owner", true); RelationDefinition rel2 = buildRelation("team", true); EntityTemplate template = buildEntityTemplate(List.of(), List.of(rel1, rel2)); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); var mappings = List.of(mapping); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); @@ -425,7 +436,8 @@ void shouldThrowWhenMultipleRequiredRelationsMissing() { @DisplayName("Should skip relation validation when relations map is null") void shouldSkipRelationValidationWhenNull() { EntityTemplate template = buildEntityTemplate(List.of(), List.of()); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), null); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + null); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); @@ -446,7 +458,8 @@ class MapperValidatorDelegationTests { @DisplayName("Should delegate to entityDynamicMapperValidator after all domain checks pass") void shouldDelegateToMapperValidator() { EntityTemplate template = buildEntityTemplate(List.of(), List.of()); - EntityDynamicMapping mapping = buildMapping("deployment", Map.of(), Map.of()); + EntityDynamicMapping mapping = buildMapping("deployment", "deployment_mapping", Map.of(), + Map.of()); doNothing().when(entityTemplateValidationService).validateTemplateExists("deployment"); when(entityTemplateService.getEntityTemplateByIdentifier("deployment")).thenReturn(template); @@ -463,7 +476,8 @@ void shouldDelegateToMapperValidator() { @Test @DisplayName("Should NOT call entityDynamicMapperValidator when domain check throws") void shouldNotCallMapperValidatorWhenDomainCheckFails() { - EntityDynamicMapping mapping = buildMapping("bad-template", Map.of(), Map.of()); + EntityDynamicMapping mapping = buildMapping("bad-template", "deployment_mapping", Map.of(), + Map.of()); var mappings = List.of(mapping); doThrow(new EntityTemplateNotFoundException("identifier", "bad-template")) diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java index cfe724f2..17a0929e 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorServiceTest.java @@ -24,10 +24,13 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import com.decathlon.idp_core.domain.exception.entity_mapping.EntityDynamicMappingNotFoundException; import com.decathlon.idp_core.domain.exception.webhook.WebhookConnectorNotFoundException; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.port.EntityDynamicMappingPort; import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; @DisplayName("WebhookConnectorService Tests") @@ -40,12 +43,15 @@ class WebhookConnectorServiceTest { @Mock private WebhookConnectorValidationService webhookConnectorValidationService; + @Mock + private EntityDynamicMappingPort entityDynamicMappingPort; + private WebhookConnectorService service; @BeforeEach void setUp() { service = new WebhookConnectorService(webhookConnectorRepositoryPort, - webhookConnectorValidationService); + webhookConnectorValidationService, entityDynamicMappingPort); } @Nested @@ -286,4 +292,48 @@ private WebhookConnector buildWebhookConnector(UUID id, String identifier, Strin Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET")); return new WebhookConnector(id, identifier, title, description, enabled, List.of(), security); } + + @Nested + @DisplayName("resolveAndValidateMappings") + class ResolveAndValidateMappingsTests { + + @Test + @DisplayName("Should return empty list when identifiers are null or empty") + void shouldReturnEmptyWhenNoIdentifiers() { + assertThat(service.resolveAndValidateMappings(null)).isEmpty(); + assertThat(service.resolveAndValidateMappings(List.of())).isEmpty(); + verifyNoInteractions(entityDynamicMappingPort); + } + + @Test + @DisplayName("Should resolve each identifier to its existing mapping") + void shouldResolveExistingMappings() { + EntityDynamicMapping mapping = buildMapping("deployment-mapping"); + when(entityDynamicMappingPort.findByIdentifier("deployment-mapping")) + .thenReturn(Optional.of(mapping)); + + List result = service + .resolveAndValidateMappings(List.of("deployment-mapping")); + + assertThat(result).containsExactly(mapping); + } + + @Test + @DisplayName("Should throw EntityDynamicMappingNotFoundException when a mapping is missing") + void shouldThrowWhenMappingMissing() { + when(entityDynamicMappingPort.findByIdentifier("missing-mapping")) + .thenReturn(Optional.empty()); + + List mappings = List.of("missing-mapping"); + + assertThatThrownBy(() -> service.resolveAndValidateMappings(mappings)) + .isInstanceOf(EntityDynamicMappingNotFoundException.class) + .hasMessageContaining("missing-mapping"); + } + + private EntityDynamicMapping buildMapping(String identifier) { + return new EntityDynamicMapping(UUID.randomUUID(), identifier, "deployment", "true", ".id", + ".name", Map.of(), Map.of()); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java index 654d67b8..f1310541 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/webhook/WebhookConnectorValidationServiceTest.java @@ -23,6 +23,7 @@ import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; import com.decathlon.idp_core.domain.port.WebhookConnectorRepositoryPort; +import com.decathlon.idp_core.domain.port.WebhookTemplateMappingPort; import com.decathlon.idp_core.domain.service.webhook.security.WebhookSecurityValidationService; @DisplayName("WebhookConnectorValidationService Tests") @@ -32,6 +33,9 @@ class WebhookConnectorValidationServiceTest { @Mock private WebhookConnectorRepositoryPort webhookConnectorRepositoryPort; + @Mock + private WebhookTemplateMappingPort webhookTemplateMappingPort; + @Mock private WebhookSecurityValidationService webhookSecurityValidationService; @@ -43,7 +47,8 @@ class WebhookConnectorValidationServiceTest { @BeforeEach void setUp() { service = new WebhookConnectorValidationService(webhookConnectorRepositoryPort, - webhookConnectorMappingValidationService, webhookSecurityValidationService); + webhookTemplateMappingPort, webhookConnectorMappingValidationService, + webhookSecurityValidationService); } @Nested diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java new file mode 100644 index 00000000..39cb45ca --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java @@ -0,0 +1,403 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import com.decathlon.idp_core.AbstractIntegrationTest; + +import lombok.extern.slf4j.Slf4j; + +/// Integration tests for EntityDynamicMappingController, covering all CRUD operations on entity dynamic mappings. +/// +/// Covers the full HTTP contract (status codes, response shape, validation errors) +/// for all CRUD operations on entity dynamic mappings. +@DisplayName("EntityDynamicMappingController Integration Tests") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", + "/db/test/R__3_insert_webhhook_test_data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Slf4j +class EntityDynamicMappingControllerTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + private static final String MAPPING_PATH = "/api/v1/entity-dynamic-mappings"; + + /// Builds a valid entity dynamic mapping creation payload. + private String buildCreatePayload(String mappingIdentifier) { + return """ + { + "identifier": "%s", + "template": "microservice", + "filter": ".action == \\"pushed\\"", + "entity": { + "identifier": ".repository.full_name", + "title": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.email", + "environment": "\\"DEV\\"", + "version": ".ref", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """.formatted(mappingIdentifier); + } + + /// Builds a valid entity dynamic mapping update payload. + private String buildUpdatePayload() { + return """ + { + "template": "microservice", + "filter": ".action == \\"released\\"", + "entity": { + "identifier": ".release.tag_name", + "title": ".release.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".release.author.email", + "environment": "\\"PROD\\"", + "version": ".release.tag_name", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """; + } + + @Nested + @DisplayName("GET /api/v1/entity-dynamic-mappings - Get mappings paginated") + @Order(1) + class GetMappingsPaginatedTests { + + @Test + @DisplayName("Should return 401 without authentication") + void getMappings_401_without_user_token() throws Exception { + mockMvc.perform(get(MAPPING_PATH).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with page containing mappings from test data") + void getMappings_200_with_data() throws Exception { + mockMvc.perform(get(MAPPING_PATH).accept(APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page.total_elements").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("microservice-mapping")); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with custom pagination") + void getMappings_200_with_custom_pagination() throws Exception { + mockMvc + .perform(get(MAPPING_PATH).param("page", "0").param("size", "5") + .param("sort", "identifier,asc").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(0)); + } + } + + @Nested + @DisplayName("POST /api/v1/entity-dynamic-mappings - Create mapping") + @Order(2) + class PostMappingTests { + + @Test + @DisplayName("Should return 401 without authentication") + void postMapping_401_without_user_token() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(buildCreatePayload("new-mapping"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should create mapping and return 201") + void postMapping_201() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildCreatePayload("new-test-mapping"))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.identifier").value("new-test-mapping")) + .andExpect(jsonPath("$.template").value("microservice")) + .andExpect(jsonPath("$.filter").value(".action == \"pushed\"")) + .andExpect(jsonPath("$.entity.identifier").value(".repository.full_name")); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when identifier already exists") + void postMapping_409_identifier_already_exists() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(buildCreatePayload("microservice-mapping"))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString("microservice-mapping"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when mappingIdentifier is missing") + void postMapping_400_identifier_missing() throws Exception { + var payload = """ + { + "template": "microservice", + "filter": ".action == \\"pushed\\"", + "entity": { + "identifier": ".repository.full_name", + "title": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.email", + "environment": "\\"DEV\\"", + "version": ".ref", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("mapping identifier"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when template is missing") + void postMapping_400_template_missing() throws Exception { + var payload = """ + { + "identifier": "test-mapping", + "filter": ".action == \\"pushed\\"", + "entity": { + "identifier": ".repository.full_name", + "title": ".repository.name", + "properties": {}, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("template"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when template does not exist") + void postMapping_404_template_not_found() throws Exception { + var payload = """ + { + "identifier": "test-mapping", + "template": "non-existent-template", + "filter": ".action == \\"pushed\\"", + "entity": { + "identifier": ".repository.full_name", + "title": ".repository.name", + "properties": {}, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect( + jsonPath("$.error_description").value(containsString("non-existent-template"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when required template properties are missing") + void postMapping_400_missing_required_properties() throws Exception { + var payload = """ + { + "identifier": "test-mapping", + "template": "microservice", + "filter": ".action == \\"pushed\\"", + "entity": { + "identifier": ".repository.full_name", + "title": ".repository.name", + "properties": { + "applicationName": ".repository.name" + }, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString("missing required"))); + } + } + + @Nested + @DisplayName("GET /api/v1/entity-dynamic-mappings/{identifier} - Get by identifier") + @Order(3) + class GetMappingByIdentifierTests { + + @Test + @DisplayName("Should return 401 without authentication") + void getMapping_401_without_user_token() throws Exception { + mockMvc.perform(get(MAPPING_PATH + "/microservice-mapping").accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 with mapping details from test data") + void getMapping_200() throws Exception { + mockMvc.perform(get(MAPPING_PATH + "/microservice-mapping").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier").value("microservice-mapping")) + .andExpect(jsonPath("$.template").value("microservice")) + .andExpect(jsonPath("$.filter").value(".action == \"pushed\"")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping does not exist") + void getMapping_404_not_found() throws Exception { + mockMvc.perform(get(MAPPING_PATH + "/non-existent-mapping").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + } + + @Nested + @DisplayName("PUT /api/v1/entity-dynamic-mappings/{identifier} - Update mapping") + @Order(4) + class PutMappingTests { + + @Test + @DisplayName("Should return 401 without authentication") + void putMapping_401_without_user_token() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/microservice-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildUpdatePayload())).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping does not exist") + void putMapping_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/non-existent-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(buildUpdatePayload())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when template is missing in update") + void putMapping_400_template_missing() throws Exception { + var payload = """ + { + "filter": ".action == \\"released\\"", + "entity": { + "identifier": ".release.tag_name", + "title": ".release.name", + "properties": {}, + "relations": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.put(MAPPING_PATH + "/microservice-mapping") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")); + } + } + + @Nested + @DisplayName("DELETE /api/v1/entity-dynamic-mappings/{identifier} - Delete mapping") + @Order(5) + class DeleteMappingTests { + + @Test + @DisplayName("Should return 401 without authentication") + void deleteMapping_401_without_user_token() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/microservice-mapping") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should delete mapping and return 204") + void deleteMapping_204() throws Exception { + // First create a mapping to delete + mockMvc.perform(MockMvcRequestBuilders.post(MAPPING_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(buildCreatePayload("mapping-to-delete"))) + .andExpect(status().isCreated()); + + // Delete the mapping + mockMvc.perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/mapping-to-delete") + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + // Verify the mapping no longer exists + mockMvc.perform(get(MAPPING_PATH + "/mapping-to-delete").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")); + } + + @Test + @WithMockUser + @DisplayName("Should return 404 when mapping does not exist") + void deleteMapping_404_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/non-existent-mapping") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return 409 when mapping is in use by a webhook") + void deleteMapping_409_in_use() throws Exception { + // The microservice-mapping is used by github-dora-connector from test data + mockMvc + .perform(MockMvcRequestBuilders.delete(MAPPING_PATH + "/microservice-mapping") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString("in use"))); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java index 7ceefdcb..de7474a3 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MockMvc; @@ -26,8 +25,6 @@ /// for all CRUD operations on inbound webhook connectors. @DisplayName("InboundWebhookManagementController Integration Tests") @TestClassOrder(ClassOrderer.OrderAnnotation.class) -@Sql(statements = {"DELETE FROM webhook_template_mapping", "DELETE FROM webhook_connector", - "DELETE FROM entity_dynamic_mapping"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", "/db/test/R__3_insert_webhhook_test_data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Slf4j @@ -36,44 +33,58 @@ class InboundWebhookManagementControllerTest extends AbstractIntegrationTest { @Autowired private MockMvc mockMvc; - @Autowired - private JdbcTemplate jdbcTemplate; - private static final String WEBHOOK_PATH = "/api/v1/inbound-webhooks"; + private static final String ENTITY_DYNAMIC_MAPPING_PATH = "/api/v1/entity-dynamic-mappings"; private static final String JSON_PATH = "integration_test/json/webhook/v1/"; + /// Creates an entity dynamic mapping via API required for webhook connector + /// creation. + private void createEntityDynamicMapping(String mappingIdentifier) throws Exception { + var payload = """ + { + "identifier": "%s", + "template": "microservice", + "filter": ".action == \\"pushed\\"", + "entity": { + "identifier": ".repository.full_name", + "title": ".repository.name", + "properties": { + "applicationName": ".repository.name", + "ownerEmail": ".sender.email", + "environment": "\\"DEV\\"", + "version": ".ref", + "port": "8080", + "programmingLanguage": ".repository.language" + }, + "relations": {} + } + } + """.formatted(mappingIdentifier); + + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_DYNAMIC_MAPPING_PATH) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isCreated()); + } + private void createWebhookConnector(String identifier, String title) throws Exception { + // Create a unique entity dynamic mapping for this webhook connector + String uniqueMappingIdentifier = identifier + "-mapping"; + createEntityDynamicMapping(uniqueMappingIdentifier); + var payload = """ { "identifier": "%s", "title": "%s", "description": "test connector", "enabled": true, - "mappings": [ - { - "template": "microservice", - "filter": "true", - "entity": { - "identifier": ".id", - "title": ".name", - "properties": { - "applicationName": ".repository.name", - "ownerEmail": ".sender.login", - "environment": ".deployment.environment", - "version": ".deployment.sha", - "port": "8080", - "programmingLanguage": ".language" - }, - "relations": {} - } - } - ], + "mapping_identifiers": ["%s"], "security": { "type": "NONE", "config": {} } } - """.formatted(identifier, title); + """.formatted(identifier, title, uniqueMappingIdentifier); mockMvc .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) @@ -81,32 +92,14 @@ private void createWebhookConnector(String identifier, String title) throws Exce .andExpect(status().isCreated()); } - private String buildPutPayload(String title, boolean enabled) { + private String buildPutPayload(String title, boolean enabled, String mappingIdentifier) { return """ { "identifier": "ignored-by-put", "title": "%s", "description": "updated description", "enabled": %s, - "mappings": [ - { - "template": "microservice", - "filter": "true", - "entity": { - "identifier": ".id", - "title": ".name", - "properties": { - "applicationName": ".repository.name", - "ownerEmail": ".sender.login", - "environment": ".deployment.environment", - "version": ".deployment.sha", - "port": "8080", - "programmingLanguage": ".language" - }, - "relations": {} - } - } - ], + "mapping_identifiers": ["%s"], "security": { "type": "STATIC_TOKEN", "config": { @@ -115,7 +108,7 @@ private String buildPutPayload(String title, boolean enabled) { } } } - """.formatted(title, enabled); + """.formatted(title, enabled, mappingIdentifier); } @Nested @@ -172,8 +165,7 @@ void postWebhook_201() throws Exception { .andExpect(jsonPath("$.title").value("GitHub DORA Connector test")) .andExpect(jsonPath("$.security.type").value("HMAC_SHA256")); - assertWebhookTemplateMapping("github-dora-connector-test", "microservice", - ".action == \"deployment\""); + assertWebhookTemplateMapping("github-dora-connector-test"); } @Test @@ -221,11 +213,27 @@ void postWebhook_400_invalid_security_type() throws Exception { @Test @WithMockUser - @DisplayName("Should return 400 when JSLT expression is invalid") - void postWebhook_400_invalid_jslt() throws Exception { - var result = postBadRequestAndAssertContains(WEBHOOK_PATH, - JSON_PATH + "postWebhook_400_invalid_jslt.json", "Invalid webhook mapping configuration"); - assertNotNull(result); + @DisplayName("Should return 404 when mapping identifier does not exist") + void postWebhook_404_mapping_not_found() throws Exception { + var payload = """ + { + "identifier": "webhook-with-unknown-mapping", + "title": "Webhook With Unknown Mapping", + "description": "Should fail due to non-existent mapping", + "enabled": true, + "mapping_identifiers": ["non-existent-mapping"], + "security": { + "type": "NONE", + "config": {} + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").value(containsString("non-existent-mapping"))); } @Test @@ -237,7 +245,7 @@ void postWebhook_400_missing_security_config_fields() throws Exception { "identifier": "missing-security-fields", "title": "Missing Security Fields", "enabled": true, - "mappings": [], + "mapping_identifiers": [], "security": { "type": "HMAC_SHA256", "config": { @@ -313,7 +321,8 @@ void putWebhook_200() throws Exception { mockMvc .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-200") .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) - .content(buildPutPayload("Connector Put Updated Title", false))) + .content(buildPutPayload("Connector Put Updated Title", false, + "connector-put-200-mapping"))) .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("connector-put-200")) .andExpect(jsonPath("$.title").value("Connector Put Updated Title")) .andExpect(jsonPath("$.enabled").value(false)) @@ -324,7 +333,7 @@ void putWebhook_200() throws Exception { .andExpect(jsonPath("$.title").value("Connector Put Updated Title")) .andExpect(jsonPath("$.enabled").value(false)); - assertWebhookTemplateMapping("connector-put-200", "microservice", "true"); + assertWebhookTemplateMapping("connector-put-200"); } @Test @@ -337,7 +346,7 @@ void putWebhook_409_title_already_exists() throws Exception { mockMvc .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/connector-put-409-b") .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) - .content(buildPutPayload("Connector A Title", true))) + .content(buildPutPayload("Connector A Title", true, "connector-put-409-b-mapping"))) .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) .andExpect(jsonPath("$.error_description").value(containsString( "Webhook Connector already exist with the same name:Connector A Title"))); @@ -350,7 +359,7 @@ void putWebhook_404_not_found() throws Exception { mockMvc .perform(MockMvcRequestBuilders.put(WEBHOOK_PATH + "/non-existent-connector") .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) - .content(buildPutPayload("Updated Title", false))) + .content(buildPutPayload("Updated Title", false, "microservice-mapping"))) .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")) .andExpect(jsonPath("$.error_description").exists()); } @@ -377,10 +386,9 @@ void deleteWebhook_204() throws Exception { mockMvc.perform(MockMvcRequestBuilders.delete(WEBHOOK_PATH + "/connector-delete-204") .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + // Verify the webhook no longer exists mockMvc.perform(get(WEBHOOK_PATH + "/connector-delete-204").accept(APPLICATION_JSON)) .andExpect(status().isNotFound()).andExpect(jsonPath("$.error").value("NOT_FOUND")); - - assertWebhookTemplateMappingCount("connector-delete-204", 0L); } @Test @@ -395,31 +403,11 @@ void deleteWebhook_404_not_found() throws Exception { } } - private void assertWebhookTemplateMappingCount(String identifier, long expectedCount) { - Long count = jdbcTemplate.queryForObject(""" - SELECT COUNT(*) - FROM webhook_template_mapping wtm - JOIN webhook_connector wc ON wc.id = wtm.webhook_id - WHERE wc.identifier = ? - """, Long.class, identifier); - - org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount); - } - - private void assertWebhookTemplateMapping(String identifier, String templateIdentifier, - String filter) { - assertWebhookTemplateMappingCount(identifier, 1L); - - var row = jdbcTemplate.queryForMap(""" - SELECT et.identifier AS template_identifier, wtm.jslt_filter AS jslt_filter - FROM idp_core.webhook_template_mapping wtm - JOIN idp_core.webhook_connector wc ON wc.id = wtm.webhook_id - JOIN idp_core.entity_template et ON et.id = wtm.template_id - WHERE wc.identifier = ? - """, identifier); - - org.assertj.core.api.Assertions.assertThat(row).containsEntry("template_identifier", - templateIdentifier); - org.assertj.core.api.Assertions.assertThat(row).containsEntry("jslt_filter", filter); + /// Asserts that the webhook connector has at least one mapping via API. + private void assertWebhookTemplateMapping(String identifier) throws Exception { + mockMvc.perform(get(WEBHOOK_PATH + "/" + identifier).accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.mappings").isArray()) + .andExpect(jsonPath("$.mappings").isNotEmpty()) + .andExpect(jsonPath("$.mappings[0].filter").value(".action == \"pushed\"")); } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java index 07411231..176ed06c 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/webhook/InboundWebhookMapperTest.java @@ -4,34 +4,40 @@ import java.util.List; import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookEntityMappingDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookMappingDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.DynamicMappingMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook.InboundWebhookMapper; @DisplayName("InboundWebhookMapper Tests") class InboundWebhookMapperTest { - private final InboundWebhookMapper mapper = new InboundWebhookMapper(); + private final InboundWebhookMapper mapper = new InboundWebhookMapper(new DynamicMappingMapper()); + + private static EntityDynamicMapping resolvedMapping() { + return new EntityDynamicMapping(UUID.randomUUID(), "deployment-mapping", "deployment", + ".eventType == \"DEPLOYED\"", ".id", ".name", Map.of("environment", ".env"), + Map.of("service", ".service")); + } @Test @DisplayName("Should use path identifier for update mapping") void shouldUsePathIdentifierForUpdateMapping() { var request = new InboundWebhookCreateDtoIn("identifier_from_body", "GitHub DORA", - "Collect deployment events", true, - List.of(new InboundWebhookMappingDtoIn("deployment", ".eventType == \"DEPLOYED\"", - new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of("environment", ".env"), - Map.of("service", ".service")))), + "Collect deployment events", true, List.of("deployment-mapping"), new InboundWebhookSecurityContractDtoIn("HMAC_SHA256", Map.of("header_name", "X-Hub-Signature-256", "secret_alias", "MY_SECRET", "prefix", "sha256="))); - WebhookConnector domain = mapper.toDomainForUpdate("identifier_from_path", request); + WebhookConnector domain = mapper.toDomainForUpdate("identifier_from_path", request, + List.of(resolvedMapping())); assertThat(domain.id()).isNull(); assertThat(domain.identifier()).isEqualTo("identifier_from_path"); @@ -45,14 +51,14 @@ void shouldUsePathIdentifierForUpdateMapping() { @DisplayName("Should throw for unknown security type") void shouldThrowForUnknownSecurityType() { var request = new InboundWebhookCreateDtoIn("my-connector", "Custom Security", - "Uses custom security", true, - List.of(new InboundWebhookMappingDtoIn("deployment", "true", - new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of(), Map.of()))), + "Uses custom security", true, List.of("deployment-mapping"), new InboundWebhookSecurityContractDtoIn("CUSTOM_UNKNOWN_TYPE", Map.of("customKey", "customValue"))); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> mapper.toDomain(request)).isInstanceOf( - com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> mapper.toDomain(request, List.of(resolvedMapping()))) + .isInstanceOf( + com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException.class) .hasMessageContaining("CUSTOM_UNKNOWN_TYPE"); } @@ -60,12 +66,10 @@ void shouldThrowForUnknownSecurityType() { @DisplayName("Should map NONE security type explicitly") void shouldMapNoneSecurityTypeExplicitly() { var request = new InboundWebhookCreateDtoIn("my-connector", "No Auth", - "Webhook without authentication", true, - List.of(new InboundWebhookMappingDtoIn("deployment", "true", - new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of(), Map.of()))), + "Webhook without authentication", true, List.of("deployment-mapping"), new InboundWebhookSecurityContractDtoIn("NONE", Map.of())); - var domain = mapper.toDomain(request); + var domain = mapper.toDomain(request, List.of(resolvedMapping())); assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE); assertThat(domain.security().config()).isEmpty(); @@ -75,11 +79,9 @@ void shouldMapNoneSecurityTypeExplicitly() { @DisplayName("Should default to NONE when security section is missing") void shouldDefaultToNoneWhenSecurityIsMissing() { var request = new InboundWebhookCreateDtoIn("my-connector", "No Auth", - "Webhook without authentication", true, List.of(new InboundWebhookMappingDtoIn("deployment", - "true", new InboundWebhookEntityMappingDtoIn(".id", ".name", Map.of(), Map.of()))), - null); + "Webhook without authentication", true, List.of("deployment-mapping"), null); - var domain = mapper.toDomain(request); + var domain = mapper.toDomain(request, List.of(resolvedMapping())); assertThat(domain.security().type()).isEqualTo(WebhookSecurityType.NONE); assertThat(domain.security().config()).isEmpty(); diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index b44a75c0..976ec3c8 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -1,6 +1,10 @@ -- Sample data for IDP Core domain models - Enhanced with 10 templates -- Clear existing data (for repeatable migrations) +-- Order matters: respect foreign key constraints +DELETE FROM webhook_template_mapping; +DELETE FROM entity_dynamic_mapping; +DELETE FROM webhook_connector; DELETE FROM entity_template_relations_definitions; DELETE FROM entity_template_properties_definitions; DELETE FROM entity_template; diff --git a/src/test/resources/db/test/R__3_insert_webhhook_test_data.sql b/src/test/resources/db/test/R__3_insert_webhhook_test_data.sql index 45eb1e4e..184cac25 100644 --- a/src/test/resources/db/test/R__3_insert_webhhook_test_data.sql +++ b/src/test/resources/db/test/R__3_insert_webhhook_test_data.sql @@ -34,8 +34,9 @@ VALUES ('770e8400-e29b-41d4-a716-446655440003', '{"type": "NONE", "config": {}}'::jsonb); -- Dynamic Mapping for GitHub Connector -INSERT INTO entity_dynamic_mapping (id, template_identifier, filter, entity_identifier, entity_title, properties, relations) +INSERT INTO entity_dynamic_mapping (id, identifier, template_identifier, filter, entity_identifier, entity_title, properties, relations) VALUES ('880e8400-e29b-41d4-a716-446655440001', + 'microservice-mapping', 'microservice', '.action == "pushed"', '.repository.full_name', diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json index 680c7e9c..ef54609e 100644 --- a/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_201.json @@ -3,25 +3,7 @@ "title": "GitHub DORA Connector test", "description": "Collects deployment events from GitHub", "enabled": true, - "mappings": [ - { - "template": "microservice", - "filter": ".action == \"deployment\"", - "entity": { - "identifier": ".repository.name", - "title": ".repository.name", - "properties": { - "applicationName": ".repository.name", - "ownerEmail": ".sender.login + \"@example.com\"", - "environment": ".deployment.environment", - "version": ".deployment.sha", - "port": "8080", - "programmingLanguage": "\"Java\"" - }, - "relations": {} - } - } - ], + "mapping_identifiers": ["microservice-mapping"], "security": { "type": "HMAC_SHA256", "config": { diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json index 77d7fb0a..e7e44cd9 100644 --- a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_blank.json @@ -3,18 +3,7 @@ "title": "Blank Identifier", "description": "Should fail", "enabled": true, - "mappings": [ - { - "template": "microservice", - "filter": "true", - "entity": { - "identifier": ".id", - "title": ".name", - "properties": {}, - "relations": {} - } - } - ], + "mapping_identifiers": ["microservice-mapping"], "security": { "type": "NONE", "config": {} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json index 23929880..0520b5cb 100644 --- a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_identifier_missing.json @@ -2,18 +2,7 @@ "title": "Missing Identifier", "description": "Should fail", "enabled": true, - "mappings": [ - { - "template": "microservice", - "filter": "true", - "entity": { - "identifier": ".id", - "title": ".name", - "properties": {}, - "relations": {} - } - } - ], + "mapping_identifiers": ["microservice-mapping"], "security": { "type": "NONE", "config": {} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json index 2b021198..50531cbd 100644 --- a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_jslt.json @@ -3,25 +3,7 @@ "title": "Invalid JSLT Connector", "description": "Should fail due to invalid JSLT filter", "enabled": true, - "mappings": [ - { - "template": "microservice", - "filter": "@@@ INVALID JSLT @@@", - "entity": { - "identifier": ".id", - "title": ".name", - "properties": { - "applicationName": ".repository.name", - "ownerEmail": ".sender.login + \"@example.com\"", - "environment": ".deployment.environment", - "version": ".deployment.sha", - "port": "8080", - "programmingLanguage": "\"Java\"" - }, - "relations": {} - } - } - ], + "mapping_identifiers": ["microservice-mapping"], "security": { "type": "NONE", "config": {} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json index c58f6b92..7230b3b5 100644 --- a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_invalid_security_type.json @@ -3,25 +3,7 @@ "title": "Invalid Security Type", "description": "Should fail", "enabled": true, - "mappings": [ - { - "template": "microservice", - "filter": "true", - "entity": { - "identifier": ".id", - "title": ".name", - "properties": { - "applicationName": ".repository.name", - "ownerEmail": ".sender.login + \"@example.com\"", - "environment": ".deployment.environment", - "version": ".deployment.sha", - "port": "8080", - "programmingLanguage": "\"Java\"" - }, - "relations": {} - } - } - ], + "mapping_identifiers": ["microservice-mapping"], "security": { "type": "UNKNOWN_TYPE", "config": {} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json index a96b1d2f..2a7548e7 100644 --- a/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_400_mappings_empty.json @@ -3,7 +3,7 @@ "title": "No Mappings", "description": "Should fail", "enabled": true, - "mappings": [], + "mapping_identifiers": [], "security": { "type": "NONE", "config": {} diff --git a/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json b/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json index 430dc099..504ca871 100644 --- a/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json +++ b/src/test/resources/integration_test/json/webhook/v1/postWebhook_409_identifier_already_exists.json @@ -3,25 +3,7 @@ "title": "GitHub DORA Connector", "description": "Collects deployment events from GitHub", "enabled": true, - "mappings": [ - { - "template": "microservice", - "filter": ".action == \"deployment\"", - "entity": { - "identifier": ".repository.name", - "title": ".repository.name", - "properties": { - "applicationName": ".repository.name", - "ownerEmail": ".sender.login + \"@example.com\"", - "environment": ".deployment.environment", - "version": ".deployment.sha", - "port": "8080", - "programmingLanguage": "\"Java\"" - }, - "relations": {} - } - } - ], + "mapping_identifiers": ["microservice-mapping"], "security": { "type": "HMAC_SHA256", "config": { diff --git a/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json b/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json index 2d8c1a78..ee71a1a0 100644 --- a/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json +++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_200.json @@ -3,25 +3,7 @@ "title": "Updated Title", "description": "Updated description", "enabled": false, - "mappings": [ - { - "template": "microservice", - "filter": "true", - "entity": { - "identifier": ".id", - "title": ".name", - "properties": { - "applicationName": ".repository.name", - "ownerEmail": ".sender.login + \"@example.com\"", - "environment": ".deployment.environment", - "version": ".deployment.sha", - "port": "8080", - "programmingLanguage": "\"Java\"" - }, - "relations": {} - } - } - ], + "mapping_identifiers": ["microservice-mapping"], "security": { "type": "STATIC_TOKEN", "config": { diff --git a/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json b/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json index 08f7761b..aa07346c 100644 --- a/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json +++ b/src/test/resources/integration_test/json/webhook/v1/putWebhook_409_title_already_exists.json @@ -3,25 +3,7 @@ "title": "GitHub DORA Connector", "description": "Duplicate title", "enabled": true, - "mappings": [ - { - "template": "microservice", - "filter": "true", - "entity": { - "identifier": ".id", - "title": ".name", - "properties": { - "applicationName": ".repository.name", - "ownerEmail": ".sender.login + \"@example.com\"", - "environment": ".deployment.environment", - "version": ".deployment.sha", - "port": "8080", - "programmingLanguage": "\"Java\"" - }, - "relations": {} - } - } - ], + "mapping_identifiers": ["microservice-mapping"], "security": { "type": "NONE", "config": {}