| /** |
| * 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.commands.service; |
| |
| import static org.apache.fineract.commands.domain.CommandProcessingResultType.ERROR; |
| import static org.apache.fineract.commands.domain.CommandProcessingResultType.PROCESSED; |
| import static org.apache.http.HttpStatus.SC_OK; |
| |
| import com.google.gson.Gson; |
| import com.google.gson.reflect.TypeToken; |
| import io.github.resilience4j.retry.annotation.Retry; |
| import java.lang.reflect.Type; |
| import java.time.Instant; |
| import java.util.HashMap; |
| import java.util.Map; |
| import lombok.RequiredArgsConstructor; |
| import lombok.extern.slf4j.Slf4j; |
| import org.apache.fineract.batch.exception.ErrorInfo; |
| import org.apache.fineract.commands.domain.CommandProcessingResultType; |
| import org.apache.fineract.commands.domain.CommandSource; |
| import org.apache.fineract.commands.domain.CommandWrapper; |
| import org.apache.fineract.commands.exception.RollbackTransactionAsCommandIsNotApprovedByCheckerException; |
| import org.apache.fineract.commands.exception.UnsupportedCommandException; |
| import org.apache.fineract.commands.handler.NewCommandSourceHandler; |
| import org.apache.fineract.commands.provider.CommandHandlerProvider; |
| import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; |
| import org.apache.fineract.infrastructure.core.api.JsonCommand; |
| import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; |
| import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; |
| import org.apache.fineract.infrastructure.core.domain.BatchRequestContextHolder; |
| import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; |
| import org.apache.fineract.infrastructure.core.exception.ErrorHandler; |
| import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessFailedException; |
| import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException; |
| import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException; |
| import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper; |
| import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; |
| 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.ApplicationContext; |
| import org.springframework.stereotype.Service; |
| import org.springframework.transaction.annotation.Transactional; |
| |
| @Service |
| @Slf4j |
| @RequiredArgsConstructor |
| public class SynchronousCommandProcessingService implements CommandProcessingService { |
| |
| public static final String IDEMPOTENCY_KEY_STORE_FLAG = "idempotencyKeyStoreFlag"; |
| |
| public static final String IDEMPOTENCY_KEY_ATTRIBUTE = "IdempotencyKeyAttribute"; |
| public static final String COMMAND_SOURCE_ID = "commandSourceId"; |
| private final PlatformSecurityContext context; |
| private final ApplicationContext applicationContext; |
| private final ToApiJsonSerializer<Map<String, Object>> toApiJsonSerializer; |
| private final ToApiJsonSerializer<CommandProcessingResult> toApiResultJsonSerializer; |
| private final ConfigurationDomainService configurationDomainService; |
| private final CommandHandlerProvider commandHandlerProvider; |
| private final IdempotencyKeyResolver idempotencyKeyResolver; |
| private final IdempotencyKeyGenerator idempotencyKeyGenerator; |
| private final CommandSourceService commandSourceService; |
| private final ErrorHandler errorHandler; |
| |
| private final FineractRequestContextHolder fineractRequestContextHolder; |
| private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson(); |
| |
| @Override |
| @Retry(name = "executeCommand", fallbackMethod = "fallbackExecuteCommand") |
| public CommandProcessingResult executeCommand(final CommandWrapper wrapper, final JsonCommand command, |
| final boolean isApprovedByChecker) { |
| // Do not store the idempotency key because of the exception handling |
| setIdempotencyKeyStoreFlag(false); |
| |
| Long commandId = (Long) fineractRequestContextHolder.getAttribute(COMMAND_SOURCE_ID, null); |
| boolean isRetry = commandId != null; |
| |
| CommandSource commandSource = null; |
| String idempotencyKey; |
| if (isRetry) { |
| commandSource = commandSourceService.getCommandSource(commandId); |
| idempotencyKey = commandSource.getIdempotencyKey(); |
| } else { |
| idempotencyKey = idempotencyKeyResolver.resolve(wrapper); |
| } |
| exceptionWhenTheRequestAlreadyProcessed(wrapper, idempotencyKey, isRetry); |
| |
| boolean sameTransaction = BatchRequestContextHolder.getEnclosingTransaction().isPresent(); |
| if (commandSource == null) { |
| AppUser user = context.authenticatedUser(wrapper); |
| if (sameTransaction) { |
| commandSource = commandSourceService.getInitialCommandSource(wrapper, command, user, idempotencyKey); |
| } else { |
| commandSource = commandSourceService.saveInitialNewTransaction(wrapper, command, user, idempotencyKey); |
| storeCommandIdInContext(commandSource); // Store command id as a request attribute |
| } |
| } |
| setIdempotencyKeyStoreFlag(true); |
| |
| final CommandProcessingResult result; |
| try { |
| result = findCommandHandler(wrapper).processCommand(command); |
| } catch (Throwable t) { // NOSONAR |
| RuntimeException mappable = ErrorHandler.getMappable(t); |
| ErrorInfo errorInfo = commandSourceService.generateErrorInfo(mappable); |
| commandSource.setResultStatusCode(errorInfo.getStatusCode()); |
| commandSource.setResult(errorInfo.getMessage()); |
| commandSource.setStatus(ERROR); |
| if (!sameTransaction) { // TODO: temporary solution |
| commandSource = commandSourceService.saveResultNewTransaction(commandSource); |
| } |
| publishHookErrorEvent(wrapper, command, errorInfo); // TODO must be performed in a new transaction |
| throw mappable; |
| } |
| |
| commandSource.updateForAudit(result); |
| commandSource.setResultStatusCode(SC_OK); |
| commandSource.setResult(toApiJsonSerializer.serializeResult(result)); |
| commandSource.setStatus(PROCESSED); |
| |
| boolean isRollback = !isApprovedByChecker && (result.isRollbackTransaction() |
| || configurationDomainService.isMakerCheckerEnabledForTask(wrapper.taskPermissionName())); |
| // TODO: this should be removed, can not override audit information (and maker-checker does not work) |
| if (!isRollback && result.hasChanges()) { |
| commandSource.setCommandJson(toApiJsonSerializer.serializeResult(result.getChanges())); |
| } |
| |
| commandSource = commandSourceService.saveResultSameTransaction(commandSource); |
| if (sameTransaction) { |
| storeCommandIdInContext(commandSource); // Store command id as a request attribute |
| } |
| |
| if (isRollback) { |
| /* |
| * JournalEntry will generate a new transactionId every time. Updating the transactionId with old |
| * transactionId, because as there are no entries are created with new transactionId, will throw an error |
| * when checker approves the transaction |
| */ |
| commandSource.setTransactionId(command.getTransactionId()); |
| // TODO: this should be removed together with lines 147-149 |
| commandSource.setCommandJson(command.json()); // Set back CommandSource json data |
| throw new RollbackTransactionAsCommandIsNotApprovedByCheckerException(commandSource); |
| } |
| |
| result.setRollbackTransaction(null); |
| publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result); // TODO must be performed in a |
| // new transaction |
| return result; |
| } |
| |
| private void storeCommandIdInContext(CommandSource savedCommandSource) { |
| if (savedCommandSource.getId() == null) { |
| throw new IllegalStateException("Command source not saved"); |
| } |
| // Idempotency filters and retry need this |
| fineractRequestContextHolder.setAttribute(COMMAND_SOURCE_ID, savedCommandSource.getId()); |
| } |
| |
| private void publishHookErrorEvent(CommandWrapper wrapper, JsonCommand command, ErrorInfo errorInfo) { |
| publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, gson.toJson(errorInfo)); |
| } |
| |
| private void exceptionWhenTheRequestAlreadyProcessed(CommandWrapper wrapper, String idempotencyKey, boolean retry) { |
| CommandSource command = commandSourceService.findCommandSource(wrapper, idempotencyKey); |
| if (command == null) { |
| return; |
| } |
| CommandProcessingResultType status = CommandProcessingResultType.fromInt(command.getStatus()); |
| switch (status) { |
| case UNDER_PROCESSING -> throw new IdempotentCommandProcessUnderProcessingException(wrapper, idempotencyKey); |
| case PROCESSED -> throw new IdempotentCommandProcessSucceedException(wrapper, idempotencyKey, command); |
| case ERROR -> { |
| if (!retry) { |
| throw new IdempotentCommandProcessFailedException(wrapper, idempotencyKey, command); |
| } |
| } |
| default -> { |
| } |
| } |
| } |
| |
| private void setIdempotencyKeyStoreFlag(boolean flag) { |
| fineractRequestContextHolder.setAttribute(IDEMPOTENCY_KEY_STORE_FLAG, flag); |
| } |
| |
| @Transactional |
| @Override |
| public CommandProcessingResult logCommand(CommandSource commandSource) { |
| commandSource.markAsAwaitingApproval(); |
| if (commandSource.getIdempotencyKey() == null) { |
| commandSource.setIdempotencyKey(idempotencyKeyGenerator.create()); |
| } |
| commandSource = commandSourceService.saveResultSameTransaction(commandSource); |
| |
| return new CommandProcessingResultBuilder().withCommandId(commandSource.getId()).withEntityId(commandSource.getResourceId()) |
| .build(); |
| } |
| |
| @SuppressWarnings("unused") |
| public CommandProcessingResult fallbackExecuteCommand(Exception e) { |
| if (e instanceof RollbackTransactionAsCommandIsNotApprovedByCheckerException ex) { |
| return logCommand(ex.getCommandSourceResult()); |
| } |
| throw ErrorHandler.getMappable(e); |
| } |
| |
| private NewCommandSourceHandler findCommandHandler(final CommandWrapper wrapper) { |
| NewCommandSourceHandler handler; |
| |
| if (wrapper.isDatatableResource()) { |
| if (wrapper.isCreateDatatable()) { |
| handler = applicationContext.getBean("createDatatableCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isDeleteDatatable()) { |
| handler = applicationContext.getBean("deleteDatatableCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isUpdateDatatable()) { |
| handler = applicationContext.getBean("updateDatatableCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isCreate()) { |
| handler = applicationContext.getBean("createDatatableEntryCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isUpdateMultiple()) { |
| handler = applicationContext.getBean("updateOneToManyDatatableEntryCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isUpdateOneToOne()) { |
| handler = applicationContext.getBean("updateOneToOneDatatableEntryCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isDeleteMultiple()) { |
| handler = applicationContext.getBean("deleteOneToManyDatatableEntryCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isDeleteOneToOne()) { |
| handler = applicationContext.getBean("deleteOneToOneDatatableEntryCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isRegisterDatatable()) { |
| handler = applicationContext.getBean("registerDatatableCommandHandler", NewCommandSourceHandler.class); |
| } else { |
| throw new UnsupportedCommandException(wrapper.commandName()); |
| } |
| } else if (wrapper.isNoteResource()) { |
| if (wrapper.isCreate()) { |
| handler = applicationContext.getBean("createNoteCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isUpdate()) { |
| handler = applicationContext.getBean("updateNoteCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isDelete()) { |
| handler = applicationContext.getBean("deleteNoteCommandHandler", NewCommandSourceHandler.class); |
| } else { |
| throw new UnsupportedCommandException(wrapper.commandName()); |
| } |
| } else if (wrapper.isSurveyResource()) { |
| if (wrapper.isRegisterSurvey()) { |
| handler = applicationContext.getBean("registerSurveyCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.isFullFilSurvey()) { |
| handler = applicationContext.getBean("fullFilSurveyCommandHandler", NewCommandSourceHandler.class); |
| } else { |
| throw new UnsupportedCommandException(wrapper.commandName()); |
| } |
| } else if (wrapper.isLoanDisburseDetailResource()) { |
| if (wrapper.isUpdateDisbursementDate()) { |
| handler = applicationContext.getBean("updateLoanDisburseDateCommandHandler", NewCommandSourceHandler.class); |
| } else if (wrapper.addAndDeleteDisbursementDetails()) { |
| handler = applicationContext.getBean("addAndDeleteLoanDisburseDetailsCommandHandler", NewCommandSourceHandler.class); |
| } else { |
| throw new UnsupportedCommandException(wrapper.commandName()); |
| } |
| } else { |
| handler = commandHandlerProvider.getHandler(wrapper.entityName(), wrapper.actionName()); |
| } |
| |
| return handler; |
| } |
| |
| @Override |
| public boolean validateCommand(final CommandWrapper commandWrapper, final AppUser user) { |
| boolean rollbackTransaction = configurationDomainService.isMakerCheckerEnabledForTask(commandWrapper.taskPermissionName()); |
| user.validateHasPermissionTo(commandWrapper.getTaskPermissionName()); |
| return rollbackTransaction; |
| } |
| |
| private void publishHookEvent(final String entityName, final String actionName, JsonCommand command, final Object result) { |
| try { |
| final AppUser appUser = context.authenticatedUser(CommandWrapper.wrap(actionName, entityName, null, null)); |
| |
| final HookEventSource hookEventSource = new HookEventSource(entityName, actionName); |
| |
| // TODO: Add support for publishing array events |
| if (command.json() != null && command.json().startsWith("{")) { |
| Type type = new TypeToken<Map<String, Object>>() { |
| |
| }.getType(); |
| Map<String, Object> myMap = gson.fromJson(command.json(), type); |
| |
| Map<String, Object> reqmap = new HashMap<>(); |
| reqmap.put("entityName", entityName); |
| reqmap.put("actionName", actionName); |
| reqmap.put("createdBy", context.authenticatedUser().getId()); |
| reqmap.put("createdByName", context.authenticatedUser().getUsername()); |
| reqmap.put("createdByFullName", context.authenticatedUser().getDisplayName()); |
| |
| reqmap.put("request", myMap); |
| if (result instanceof CommandProcessingResult) { |
| CommandProcessingResult resultCopy = CommandProcessingResult |
| .fromCommandProcessingResult((CommandProcessingResult) result); |
| |
| reqmap.put("officeId", resultCopy.getOfficeId()); |
| reqmap.put("clientId", resultCopy.getClientId()); |
| resultCopy.setOfficeId(null); |
| reqmap.put("response", resultCopy); |
| } else if (result instanceof ErrorInfo ex) { |
| reqmap.put("status", "Exception"); |
| |
| Map<String, Object> errorMap = gson.fromJson(ex.getMessage(), type); |
| errorMap.put("errorCode", ex.getErrorCode()); |
| errorMap.put("statusCode", ex.getStatusCode()); |
| |
| reqmap.put("response", errorMap); |
| } |
| |
| reqmap.put("timestamp", Instant.now().toString()); |
| |
| final String serializedResult = toApiResultJsonSerializer.serialize(reqmap); |
| |
| final HookEvent applicationEvent = new HookEvent(hookEventSource, serializedResult, appUser, |
| ThreadLocalContextUtil.getContext()); |
| |
| applicationContext.publishEvent(applicationEvent); |
| } |
| } catch (Exception e) { |
| log.error("Error", e); |
| } |
| } |
| } |