Skip to content
Open
62 changes: 35 additions & 27 deletions docs/src/concepts/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ title: Webhooks
description: Understand webhook connectors, security strategies, and dynamic mappings in IDP-Core
---

Webhooks let external systems push JSON events to the Internal Developer Platform through a generic HTTP endpoint. You configure a webhook connector at runtime, choose a security strategy, and define mappings that translate incoming payloads into entity data with JSLT expressions.
Webhooks let external systems push JSON events to the Internal Developer Platform through a generic HTTP endpoint. You
configure a webhook connector at runtime, choose a security strategy, and define mappings that translate incoming
payloads into entity data with JSLT expressions.

## Overview

Expand All @@ -16,19 +18,19 @@ A webhook connector combines three concerns:
```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]
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 |
| `name` | String | Human-readable name |
| `description` | String | Optional explanation of the connector purpose |
Expand Down Expand Up @@ -58,16 +60,18 @@ A webhook connector is the runtime configuration stored by IDP-Core for one inbo

## 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.
Each connector contains at least one dynamic mapping[cite: 12]. 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[cite: 12].

| Field | Type | Description |
| ------------- | ------ | --------------------------------------------------------------------------- |
| `template` | String | Identifier of the target Entity Template |
| `identifier` | String | Stable and unique key for this specific mapping |
| `name` | String | Human-readable name of the mapping |
| `description` | String | Optional explanation of the mapping purpose |
| `filter` | String | JSLT boolean expression to evaluate if the payload should be processed |
| `entity` | Object | JSLT projections defining how to map the payload to the entity's attributes |
| Field | Type | Description |
|---------------|--------|--------------------------------------------------------------------------------------------------------------------------------|
| `template` | String | Identifier of the target Entity Template[cite: 12] |
| `identifier` | String | Stable and unique key for this specific mapping[cite: 12] |
| `name` | String | Human-readable name of the mapping[cite: 12] |
| `description` | String | Optional explanation of the mapping purpose[cite: 12] |
| `action` | String | **Required.** The mutation logic applied to the entity (`UPDATE_ENTITY`, `UPDATE_PROPERTIES`, `UPDATE_RELATIONS`, or `DELETE`) |
| `filter` | String | JSLT boolean expression to evaluate if the payload should be processed[cite: 12] |
| `entity` | Object | JSLT projections defining how to map the payload to the entity's attributes[cite: 12] |

### Dynamic Mapping Example

Expand All @@ -77,6 +81,7 @@ Each connector contains at least one dynamic mapping. A mapping targets one Enti
"identifier": "mapping-github",
"name": "mapping github",
"description": "mapping github description",
"action": "UPDATE_ENTITY",
"filter": ".repository != null",
"entity": {
"identifier": "replace(.repository.name, \" \", \"-\")",
Expand Down Expand Up @@ -108,10 +113,11 @@ This validation keeps the connector configuration aligned with the current data

## Security Strategies

Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests again at runtime.
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 |
Expand All @@ -121,7 +127,8 @@ Each connector declares one security type. IDP-Core validates the configuration
> [!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.
> `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

Expand Down Expand Up @@ -197,15 +204,16 @@ The request flow is:

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 |
| 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.
This separation keeps configuration management under versioned API routes while the event ingestion endpoint stays
simple for external systems.

## When to Use Webhooks

Expand Down
25 changes: 25 additions & 0 deletions docs/src/static/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,13 @@ components:
type: string
filter:
type: string
action:
type: string
enum:
- UPDATE_ENTITY
- UPDATE_PROPERTIES
- UPDATE_RELATIONS
- DELETE
name:
type: string
description:
Expand Down Expand Up @@ -1151,6 +1158,13 @@ components:
filter:
type: string
minLength: 1
action:
type: string
enum:
- UPDATE_ENTITY
- UPDATE_PROPERTIES
- UPDATE_RELATIONS
- DELETE
name:
type: string
minLength: 1
Expand All @@ -1159,6 +1173,7 @@ components:
entity:
$ref: '#/components/schemas/EntityMappingDtoIn'
required:
- action
- entity
- entity_template_identifier
- filter
Expand Down Expand Up @@ -1537,6 +1552,14 @@ components:
minLength: 1
filter:
type: string
minLength: 1
action:
type: string
enum:
- UPDATE_ENTITY
- UPDATE_PROPERTIES
- UPDATE_RELATIONS
- DELETE
name:
type: string
minLength: 1
Expand All @@ -1545,8 +1568,10 @@ components:
entity:
$ref: '#/components/schemas/EntityMappingDtoIn'
required:
- action
- entity
- entity_template_identifier
- filter
- identifier
- name
EntityDynamicMappingDryRunDtoIn:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ public static String minMaxConstraintViolated(String constraint) {
public static final String ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY = "Webhook mapping filter is mandatory";
public static final String ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY = "Entity dynamic mapping identifier is mandatory";
public static final String ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY = "Entity dynamic mapping name is mandatory";
public static final String ENTITY_DYNAMIC_MAPPING_ACTION_MANDATORY = "Entity dynamic mapping action is mandatory";
public static final String ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template Identifier is mandatory";
public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY = "Dynamic mapping entity section is mandatory";
public static final String ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY = "Entity name is mandatory";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package com.decathlon.idp_core.domain.model.entity_mapping;

import static com.decathlon.idp_core.domain.constant.ValidationMessages.*;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ENTITY_IDENTIFIER_MANDATORY;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_ENTITY_NAME_MANDATORY;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY;

import java.util.List;
import java.util.Map;
Expand All @@ -16,8 +21,8 @@
/// Note: The technical ID is managed purely at the infrastructure layer
/// (persisted in entity_dynamic_mapping table) and is NOT part of the domain model.
public record EntityDynamicMapping(UUID id, String identifier, String entityTemplateIdentifier,
String filter, String name, String description, String entityIdentifier, String entityName,
Map<String, String> properties, List<RelationMapping> relations) {
String filter, MappingAction action, String name, String description, String entityIdentifier,
String entityName, Map<String, String> properties, List<RelationMapping> relations) {

public EntityDynamicMapping {
if (isBlank(identifier)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.decathlon.idp_core.domain.model.entity_mapping;

public enum MappingAction {
UPDATE_ENTITY, UPDATE_PROPERTIES, UPDATE_RELATIONS, DELETE
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,58 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier,
return existingEntity;
}

/// Partially updates an existing entity identified by template and entity
/// identifiers.
///
/// **Contract:** Validates template existence, then entity existence within the
/// template scope. Merges the existing entity with the provided patch data,
/// validates the merged entity against the template constraints, and persists
/// changes if there are actual modifications.
///
/// @param templateIdentifier template identifier from the request path
/// @param entityIdentifier entity identifier from the request path
/// @param patchData validated entity patch payload
/// @return persisted updated entity
/// @throws EntityTemplateNotFoundException when template doesn't exist
/// @throws EntityNotFoundException when target entity doesn't exist
/// @throws EntityValidationException when payload violates
/// template constraints
@Transactional
public Entity patchEntity(String templateIdentifier, String entityIdentifier,
@Valid Entity patchData) {

EntityTemplate template = entityTemplateService
.getEntityTemplateByIdentifier(templateIdentifier);
Entity existingEntity = retrieveEntity(templateIdentifier, entityIdentifier);

Map<String, Property> mergedProperties = existingEntity.properties().stream()
.collect(Collectors.toMap(Property::name, p -> p));

if (patchData.properties() != null) {
patchData.properties().forEach(p -> mergedProperties.put(p.name(), p));
}

Map<String, Relation> mergedRelations = existingEntity.relations().stream()
.collect(Collectors.toMap(Relation::name, r -> r));

if (patchData.relations() != null) {
patchData.relations().forEach(r -> mergedRelations.put(r.name(), r));
}

Entity entityToSave = new Entity(existingEntity.id(), templateIdentifier,
patchData.name() != null ? patchData.name() : existingEntity.name(), entityIdentifier,
new ArrayList<>(mergedProperties.values()), new ArrayList<>(mergedRelations.values()));

Entity updatedEntity = enrichRelationsWithTargetTemplates(entityToSave, template);
entityValidationService.validateForUpdate(updatedEntity, template);

if (hasEntityChanged(existingEntity, updatedEntity)) {
return entityRepository.save(updatedEntity);
}

return existingEntity;
}

/// Detects if an entity has actually changed by comparing its core fields,
/// properties, and relations with the incoming entity.
///
Expand Down Expand Up @@ -589,6 +641,18 @@ public PaginatedResult<Entity> searchEntities(SearchFilterNode filter, String qu
return entityRepository.search(filter, query, paginationCriteria);
}

/// Checks whether an entity exists for the given template and entity identifier
/// without raising an exception.
///
/// @param templateIdentifier business identifier of the entity template
/// @param entityIdentifier unique business identifier of the entity
/// @return true if entity exists, false otherwise
@Transactional(readOnly = true)
public boolean entityExists(String templateIdentifier, String entityIdentifier) {
return entityRepository
.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier).isPresent();
}

private void validatePaginationCriteria(PaginationCriteria criteria) {
if (criteria.page() < 0) {
throw new InvalidSearchQueryException(ValidationMessages.SEARCH_PAGE_INVALID);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public EntityDynamicMapping updateEntityDynamicMapping(String identifier,

EntityDynamicMapping mergedMapping = new EntityDynamicMapping(existingMapping.id(),
existingMapping.identifier(), entityDynamicMapping.entityTemplateIdentifier(),
entityDynamicMapping.filter(), entityDynamicMapping.name(),
entityDynamicMapping.filter(), entityDynamicMapping.action(), entityDynamicMapping.name(),
entityDynamicMapping.description(), entityDynamicMapping.entityIdentifier(),
entityDynamicMapping.entityName(), entityDynamicMapping.properties(),
entityDynamicMapping.relations());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import com.decathlon.idp_core.domain.model.entity_mapping.MappingAction;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

Expand All @@ -15,13 +16,14 @@ public record EntityDynamicMappingCreateDtoIn(

@NotBlank(message = ENTITY_DYNAMIC_MAPPING_IDENTIFIER_MANDATORY) String identifier,
@NotBlank(message = ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier,
String filter, @NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name,
String description,
@NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter,
@NotNull(message = ENTITY_DYNAMIC_MAPPING_ACTION_MANDATORY) MappingAction action,
@NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description,
@NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid EntityMappingDtoIn entity) {

/// Returns a CommonFields view for compatibility with the mapper.
public EntityDynamicMappingDtoInCommonFields commonFields() {
return new EntityDynamicMappingDtoInCommonFields(entityTemplateIdentifier, filter, name,
return new EntityDynamicMappingDtoInCommonFields(entityTemplateIdentifier, filter, action, name,
description, entity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import com.decathlon.idp_core.domain.model.entity_mapping.MappingAction;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

Expand All @@ -14,6 +15,7 @@
public record EntityDynamicMappingDtoInCommonFields(
@NotBlank(message = ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY) String entityTemplateIdentifier,
@NotBlank(message = ENTITY_DYNAMIC_MAPPING_FILTER_MANDATORY) String filter,
@NotNull(message = ENTITY_DYNAMIC_MAPPING_ACTION_MANDATORY) MappingAction action,
@NotBlank(message = ENTITY_DYNAMIC_MAPPING_NAME_MANDATORY) String name, String description,
@NotNull(message = ENTITY_DYNAMIC_MAPPING_ENTITY_MANDATORY) @Valid EntityMappingDtoIn entity) {
}
Loading
Loading