diff --git a/fineract-command/src/main/java/org/apache/fineract/command/core/HookableCommand.java b/fineract-command/src/main/java/org/apache/fineract/command/core/HookableCommand.java new file mode 100644 index 00000000000..6af72daf36d --- /dev/null +++ b/fineract-command/src/main/java/org/apache/fineract/command/core/HookableCommand.java @@ -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(); + + String hookActionName(); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/hooks/command/WebhookCommandHookAfter.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/hooks/command/WebhookCommandHookAfter.java new file mode 100644 index 00000000000..ecf0ff81379 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/hooks/command/WebhookCommandHookAfter.java @@ -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 { + + private final PlatformSecurityContext context; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper mapper; + + @Override + public void onAfter(Command 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 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 payload, String field, JsonNode value) { + if (value != null && !value.isNull()) { + payload.put(field, value); + } + } +} diff --git a/fineract-core/src/test/java/org/apache/fineract/infrastructure/hooks/command/WebhookCommandHookAfterTest.java b/fineract-core/src/test/java/org/apache/fineract/infrastructure/hooks/command/WebhookCommandHookAfterTest.java new file mode 100644 index 00000000000..5f371b64bf9 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/infrastructure/hooks/command/WebhookCommandHookAfterTest.java @@ -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 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 captor = ArgumentCaptor.forClass(HookEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + return captor.getValue(); + } + + private static Command 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 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) { + } +} diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentCreateCommand.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentCreateCommand.java index a8b0d1e65a3..face5244f81 100644 --- a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentCreateCommand.java +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentCreateCommand.java @@ -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 {} +public class DocumentCreateCommand extends Command implements HookableCommand { + + @Override + public String hookEntityName() { + return "DOCUMENT"; + } + + @Override + public String hookActionName() { + return "CREATE"; + } +} diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentDeleteCommand.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentDeleteCommand.java index 1f0b5dc90a5..ed94119436c 100644 --- a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentDeleteCommand.java +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentDeleteCommand.java @@ -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 {} +public class DocumentDeleteCommand extends Command implements HookableCommand { + + @Override + public String hookEntityName() { + return "DOCUMENT"; + } + + @Override + public String hookActionName() { + return "DELETE"; + } +} diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentUpdateCommand.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentUpdateCommand.java index 609b76bab71..d0c1c7c1b19 100644 --- a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentUpdateCommand.java +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/command/DocumentUpdateCommand.java @@ -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.DocumentUpdateRequest; @Data @EqualsAndHashCode(callSuper = true) -public class DocumentUpdateCommand extends Command {} +public class DocumentUpdateCommand extends Command implements HookableCommand { + + @Override + public String hookEntityName() { + return "DOCUMENT"; + } + + @Override + public String hookActionName() { + return "UPDATE"; + } +}