Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ When the annotated event fires, the plugin posts the selected `inputs` fields as
}
```

When `inputs` is omitted, all scalar fields of the entity are included in the payload. Specify `inputs` explicitly to limit which fields are sent — useful to avoid exposing sensitive or large fields.
When `inputs` is omitted, all direct fields of the entity are included in the payload. Specify `inputs` explicitly to limit which fields are sent — useful to avoid exposing sensitive or large fields.

**Association fields** can be included using dot notation — the plugin issues a single expanded query to fetch the associated data:

Expand All @@ -272,7 +272,7 @@ This produces a payload with the leaf field name as the key:

> **Note:**
> 1. Only one level of association traversal is supported (`$self.author.name`). Deeper paths (`$self.author.address.city`) are skipped with a warning. This is a known limitation compared to the Node.js plugin — contributions welcome.
> 2. Association paths are *not* resolved for `CREATE` events. For these, the plugin uses the raw request payload (the data as submitted), so association fields like `$self.author.name` will be `null`. Use scalar FK fields (e.g. `$self.author_ID`) for `CREATE` triggers instead.
> 2. Association paths are *not* resolved for `CREATE` events. For these, the plugin uses the raw request payload (the data as submitted), so association fields like `$self.author.name` will be `null`. Use direct FK fields (e.g. `$self.author_ID`) for `CREATE` triggers instead.


### Conditions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,13 @@ public void afterAction(EventContext ctx) {
HttpMethod.valueOf(annotatable.getAnnotationValue(ANNOTATION_START + ".method", "POST"));

// Copy into a plain Map so InputExtractor can pull only the annotated fields from it
Map<String, Object> data = new HashMap<>();
ctx.keySet().forEach(k -> data.put(k, ctx.get(k)));
Map<String, Object> ctxData = new HashMap<>();
ctx.keySet().forEach(k -> ctxData.put(k, ctx.get(k)));

Object ifExpr = annotatable.getAnnotationValue(ANNOTATION_START + ".if", null);
if (!ConditionEvaluator.evaluate(ifExpr, data)) return;
if (!ConditionEvaluator.evaluate(ifExpr, ctxData)) return;

Map<String, Object> payload = InputExtractor.extract(inputs, data);
Map<String, Object> payload = InputExtractor.extract(inputs, ctxData);

if (props.isUseConsole()) {
log.info("[console-n8n-service]: delivering n8n webhook path={} synchronously", path);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ public N8nServiceHandler(
* Handles the {@code trigger} event. In console mode delivers synchronously via {@link
* N8nWebhookService#notify}; otherwise submits an outbox message for deferred HTTP delivery.
*
* @param ctx event context carrying {@code path} and {@code data} set by {@link
* @param ctx event context carrying {@code path} and {@code payload} set by {@link
* com.sap.cds.feature.n8n.services.N8nServiceImpl#trigger}
*/
@On(event = "trigger")
public void onTrigger(EventContext ctx) {
String path = (String) ctx.get("path");
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) ctx.get("data");
Map<String, Object> payload = (Map<String, Object>) ctx.get("payload");
HttpMethod method =
ctx.get("method") instanceof String m ? HttpMethod.valueOf(m) : HttpMethod.POST;
if (props.isUseConsole()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ public interface N8nService extends Service {
* method to {@code POST} — the n8n webhook default.
*
* @param path webhook path appended to {@code n8n.base-url}
* @param data payload sent as JSON in the request body
* @param payload payload sent as JSON in the request body
*/
default void trigger(String path, Map<String, Object> data) {
trigger(path, data, HttpMethod.POST);
default void trigger(String path, Map<String, Object> payload) {
trigger(path, payload, HttpMethod.POST);
}

/**
Expand All @@ -36,8 +36,8 @@ default void trigger(String path, Map<String, Object> data) {
* transaction commits, so a failing webhook never rolls back the business transaction.
*
* @param path webhook path appended to {@code n8n.base-url}
* @param data payload sent as JSON in the request body
* @param payload payload sent as JSON in the request body
* @param method HTTP method to use (e.g. {@code HttpMethod.POST}, {@code HttpMethod.PUT})
*/
void trigger(String path, Map<String, Object> data, HttpMethod method);
void trigger(String path, Map<String, Object> payload, HttpMethod method);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ public N8nServiceImpl(String name) {

/**
* Emits a {@code trigger} event on the CAP event bus carrying {@code path}, {@code method}, and
* {@code data}. {@link com.sap.cds.feature.n8n.handlers.N8nServiceHandler} listens for this event
* and forwards it to the outbox.
* {@code payload}. {@link com.sap.cds.feature.n8n.handlers.N8nServiceHandler} listens for this
* event and forwards it to the outbox.
*/
@Override
public void trigger(String path, Map<String, Object> data, HttpMethod method) {
public void trigger(String path, Map<String, Object> payload, HttpMethod method) {
// Create a named event so N8nServiceHandler can listen for it with @On(event = "trigger");
// null as the second argument means this event is not bound to any specific entity type
EventContext ctx = EventContext.create("trigger", null);
ctx.put("path", path);
ctx.put("data", data);
ctx.put("payload", payload);
ctx.put("method", method.name());
// emit() dispatches through the CAP event bus, invoking registered @On handlers
emit(ctx);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@ private InputExtractor() {}
/**
* Extracts only the fields named in {@code inputs} from {@code row}.
*
* <p>When {@code inputs} is empty, all scalar fields are returned — fields whose value is a
* <p>When {@code inputs} is empty, all direct fields are returned — fields whose value is a
* {@link Map} (to-one association/composition) or a {@link Collection} (to-many) are excluded.
*
* @param inputs list of CDS path expressions ({@code String} or {@code {"=": "..."}}) or struct
* forms ({@code {path: ..., as: ...}}); empty means "all scalar fields"
* forms ({@code {path: ..., as: ...}}); empty means "all direct fields"
* @param row the full entity row to extract from
* @return a map containing the requested fields, keyed by the leaf segment or {@code as} alias
*/
public static Map<String, Object> extract(List<Object> inputs, Map<String, Object> row) {
// when inputs are empty, send all scalar fields
// when inputs are empty, send all direct fields
if (inputs.isEmpty()) {
return getAllScalarFieldsByKey(row);
return getAllDirectFieldsByKey(row);
}
// else, when inputs are not empty
Map<String, Object> fieldInputsByKey = new LinkedHashMap<>();
Expand All @@ -54,7 +54,7 @@ public static Map<String, Object> extract(List<Object> inputs, Map<String, Objec
* Builds the CQL column list for a prefetch SELECT from {@code inputs} and the entity metadata.
*
* <p>Bare {@code $self} expands to all concrete non-association elements of {@code entity}. Plain
* scalar paths become {@link CQL#get} references; one-level association paths become {@link
* direct paths become {@link CQL#get} references; one-level association paths become {@link
* com.sap.cds.ql.CQL#to(String) CQL.to(...).expand(...)} expands. Deep paths (more than one dot
* after stripping the {@code $self.} prefix) are skipped. Returns an empty list when {@code
* inputs} is empty, which the caller interprets as "no column restriction".
Expand All @@ -73,7 +73,8 @@ public static List<Selectable> extractSelectables(List<Object> inputs, CdsStruct
if (path == null) return Stream.empty();
int dot = path.indexOf('.');
if (dot < 0) {
// scalar already covered by bare $self expansion — skip to avoid duplicate column
// direct field already covered by bare $self expansion — skip to avoid duplicate
// column
if (hasBareSelf) return Stream.empty();
return Stream.of(CQL.<Object>get(path));
}
Expand All @@ -100,23 +101,23 @@ public static boolean isBareSelf(Object input) {
return BARE_SELF.equals(resolvePath(input));
}

private static Map<String, Object> getAllScalarFieldsByKey(Map<String, Object> row) {
Map<String, Object> scalarFieldsByKey = new LinkedHashMap<>();
private static Map<String, Object> getAllDirectFieldsByKey(Map<String, Object> row) {
Map<String, Object> directFieldsByKey = new LinkedHashMap<>();
row.forEach(
(key, fieldValue) -> {
if (!(fieldValue instanceof Map) && !(fieldValue instanceof Collection<?>))
scalarFieldsByKey.put(key, fieldValue);
directFieldsByKey.put(key, fieldValue);
});
return scalarFieldsByKey;
return directFieldsByKey;
}

private static void putInput(
Object input, Map<String, Object> row, Map<String, Object> fieldInputsByKey) {
String path = resolvePath(input);
if (path != null) {
if (BARE_SELF.equals(path)) {
// bare $self with no field — expand all scalar fields
fieldInputsByKey.putAll(getAllScalarFieldsByKey(row));
// bare $self with no field — expand all direct fields
fieldInputsByKey.putAll(getAllDirectFieldsByKey(row));
return;
}
String field = stripSelfPrefix(path);
Expand Down Expand Up @@ -164,12 +165,12 @@ private static String leafKey(String path) {
* @return the value at the path, or {@code null} if any segment is missing
*/
@SuppressWarnings("unchecked")
private static Object getNestedValue(String path, Map<String, Object> data) {
private static Object getNestedValue(String path, Map<String, Object> nestedValuesByKey) {
int dot = path.indexOf('.');
if (dot < 0) {
return data.get(path);
return nestedValuesByKey.get(path);
}
Object nested = data.get(path.substring(0, dot));
Object nested = nestedValuesByKey.get(path.substring(0, dot));
return nested instanceof Map
? getNestedValue(path.substring(dot + 1), (Map<String, Object>) nested)
: null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -874,7 +874,7 @@ private Map<String, Object> bareSelfPrefetchRow() {
}

@Test
void onDelete_assocPathBeforeBareSelf_payloadContainsAllScalarsAndAssocField() {
void onDelete_assocPathBeforeBareSelf_payloadContainsAllDirectsAndAssocField() {
N8nHandler handlerForDelete = handlerWithFixedBareSelfRow();

when(deleteCtx.getTarget()).thenReturn(entity);
Expand Down Expand Up @@ -952,7 +952,7 @@ protected Map<String, Object> fetchEntityRow(
}

@Test
void onDelete_bareSelfMixedWithAssocPath_payloadContainsAllScalarsAndAssocField() {
void onDelete_bareSelfMixedWithAssocPath_payloadContainsAllDirectsAndAssocField() {
N8nHandler handlerForDelete = handlerWithFixedBareSelfRow();

when(deleteCtx.getTarget()).thenReturn(entity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ void constructor_nullOutbox_consoleMode_doesNotThrow() {
void onTrigger_submitsOutboxMessageAndSetsCompleted() {
Map<String, Object> payload = Map.of("ID", "42", "title", "Dune");
when(ctx.get("path")).thenReturn("book-created");
when(ctx.get("data")).thenReturn(payload);
when(ctx.get("payload")).thenReturn(payload);
when(ctx.get("method")).thenReturn("POST");

handler.onTrigger(ctx);
Expand All @@ -78,7 +78,7 @@ void onTrigger_consoleMode_callsWebhookDirectlyWithoutOutbox() {
Map<String, Object> payload = Map.of("ID", "42", "title", "Dune");
when(props.isUseConsole()).thenReturn(true);
when(ctx.get("path")).thenReturn("book-created");
when(ctx.get("data")).thenReturn(payload);
when(ctx.get("payload")).thenReturn(payload);
when(ctx.get("method")).thenReturn("POST");

handler.onTrigger(ctx);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ void extract_selfPrefixIsStripped() {
}

@Test
void extract_bareSelf_expandsAllScalarFields() {
void extract_bareSelf_expandsAllDirectFields() {
Map<String, Object> row = Map.of("ID", "1", "title", "Dune", "stock", 42);
Map<String, Object> result = InputExtractor.extract(List.of(Map.of("=", "$self")), row);
assertThat(result)
Expand All @@ -50,7 +50,7 @@ void extract_bareSelf_expandsAllScalarFields() {
}

@Test
void extract_bareSelfMixedWithAssocPath_includesAllScalarsAndAssocField() {
void extract_bareSelfMixedWithAssocPath_includesAllDirectsAndAssocField() {
Map<String, Object> row = new java.util.LinkedHashMap<>();
row.put("ID", "1");
row.put("title", "Dune");
Expand Down Expand Up @@ -125,7 +125,7 @@ void extract_missingField_returnsNull() {
}

@Test
void extract_emptyInputs_returnsAllScalarFields() {
void extract_emptyInputs_returnsAllDirectFields() {
Map<String, Object> row = Map.of("ID", "1", "title", "Dune", "stock", 42);
Map<String, Object> result = InputExtractor.extract(List.of(), row);
assertThat(result)
Expand Down Expand Up @@ -182,7 +182,7 @@ void isBareSelf_falseForFieldPath() {
}

@Test
void extractSelectables_bareSelf_emitsAllScalarColumns() {
void extractSelectables_bareSelf_emitsAllDirectColumns() {
CdsEntity entity = mock(CdsEntity.class);
CdsElement id = mock(CdsElement.class);
CdsElement title = mock(CdsElement.class);
Expand All @@ -197,7 +197,7 @@ void extractSelectables_bareSelf_emitsAllScalarColumns() {
}

@Test
void extractSelectables_bareSelfMixedWithScalarPath_noDuplicateColumns() {
void extractSelectables_bareSelfMixedWithDirectPath_noDuplicateColumns() {
CdsEntity entity = mock(CdsEntity.class);
CdsElement id = mock(CdsElement.class);
CdsElement title = mock(CdsElement.class);
Expand Down
Loading