Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.command.core;

/**
* Implemented by commands whose successful execution must notify the webhooks registered for its event, as the legacy
* command processing does for every command. The names are the entity and action a hook subscribes to, e.g.
* {@code DOCUMENT} and {@code CREATE}.
*/
public interface HookableCommand {

String hookEntityName();
Comment thread
IOhacker marked this conversation as resolved.

String hookActionName();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.hooks.command;

import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHookAfter;
import org.apache.fineract.command.core.HookableCommand;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.hooks.event.HookEvent;
import org.apache.fineract.infrastructure.hooks.event.HookEventSource;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;

/**
* Publishes the {@link HookEvent} for commands processed by the new command processing, which unlike the legacy
* {@code SynchronousCommandProcessingService} would otherwise never notify webhooks.
*/
@Slf4j
@RequiredArgsConstructor
@Component
final class WebhookCommandHookAfter implements CommandHookAfter<Object, Object> {

private final PlatformSecurityContext context;
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper mapper;

@Override
public void onAfter(Command<Object> command, Object response) {
if (!(command instanceof HookableCommand hookable)) {
return;
}

// Inside an enclosing (e.g. batch) transaction, wait for the commit so that webhooks never fire for a command
// that is rolled back by a later failure, as in the legacy command processing.
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {

@Override
public void afterCommit() {
publish(hookable, command.getPayload(), response);
}
});
} else {
publish(hookable, command.getPayload(), response);
}
}

private void publish(HookableCommand hookable, Object request, Object response) {
final String entityName = hookable.hookEntityName();
final String actionName = hookable.hookActionName();

try {
final AppUser user = context.authenticatedUser();

final Map<String, Object> payload = new LinkedHashMap<>();
payload.put("entityName", entityName);
payload.put("actionName", actionName);
payload.put("createdBy", user.getId());
payload.put("createdByName", user.getUsername());
payload.put("createdByFullName", user.getDisplayName());
payload.put("request", request);

// As in the legacy command processing, the office and client are exposed at the top level of the payload,
// where the SMS hooks look up the client to notify, and the office is cleared from the response.
final JsonNode responseNode = mapper.valueToTree(response);
if (responseNode instanceof ObjectNode responseObject) {
putIfPresent(payload, "officeId", responseObject.get("officeId"));
putIfPresent(payload, "clientId", responseObject.get("clientId"));
if (responseObject.has("officeId")) {
responseObject.putNull("officeId");
}
}
payload.put("response", responseNode);
payload.put("timestamp", Instant.now().toString());

eventPublisher.publishEvent(new HookEvent(new HookEventSource(entityName, actionName), mapper.writeValueAsString(payload), user,
ThreadLocalContextUtil.getContext()));
} catch (Exception e) {
log.error("Failed to publish hook event for entity: {}, action: {}", entityName, actionName, e);
}
}

private static void putIfPresent(Map<String, Object> payload, String field, JsonNode value) {
if (value != null && !value.isNull()) {
payload.put(field, value);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.hooks.command;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

import com.fasterxml.jackson.annotation.JsonIgnore;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.HookableCommand;
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.hooks.event.HookEvent;
import org.apache.fineract.infrastructure.hooks.event.HookEventSource;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;

class WebhookCommandHookAfterTest {

private final ObjectMapper mapper = JsonMapper.builder().build();
private final PlatformSecurityContext context = mock(PlatformSecurityContext.class);
private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class);
private final AppUser user = mock(AppUser.class);

private WebhookCommandHookAfter underTest;

@BeforeEach
void setUp() {
ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.of(2026, 1, 1))));
when(context.authenticatedUser()).thenReturn(user);
when(user.getId()).thenReturn(1L);
when(user.getUsername()).thenReturn("mifos");
when(user.getDisplayName()).thenReturn("App Administrator");
underTest = new WebhookCommandHookAfter(context, eventPublisher, mapper);
}

@AfterEach
void tearDown() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
ThreadLocalContextUtil.reset();
}

@Test
void shouldPublishHookEventForHookableCommand() {
final TestResponse response = new TestResponse(7L);

underTest.onAfter(hookableCommand(), response);

final HookEvent event = publishedEvent();
final HookEventSource source = (HookEventSource) event.getSource();
assertEquals("DOCUMENT", source.getEntityName());
assertEquals("CREATE", source.getActionName());
assertSame(user, event.getAppUser());

final JsonNode payload = mapper.readTree(event.getPayload());
assertEquals("DOCUMENT", payload.get("entityName").asString());
assertEquals("CREATE", payload.get("actionName").asString());
assertEquals(1L, payload.get("createdBy").asLong());
assertEquals("mifos", payload.get("createdByName").asString());
assertEquals("App Administrator", payload.get("createdByFullName").asString());
assertEquals("contract.pdf", payload.get("request").get("name").asString());
// the uploaded content is not part of the notification
assertFalse(payload.get("request").has("stream"));
assertEquals(7L, payload.get("response").get("resourceId").asLong());
assertFalse(payload.get("timestamp").asString().isEmpty());
assertFalse(payload.has("officeId"));
assertFalse(payload.has("clientId"));
}

@Test
void shouldExposeOfficeAndClientAtTopLevelOfPayload() {
underTest.onAfter(hookableCommand(), new TestClientResponse(7L, 2L, 3L));

final JsonNode payload = mapper.readTree(publishedEvent().getPayload());
assertEquals(2L, payload.get("officeId").asLong());
assertEquals(3L, payload.get("clientId").asLong());
assertTrue(payload.get("response").get("officeId").isNull());
assertEquals(3L, payload.get("response").get("clientId").asLong());
assertEquals(7L, payload.get("response").get("resourceId").asLong());
}

@Test
void shouldIgnoreCommandThatIsNotHookable() {
final Command<Object> command = new Command<>();
command.setPayload(new TestRequest("contract.pdf", null));

underTest.onAfter(command, new TestResponse(7L));

verifyNoInteractions(eventPublisher);
}

@Test
void shouldDeferPublicationUntilEnclosingTransactionCommits() {
TransactionSynchronizationManager.initSynchronization();

underTest.onAfter(hookableCommand(), new TestResponse(7L));

verifyNoInteractions(eventPublisher);

TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit);

assertEquals("DOCUMENT", ((HookEventSource) publishedEvent().getSource()).getEntityName());
}

@Test
void shouldNotFailCommandWhenPublishingFails() {
doThrow(new IllegalStateException("listener failed")).when(eventPublisher).publishEvent(any(HookEvent.class));

assertDoesNotThrow(() -> underTest.onAfter(hookableCommand(), new TestResponse(7L)));
}

private HookEvent publishedEvent() {
final ArgumentCaptor<HookEvent> captor = ArgumentCaptor.forClass(HookEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
return captor.getValue();
}

private static Command<Object> hookableCommand() {
final TestHookableCommand command = new TestHookableCommand();
command.setPayload(new TestRequest("contract.pdf", new ByteArrayInputStream(new byte[] { 1, 2, 3 })));
return command;
}

private static final class TestHookableCommand extends Command<Object> implements HookableCommand {

@Override
public String hookEntityName() {
return "DOCUMENT";
}

@Override
public String hookActionName() {
return "CREATE";
}
}

private record TestRequest(String name, @JsonIgnore InputStream stream) {
}

private record TestResponse(Long resourceId) {
}

private record TestClientResponse(Long resourceId, Long officeId, Long clientId) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,20 @@
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.HookableCommand;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateRequest;

@Data
@EqualsAndHashCode(callSuper = true)
public class DocumentCreateCommand extends Command<DocumentCreateRequest> {}
public class DocumentCreateCommand extends Command<DocumentCreateRequest> implements HookableCommand {

@Override
public String hookEntityName() {
return "DOCUMENT";
}

@Override
public String hookActionName() {
return "CREATE";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,20 @@
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.HookableCommand;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteRequest;

@Data
@EqualsAndHashCode(callSuper = true)
public class DocumentDeleteCommand extends Command<DocumentDeleteRequest> {}
public class DocumentDeleteCommand extends Command<DocumentDeleteRequest> implements HookableCommand {

@Override
public String hookEntityName() {
return "DOCUMENT";
}

@Override
public String hookActionName() {
return "DELETE";
}
}
Loading
Loading