FINERACT-2452: New command processing - audit
Co-Authored-By: Adam Saghy <adam.saghy@dpc.hu> Co-Authored-By: Yu Wati Nyi <nyi22y@mtholyoke.edu>
This commit is contained in:
committed by
Aleksandar Vidakovic
co-authored by
Adam Saghy
Yu Wati Nyi
parent
1d1876edfd
commit
8d064ddf6c
@@ -20,8 +20,7 @@ package org.apache.fineract.command.core;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import java.time.Instant;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
|
||||
@@ -32,15 +31,31 @@ public class Command<T> implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private UUID id;
|
||||
private Long commandId;
|
||||
|
||||
private String idempotencyKey;
|
||||
|
||||
private OffsetDateTime createdAt;
|
||||
private String ipAddress;
|
||||
|
||||
private String tenantId;
|
||||
private Instant createdAt;
|
||||
|
||||
private String username;
|
||||
private Instant updatedAt;
|
||||
|
||||
private Instant executedAt;
|
||||
|
||||
private Instant approvedAt;
|
||||
|
||||
private Instant rejectedAt;
|
||||
|
||||
private String initiatedByUsername;
|
||||
|
||||
private String executedByUsername;
|
||||
|
||||
private String approvedByUsername;
|
||||
|
||||
private String rejectedByUsername;
|
||||
|
||||
private String error;
|
||||
|
||||
private T payload;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
public interface CommandAuditor {
|
||||
|
||||
Object getResponseByIdempotencyKey(String key);
|
||||
|
||||
void processing(Command<?> command);
|
||||
|
||||
void processed(Command<?> command, Object response);
|
||||
|
||||
void error(Command<?> command);
|
||||
}
|
||||
+4
-1
@@ -23,5 +23,8 @@ import lombok.experimental.UtilityClass;
|
||||
@UtilityClass
|
||||
public final class CommandConstants {
|
||||
|
||||
public static final String COMMAND_REQUEST_ID = "x-fineract-request-id";
|
||||
public static final String COMMAND_JSON_CLASS_ATTRIBUTE = "@class";
|
||||
public static final String COMMAND_HTTP_HEADER_REQUEST_ID = "x-fineract-request-id";
|
||||
public static final String COMMAND_HTTP_HEADER_TENANT_ID = "Fineract-Platform-TenantId";
|
||||
public static final String COMMAND_HTTP_HEADER_IP = "IP";
|
||||
}
|
||||
|
||||
@@ -19,11 +19,19 @@
|
||||
package org.apache.fineract.command.core;
|
||||
|
||||
import com.google.common.reflect.TypeToken;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
public interface CommandHandler<REQ, RES> {
|
||||
|
||||
RES handle(Command<REQ> command);
|
||||
|
||||
@SneakyThrows
|
||||
default RES fallback(Command<REQ> command, Throwable t) {
|
||||
// NOTE: I think this should be enough for now; the errors bubble up and will eventually be handled the global
|
||||
// error handler. Any command handler can override this default to implement more specialized fallbacks.
|
||||
throw t;
|
||||
}
|
||||
|
||||
default boolean matches(Command<REQ> command) {
|
||||
TypeToken<REQ> handlerType = new TypeToken<>(getClass()) {};
|
||||
|
||||
|
||||
+12
@@ -51,6 +51,18 @@ public final class CommandProperties implements Serializable {
|
||||
@Builder.Default
|
||||
private ProducerType producerType = ProducerType.SINGLE;
|
||||
|
||||
@Builder.Default
|
||||
private Boolean auditable = true;
|
||||
|
||||
@Builder.Default
|
||||
private Boolean fileDeadLetterQueueEnabled = false;
|
||||
|
||||
@Builder.Default
|
||||
private String fileDeadLetterQueuePath = "/tmp/fineract-command-audit";
|
||||
|
||||
@Builder.Default
|
||||
private String idemPotencyKeyHeaderName = "Idempotency-Key";
|
||||
|
||||
public enum CommandExecutorType {
|
||||
sync, //
|
||||
async, //
|
||||
|
||||
+34
-18
@@ -18,41 +18,57 @@
|
||||
*/
|
||||
package org.apache.fineract.command.implementation;
|
||||
|
||||
import java.util.List;
|
||||
import static java.util.concurrent.TimeUnit.SECONDS;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandExecutor;
|
||||
import org.apache.fineract.command.core.CommandAuditor;
|
||||
import org.apache.fineract.command.core.CommandHandler;
|
||||
import org.apache.fineract.command.core.CommandMiddleware;
|
||||
import org.apache.fineract.command.core.CommandRouter;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
@ConditionalOnProperty(value = "fineract.command.executor", havingValue = "async")
|
||||
public class AsynchronousCommandExecutor implements CommandExecutor {
|
||||
// TODO: not ready yet for prime time
|
||||
// @Component
|
||||
// @ConditionalOnProperty(value = "fineract.command.executor", havingValue = "async")
|
||||
public class AsynchronousCommandExecutor extends BaseCommandExecutor {
|
||||
|
||||
private final List<CommandMiddleware> middlewares;
|
||||
|
||||
private final CommandRouter router;
|
||||
public AsynchronousCommandExecutor(CommandRouter router, CommandAuditor auditor) {
|
||||
super(router, auditor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <REQ, RES> Supplier<RES> execute(Command<REQ> command) {
|
||||
public <REQ, RES> Supplier<RES> execute(final Command<REQ> command) {
|
||||
CompletableFuture<RES> future = CompletableFuture.supplyAsync(() -> {
|
||||
for (CommandMiddleware middleware : middlewares) {
|
||||
middleware.invoke(command);
|
||||
}
|
||||
auditor.processing(command);
|
||||
|
||||
CommandHandler<REQ, RES> handler = router.route(command);
|
||||
|
||||
return handler.handle(command);
|
||||
}).whenComplete((response, t) -> {
|
||||
if (t == null) {
|
||||
auditor.processed(command, response);
|
||||
} else {
|
||||
command.setError(t.getMessage());
|
||||
|
||||
auditor.error(command);
|
||||
}
|
||||
});
|
||||
|
||||
return future::join;
|
||||
return () -> {
|
||||
try {
|
||||
// TODO: make this configurable
|
||||
return future.get(3, SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (ExecutionException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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.implementation;
|
||||
|
||||
import org.apache.fineract.command.core.CommandAuditor;
|
||||
import org.apache.fineract.command.core.CommandExecutor;
|
||||
import org.apache.fineract.command.core.CommandRouter;
|
||||
|
||||
public abstract class BaseCommandExecutor implements CommandExecutor {
|
||||
|
||||
protected final CommandRouter router;
|
||||
protected final CommandAuditor auditor;
|
||||
|
||||
protected BaseCommandExecutor(CommandRouter router, CommandAuditor auditor) {
|
||||
this.router = router;
|
||||
this.auditor = auditor;
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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.implementation;
|
||||
|
||||
import static org.apache.fineract.command.core.CommandConstants.COMMAND_HTTP_HEADER_IP;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandExecutor;
|
||||
import org.apache.fineract.command.core.CommandPipeline;
|
||||
import org.apache.fineract.command.core.CommandProperties;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public abstract class BaseCommandPipeline implements CommandPipeline {
|
||||
|
||||
protected final CommandExecutor executor;
|
||||
protected final CommandProperties properties;
|
||||
|
||||
protected void setAttributes(Command<?> command) {
|
||||
if (StringUtils.isEmpty(command.getIpAddress())) {
|
||||
command.setIpAddress(getHeader(COMMAND_HTTP_HEADER_IP));
|
||||
}
|
||||
if (StringUtils.isEmpty(command.getIdempotencyKey())) {
|
||||
command.setIdempotencyKey(getHeader(properties.getIdemPotencyKeyHeaderName()));
|
||||
}
|
||||
if (command.getCreatedAt() == null) {
|
||||
command.setCreatedAt(Instant.now());
|
||||
}
|
||||
if (StringUtils.isEmpty(command.getInitiatedByUsername())) {
|
||||
command.setInitiatedByUsername(getUsername());
|
||||
}
|
||||
}
|
||||
|
||||
protected String getHeader(String name) {
|
||||
return getHeader(name, false);
|
||||
}
|
||||
|
||||
protected String getHeader(String name, boolean searchParameter) {
|
||||
var attributes = RequestContextHolder.getRequestAttributes();
|
||||
|
||||
if (attributes instanceof ServletRequestAttributes servletAttributes) {
|
||||
var value = servletAttributes.getRequest().getHeader(name.toLowerCase());
|
||||
|
||||
if (searchParameter && StringUtils.isEmpty(value)) {
|
||||
value = servletAttributes.getRequest().getParameter(name.toLowerCase());
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected String getUsername() {
|
||||
final var context = SecurityContextHolder.getContext();
|
||||
|
||||
if (context != null) {
|
||||
final var auth = context.getAuthentication();
|
||||
|
||||
if (auth != null) {
|
||||
return auth.getName();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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.implementation;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.apache.fineract.command.core.CommandConstants.COMMAND_JSON_CLASS_ATTRIBUTE;
|
||||
import static org.apache.fineract.command.persistence.domain.CommandEntityState.ERROR;
|
||||
import static org.apache.fineract.command.persistence.domain.CommandEntityState.PROCESSED;
|
||||
import static org.apache.fineract.command.persistence.domain.CommandEntityState.UNDER_PROCESSING;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandAuditor;
|
||||
import org.apache.fineract.command.core.CommandProperties;
|
||||
import org.apache.fineract.command.persistence.domain.CommandEntity;
|
||||
import org.apache.fineract.command.persistence.domain.CommandRepository;
|
||||
import org.apache.fineract.command.persistence.mapping.CommandMapper;
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class DefaultCommandAuditor implements CommandAuditor {
|
||||
|
||||
private final CommandMapper mapper;
|
||||
private final CommandRepository repository;
|
||||
private final CommandProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@SneakyThrows
|
||||
@Override
|
||||
public Object getResponseByIdempotencyKey(String key) {
|
||||
final var result = repository.findOneByIdempotencyKey(key);
|
||||
final var response = result.map(CommandEntity::getResponse).orElse(null);
|
||||
|
||||
if (response != null) {
|
||||
final var clazz = response.get(COMMAND_JSON_CLASS_ATTRIBUTE).asText();
|
||||
|
||||
return objectMapper.convertValue(response, Class.forName(clazz));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Retry(name = "commandAuditProcessing", fallbackMethod = "fallback")
|
||||
@Override
|
||||
public void processing(Command<?> command) {
|
||||
if (Boolean.TRUE.equals(properties.getAuditable())) {
|
||||
final var now = Instant.now();
|
||||
final var commandEntity = mapper.map(command);
|
||||
|
||||
commandEntity.setCreatedAt(now);
|
||||
commandEntity.setUpdatedAt(now);
|
||||
commandEntity.setState(UNDER_PROCESSING);
|
||||
|
||||
repository.save(commandEntity);
|
||||
|
||||
command.setCreatedAt(now);
|
||||
command.setUpdatedAt(now);
|
||||
command.setCommandId(commandEntity.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Retry(name = "commandAuditProcessed", fallbackMethod = "fallback")
|
||||
@Override
|
||||
public void processed(Command<?> command, Object response) {
|
||||
if (Boolean.TRUE.equals(properties.getAuditable())) {
|
||||
final var now = Instant.now();
|
||||
final var commandEntity = mapper.map(command, response);
|
||||
|
||||
// TODO: improve when maker-checker functionality lands
|
||||
commandEntity.setExecutedByUsername(command.getInitiatedByUsername());
|
||||
commandEntity.setUpdatedAt(now);
|
||||
commandEntity.setExecutedAt(now);
|
||||
commandEntity.setState(PROCESSED);
|
||||
|
||||
repository.save(commandEntity);
|
||||
|
||||
command.setUpdatedAt(now);
|
||||
command.setExecutedAt(now);
|
||||
}
|
||||
}
|
||||
|
||||
@Retry(name = "commandAuditError", fallbackMethod = "fallback")
|
||||
@Override
|
||||
public void error(Command<?> command) {
|
||||
if (Boolean.TRUE.equals(properties.getAuditable())) {
|
||||
final var now = Instant.now();
|
||||
final var commandEntity = mapper.map(command);
|
||||
|
||||
commandEntity.setUpdatedAt(now);
|
||||
commandEntity.setState(ERROR);
|
||||
|
||||
repository.save(commandEntity);
|
||||
|
||||
command.setUpdatedAt(now);
|
||||
command.setExecutedAt(now);
|
||||
}
|
||||
}
|
||||
|
||||
@EventListener(ApplicationStartedEvent.class)
|
||||
void onStartup() {
|
||||
if (Boolean.TRUE.equals(properties.getFileDeadLetterQueueEnabled())) {
|
||||
try {
|
||||
var created = Path.of(properties.getFileDeadLetterQueuePath()).toFile().mkdirs();
|
||||
log.info("Created command dead-letter queue: {} ({})", properties.getFileDeadLetterQueuePath(), created);
|
||||
} catch (Exception e) {
|
||||
log.error("Unable to initialize command dead-letter queue:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Retry(name = "commandAuditFallback", fallbackMethod = "fatal")
|
||||
@SuppressWarnings("UnusedMethod")
|
||||
private void fallback(Command<?> command, Throwable t) throws Exception {
|
||||
if (Boolean.TRUE.equals(properties.getFileDeadLetterQueueEnabled())) {
|
||||
setError(command, t);
|
||||
write(command);
|
||||
} else {
|
||||
fatal(command, t);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("UnusedMethod")
|
||||
private void fatal(Command<?> command, Throwable t) {
|
||||
// note: last line of defense; if this fails then all is lost
|
||||
setError(command, t);
|
||||
log.error("Command audit error: {}", command, t);
|
||||
}
|
||||
|
||||
private void setError(Command<?> command, Throwable t) {
|
||||
if (command != null && t != null) {
|
||||
command.setUpdatedAt(Instant.now());
|
||||
command.setError(t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void write(Command<?> command) throws IOException {
|
||||
var file = Path.of(properties.getFileDeadLetterQueuePath(), command.getCreatedAt().toEpochMilli() + ".json").toFile();
|
||||
FileUtils.write(file, objectMapper.writeValueAsString(command), UTF_8);
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -21,26 +21,29 @@ package org.apache.fineract.command.implementation;
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandExecutor;
|
||||
import org.apache.fineract.command.core.CommandPipeline;
|
||||
import org.apache.fineract.command.core.CommandProperties;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
@ConditionalOnBean(CommandPipeline.class)
|
||||
public class DefaultCommandPipeline implements CommandPipeline {
|
||||
public class DefaultCommandPipeline extends BaseCommandPipeline {
|
||||
|
||||
private final CommandExecutor executor;
|
||||
public DefaultCommandPipeline(CommandExecutor executor, CommandProperties properties) {
|
||||
super(executor, properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <REQ, RES> Supplier<RES> send(final Command<REQ> command) {
|
||||
requireNonNull(command, "Command must not be null");
|
||||
|
||||
setAttributes(command);
|
||||
|
||||
return executor.execute(command);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-17
@@ -22,7 +22,6 @@ import com.lmax.disruptor.EventHandler;
|
||||
import com.lmax.disruptor.dsl.Disruptor;
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.Getter;
|
||||
@@ -30,26 +29,38 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandExecutor;
|
||||
import org.apache.fineract.command.core.CommandMiddleware;
|
||||
import org.apache.fineract.command.core.CommandAuditor;
|
||||
import org.apache.fineract.command.core.CommandRouter;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
@ConditionalOnProperty(value = "fineract.command.executor", havingValue = "disruptor")
|
||||
// TODO: not ready yet for prime time
|
||||
// @Component
|
||||
// @ConditionalOnProperty(value = "fineract.command.executor", havingValue = "disruptor")
|
||||
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||
public class DisruptorCommandExecutor implements CommandExecutor, Closeable {
|
||||
public class DisruptorCommandExecutor extends BaseCommandExecutor implements Closeable {
|
||||
|
||||
private final Disruptor<CommandEvent> disruptor;
|
||||
|
||||
public DisruptorCommandExecutor(Disruptor<CommandEvent> disruptor, CommandRouter router, CommandAuditor auditor) {
|
||||
super(router, auditor);
|
||||
this.disruptor = disruptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <REQ, RES> Supplier<RES> execute(Command<REQ> command) {
|
||||
CommandEvent<REQ, RES> processedEvent = next(command);
|
||||
|
||||
return processedEvent.getFuture()::join;
|
||||
var future = processedEvent.getFuture().whenComplete((response, t) -> {
|
||||
if (t == null) {
|
||||
auditor.processed(command, response);
|
||||
} else {
|
||||
command.setError(t.getMessage());
|
||||
|
||||
auditor.error(command);
|
||||
}
|
||||
});
|
||||
|
||||
return future::join;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,17 +93,11 @@ public class DisruptorCommandExecutor implements CommandExecutor, Closeable {
|
||||
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||
public static class CompleteableCommandEventHandler implements EventHandler<CommandEvent> {
|
||||
|
||||
private final List<CommandMiddleware> middlewares;
|
||||
|
||||
private final CommandRouter router;
|
||||
|
||||
@Override
|
||||
public void onEvent(CommandEvent event, long sequence, boolean endOfBatch) throws Exception {
|
||||
public void onEvent(CommandEvent event, long sequence, boolean endOfBatch) {
|
||||
try {
|
||||
for (CommandMiddleware middleware : middlewares) {
|
||||
middleware.invoke(event.getCommand());
|
||||
}
|
||||
|
||||
var handler = router.route(event.getCommand());
|
||||
|
||||
event.getFuture().complete(handler.handle(event.getCommand()));
|
||||
|
||||
+23
-15
@@ -18,37 +18,45 @@
|
||||
*/
|
||||
package org.apache.fineract.command.implementation;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandExecutor;
|
||||
import org.apache.fineract.command.core.CommandAuditor;
|
||||
import org.apache.fineract.command.core.CommandHandler;
|
||||
import org.apache.fineract.command.core.CommandMiddleware;
|
||||
import org.apache.fineract.command.core.CommandRouter;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
@ConditionalOnProperty(value = "fineract.command.executor", havingValue = "sync")
|
||||
@SuppressWarnings({ "unchecked" })
|
||||
public class SynchronousCommandExecutor implements CommandExecutor {
|
||||
public class SynchronousCommandExecutor extends BaseCommandExecutor {
|
||||
|
||||
private final List<CommandMiddleware> middlewares;
|
||||
|
||||
private final CommandRouter router;
|
||||
public SynchronousCommandExecutor(CommandRouter router, CommandAuditor auditor) {
|
||||
super(router, auditor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <REQ, RES> Supplier<RES> execute(Command<REQ> command) {
|
||||
for (CommandMiddleware middleware : middlewares) {
|
||||
middleware.invoke(command);
|
||||
}
|
||||
|
||||
public <REQ, RES> Supplier<RES> execute(final Command<REQ> command) {
|
||||
CommandHandler<REQ, RES> handler = router.route(command);
|
||||
|
||||
return () -> handler.handle(command);
|
||||
return () -> {
|
||||
try {
|
||||
auditor.processing(command);
|
||||
|
||||
RES response = handler.handle(command);
|
||||
|
||||
auditor.processed(command, response);
|
||||
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
command.setError(e.getMessage());
|
||||
|
||||
auditor.error(command);
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -16,24 +16,26 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.command.sample.middleware;
|
||||
package org.apache.fineract.command.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandMiddleware;
|
||||
import org.apache.fineract.command.sample.command.DummyCommand;
|
||||
import lombok.SneakyThrows;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.data.convert.ReadingConverter;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class DummyMiddleware implements CommandMiddleware {
|
||||
@RequiredArgsConstructor
|
||||
@ReadingConverter
|
||||
public class JsonNodeReadingConverter implements Converter<String, JsonNode> {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
@SneakyThrows
|
||||
@Override
|
||||
public void invoke(Command<?> command) {
|
||||
if (command instanceof DummyCommand c) {
|
||||
c.getPayload().setId(command.getId());
|
||||
}
|
||||
public JsonNode convert(String source) {
|
||||
return mapper.readTree(source);
|
||||
}
|
||||
}
|
||||
+8
-26
@@ -20,40 +20,22 @@ package org.apache.fineract.command.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.data.convert.WritingConverter;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Converter
|
||||
public class JsonAttributeConverter implements AttributeConverter<JsonNode, String> {
|
||||
// TODO: it would be nicer to use a native JSON type on the database side, but not every system supports this;
|
||||
// string/text are the lowest common denominator that should work on every database
|
||||
@WritingConverter
|
||||
public class JsonNodeWritingConverter implements Converter<JsonNode, String> {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public String convertToDatabaseColumn(JsonNode source) {
|
||||
if (source != null) {
|
||||
return mapper.writeValueAsString(source);
|
||||
}
|
||||
|
||||
// TODO: throw exception?
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public JsonNode convertToEntityAttribute(String source) {
|
||||
if (source != null) {
|
||||
return mapper.readTree(source);
|
||||
}
|
||||
|
||||
// TODO: throw exception?
|
||||
return null;
|
||||
public String convert(JsonNode source) {
|
||||
return mapper.writeValueAsString(source);
|
||||
}
|
||||
}
|
||||
+45
-37
@@ -19,65 +19,73 @@
|
||||
package org.apache.fineract.command.persistence.domain;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.PostLoad;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import lombok.AccessLevel;
|
||||
import java.time.Instant;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.apache.fineract.command.persistence.converter.JsonAttributeConverter;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@FieldNameConstants
|
||||
@Entity
|
||||
@Table(name = "m_command")
|
||||
@Table("m_command")
|
||||
public class CommandEntity implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@jakarta.persistence.Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
@Id
|
||||
@Column("id")
|
||||
private Long id;
|
||||
|
||||
@Transient
|
||||
@Setter(value = AccessLevel.NONE)
|
||||
private boolean isNew = true;
|
||||
@Column("created_at")
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "command_id")
|
||||
private UUID commandId;
|
||||
@Column("updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
@Column("executed_at")
|
||||
private Instant executedAt;
|
||||
|
||||
@Column(name = "tenant_id")
|
||||
private String tenantId;
|
||||
@Column("approved_at")
|
||||
private Instant approvedAt;
|
||||
|
||||
@Column(name = "username")
|
||||
private String username;
|
||||
@Column("rejected_at")
|
||||
private Instant rejectedAt;
|
||||
|
||||
@Column(name = "payload")
|
||||
@Convert(converter = JsonAttributeConverter.class)
|
||||
private JsonNode payload;
|
||||
@Column("initiated_by_username")
|
||||
private String initiatedByUsername;
|
||||
|
||||
@PrePersist
|
||||
@PostLoad
|
||||
void markNotNew() {
|
||||
this.isNew = false;
|
||||
}
|
||||
@Column("executed_by_username")
|
||||
private String executedByUsername;
|
||||
|
||||
@Column("approved_by_username")
|
||||
private String approvedByUsername;
|
||||
|
||||
@Column("rejected_by_username")
|
||||
private String rejectedByUsername;
|
||||
|
||||
@Column("idempotency_key")
|
||||
private String idempotencyKey;
|
||||
|
||||
@Column("state")
|
||||
private CommandEntityState state;
|
||||
|
||||
@Column("error")
|
||||
private String error;
|
||||
|
||||
@Column("ip_address")
|
||||
private String ipAddress;
|
||||
|
||||
@Column("request")
|
||||
private JsonNode request;
|
||||
|
||||
@Column("response")
|
||||
private JsonNode response;
|
||||
}
|
||||
|
||||
+3
-5
@@ -16,10 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.command.core;
|
||||
package org.apache.fineract.command.persistence.domain;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface CommandMiddleware {
|
||||
|
||||
void invoke(Command<?> command);
|
||||
public enum CommandEntityState {
|
||||
INVALID, PROCESSED, AWAITING_APPROVAL, REJECTED, UNDER_PROCESSING, ERROR
|
||||
}
|
||||
+7
-3
@@ -18,7 +18,11 @@
|
||||
*/
|
||||
package org.apache.fineract.command.persistence.domain;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.repository.ListCrudRepository;
|
||||
import org.springframework.data.repository.query.QueryByExampleExecutor;
|
||||
|
||||
public interface CommandRepository extends JpaRepository<CommandEntity, Long>, JpaSpecificationExecutor<CommandEntity> {}
|
||||
public interface CommandRepository extends ListCrudRepository<CommandEntity, Long>, QueryByExampleExecutor<CommandEntity> {
|
||||
|
||||
Optional<CommandEntity> findOneByIdempotencyKey(String idempotencyKey);
|
||||
}
|
||||
|
||||
+5
-4
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.command.persistence.mapping;
|
||||
|
||||
import static org.apache.fineract.command.core.CommandConstants.COMMAND_JSON_CLASS_ATTRIBUTE;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
@@ -29,9 +31,8 @@ import org.springframework.stereotype.Component;
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
final class CommandJsonMapper {
|
||||
public final class CommandJsonMapper {
|
||||
|
||||
private static final String CLASS_ATTRIBUTE = "@class";
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -40,7 +41,7 @@ final class CommandJsonMapper {
|
||||
return null;
|
||||
}
|
||||
|
||||
var canonicalName = source.get(CLASS_ATTRIBUTE).asText();
|
||||
var canonicalName = source.get(COMMAND_JSON_CLASS_ATTRIBUTE).asText();
|
||||
|
||||
try {
|
||||
return (T) mapper.convertValue(source, Class.forName(canonicalName));
|
||||
@@ -58,7 +59,7 @@ final class CommandJsonMapper {
|
||||
|
||||
var json = mapper.convertValue(source, ObjectNode.class);
|
||||
|
||||
json.set(CLASS_ATTRIBUTE, new TextNode(source.getClass().getCanonicalName()));
|
||||
json.set(COMMAND_JSON_CLASS_ATTRIBUTE, new TextNode(source.getClass().getCanonicalName()));
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
+33
-5
@@ -30,14 +30,42 @@ import org.mapstruct.Mapping;
|
||||
@Mapper(componentModel = SPRING, injectionStrategy = CONSTRUCTOR, uses = { CommandJsonMapper.class })
|
||||
public interface CommandMapper {
|
||||
|
||||
@Mapping(ignore = true, target = "id")
|
||||
@Mapping(source = "id", target = "commandId")
|
||||
@Mapping(source = "commandId", target = "id")
|
||||
@Mapping(source = "createdAt", target = "createdAt")
|
||||
@Mapping(source = "tenantId", target = "tenantId")
|
||||
@Mapping(source = "username", target = "username")
|
||||
@Mapping(source = "payload", target = "payload")
|
||||
@Mapping(source = "updatedAt", target = "updatedAt")
|
||||
@Mapping(source = "executedAt", target = "executedAt")
|
||||
@Mapping(source = "approvedAt", target = "approvedAt")
|
||||
@Mapping(source = "rejectedAt", target = "rejectedAt")
|
||||
@Mapping(source = "initiatedByUsername", target = "initiatedByUsername")
|
||||
@Mapping(source = "executedByUsername", target = "executedByUsername")
|
||||
@Mapping(source = "approvedByUsername", target = "approvedByUsername")
|
||||
@Mapping(source = "rejectedByUsername", target = "rejectedByUsername")
|
||||
@Mapping(source = "payload", target = "request")
|
||||
@Mapping(source = "ipAddress", target = "ipAddress")
|
||||
@Mapping(source = "idempotencyKey", target = "idempotencyKey")
|
||||
@Mapping(source = "error", target = "error")
|
||||
@Mapping(ignore = true, target = "state")
|
||||
@Mapping(ignore = true, target = "response")
|
||||
CommandEntity map(Command source);
|
||||
|
||||
@Mapping(source = "source.commandId", target = "id")
|
||||
@Mapping(source = "source.createdAt", target = "createdAt")
|
||||
@Mapping(source = "source.updatedAt", target = "updatedAt")
|
||||
@Mapping(source = "source.executedAt", target = "executedAt")
|
||||
@Mapping(source = "source.approvedAt", target = "approvedAt")
|
||||
@Mapping(source = "source.rejectedAt", target = "rejectedAt")
|
||||
@Mapping(source = "source.initiatedByUsername", target = "initiatedByUsername")
|
||||
@Mapping(source = "source.executedByUsername", target = "executedByUsername")
|
||||
@Mapping(source = "source.approvedByUsername", target = "approvedByUsername")
|
||||
@Mapping(source = "source.rejectedByUsername", target = "rejectedByUsername")
|
||||
@Mapping(source = "source.payload", target = "request")
|
||||
@Mapping(source = "source.ipAddress", target = "ipAddress")
|
||||
@Mapping(source = "source.idempotencyKey", target = "idempotencyKey")
|
||||
@Mapping(source = "source.error", target = "error")
|
||||
@Mapping(source = "response", target = "response")
|
||||
@Mapping(ignore = true, target = "state")
|
||||
CommandEntity map(Command source, Object response);
|
||||
|
||||
@InheritInverseConfiguration
|
||||
Command map(CommandEntity source);
|
||||
}
|
||||
|
||||
+2
-5
@@ -23,8 +23,6 @@ import com.lmax.disruptor.WaitStrategy;
|
||||
import com.lmax.disruptor.YieldingWaitStrategy;
|
||||
import com.lmax.disruptor.dsl.Disruptor;
|
||||
import com.lmax.disruptor.util.DaemonThreadFactory;
|
||||
import java.util.List;
|
||||
import org.apache.fineract.command.core.CommandMiddleware;
|
||||
import org.apache.fineract.command.core.CommandProperties;
|
||||
import org.apache.fineract.command.core.CommandRouter;
|
||||
import org.apache.fineract.command.implementation.DisruptorCommandExecutor;
|
||||
@@ -50,15 +48,14 @@ class CommandConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(value = "fineract.command.executor", havingValue = "disruptor")
|
||||
Disruptor<?> disruptor(CommandProperties properties, WaitStrategy waitStrategy, List<CommandMiddleware> middlewares,
|
||||
CommandRouter router) {
|
||||
Disruptor<?> disruptor(CommandProperties properties, WaitStrategy waitStrategy, CommandRouter router) {
|
||||
// TODO: make this more configurable
|
||||
|
||||
// Create the disruptor
|
||||
Disruptor<DisruptorCommandExecutor.CommandEvent> disruptor = new Disruptor<>(DisruptorCommandExecutor.CommandEvent::new,
|
||||
properties.getRingBufferSize(), DaemonThreadFactory.INSTANCE, properties.getProducerType(), waitStrategy);
|
||||
|
||||
disruptor.handleEventsWith(new DisruptorCommandExecutor.CompleteableCommandEventHandler(middlewares, router));
|
||||
disruptor.handleEventsWith(new DisruptorCommandExecutor.CompleteableCommandEventHandler(router));
|
||||
disruptor.setDefaultExceptionHandler(new IgnoreExceptionHandler());
|
||||
|
||||
// Start the disruptor
|
||||
|
||||
+15
-1
@@ -18,9 +18,23 @@
|
||||
*/
|
||||
package org.apache.fineract.command.starter;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.command.persistence.converter.JsonNodeReadingConverter;
|
||||
import org.apache.fineract.command.persistence.converter.JsonNodeWritingConverter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jdbc.core.convert.JdbcCustomConversions;
|
||||
import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories;
|
||||
|
||||
@Configuration
|
||||
@EnableJdbcRepositories(basePackages = { "org.apache.fineract.command.persistence" })
|
||||
@ComponentScan("org.apache.fineract.command.persistence")
|
||||
class CommandPersistenceConfiguration {}
|
||||
class CommandPersistenceConfiguration {
|
||||
|
||||
@Bean
|
||||
JdbcCustomConversions customConversions(JsonNodeReadingConverter jsonNodeReadingConverter,
|
||||
JsonNodeWritingConverter jsonNodeWritingConverter) {
|
||||
return new JdbcCustomConversions(List.of(jsonNodeReadingConverter, jsonNodeWritingConverter));
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
<include relativeToChangelogFile="true" file="parts/0001_command_init.xml"/>
|
||||
<include relativeToChangelogFile="true" file="parts/0002_command_fix_structure.xml"/>
|
||||
</databaseChangeLog>
|
||||
+10
@@ -23,6 +23,11 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
<changeSet author="fineract" id="command-00001" context="postgresql">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<tableExists tableName="m_command"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_command">
|
||||
<column autoIncrement="true" name="id" type="bigint" remarks="Internal ID">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
@@ -35,6 +40,11 @@
|
||||
</createTable>
|
||||
</changeSet>
|
||||
<changeSet author="fineract" id="command-00002" context="mysql">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<tableExists tableName="command"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<createTable tableName="command">
|
||||
<column autoIncrement="true" name="id" type="bigint" remarks="Internal ID">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
<changeSet author="fineract" id="command-00002" context="mysql">
|
||||
<renameTable oldTableName="command" newTableName="m_command" />
|
||||
<addColumn tableName="m_command">
|
||||
<column name="updated_at" type="datetime" />
|
||||
<column name="executed_at" type="datetime" />
|
||||
<column name="approved_at" type="datetime" />
|
||||
<column name="rejected_at" type="datetime" />
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
<changeSet author="fineract" id="command-00003" context="postgresql">
|
||||
<addColumn tableName="m_command">
|
||||
<column name="updated_at" type="timestamp with time zone" />
|
||||
<column name="executed_at" type="timestamp with time zone" />
|
||||
<column name="approved_at" type="timestamp with time zone" />
|
||||
<column name="rejected_at" type="timestamp with time zone" />
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
<changeSet author="fineract" id="command-00004" context="postgresql,mysql">
|
||||
<renameColumn tableName="m_command" oldColumnName="username" newColumnName="initiated_by_username" columnDataType="varchar(255)" />
|
||||
<renameColumn tableName="m_command" oldColumnName="payload" newColumnName="request" columnDataType="text" />
|
||||
|
||||
<addColumn tableName="m_command">
|
||||
<column name="idempotency_key" type="varchar(255)" />
|
||||
<column name="executed_by_username" type="varchar(255)" />
|
||||
<column name="approved_by_username" type="varchar(255)" />
|
||||
<column name="rejected_by_username" type="varchar(255)" />
|
||||
<column name="ip_address" type="varchar(255)" />
|
||||
<column name="state" type="varchar(255)" />
|
||||
<column name="response" type="text" />
|
||||
<column name="error" type="text" />
|
||||
</addColumn>
|
||||
<createIndex tableName="m_command" indexName="m_command_idempotency_key_index">
|
||||
<column name="idempotency_key" />
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
|
||||
|
||||
<persistence version="2.0"
|
||||
xmlns="http://java.sun.com/xml/ns/persistence"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
|
||||
|
||||
<!-- This file is only used for static weaving, nothing more. -->
|
||||
<!-- You can find the runtime configuration in the JPAConfig class -->
|
||||
<persistence-unit name="jpa-pu" transaction-type="RESOURCE_LOCAL">
|
||||
<provider>org.eclipse.persistence.jpa.PersistenceProvider</provider>
|
||||
|
||||
<!-- Command Module Entities -->
|
||||
<class>org.apache.fineract.command.persistence.domain.CommandEntity</class>
|
||||
<class>org.apache.fineract.command.persistence.converter.JsonAttributeConverter</class>
|
||||
|
||||
<exclude-unlisted-classes>false</exclude-unlisted-classes>
|
||||
<properties>
|
||||
<property name="eclipselink.weaving" value="static" />
|
||||
</properties>
|
||||
</persistence-unit>
|
||||
</persistence>
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
package org.apache.fineract.command;
|
||||
|
||||
import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.persistence.domain.CommandRepository;
|
||||
import org.apache.fineract.command.persistence.mapping.CommandMapper;
|
||||
@@ -27,9 +26,12 @@ import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.MariaDBContainer;
|
||||
import org.testcontainers.containers.MySQLContainer;
|
||||
import org.testcontainers.containers.Network;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
@Slf4j
|
||||
@SpringBootTest
|
||||
@@ -40,9 +42,16 @@ abstract class CommandBaseTest {
|
||||
protected static Network network = Network.newNetwork();
|
||||
|
||||
@Container
|
||||
protected static final GenericContainer POSTGRES_CONTAINER = new GenericContainer("postgres:16").withNetwork(network)
|
||||
.withNetworkAliases("postgres").withExposedPorts(5432)
|
||||
.withEnv(Map.of("POSTGRES_DB", "fineract-test", "POSTGRES_USER", "root", "POSTGRES_PASSWORD", "mifos"));
|
||||
private static final PostgreSQLContainer<?> POSTGRES_CONTAINER = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16"))
|
||||
.withNetwork(network).withUsername("root").withPassword("mifos").withDatabaseName("fineract-test");
|
||||
|
||||
@Container
|
||||
private static final MariaDBContainer<?> MARIADB_CONTAINER = new MariaDBContainer<>(DockerImageName.parse("mariadb:11.4"))
|
||||
.withNetwork(network).withUsername("root").withPassword("mifos").withDatabaseName("fineract-test");
|
||||
|
||||
@Container
|
||||
private static final MySQLContainer<?> MYSQL_CONTAINER = new MySQLContainer<>(DockerImageName.parse("mysql:8")).withNetwork(network)
|
||||
.withUsername("root").withPassword("mifos").withDatabaseName("fineract-test");
|
||||
|
||||
@Autowired
|
||||
protected CommandRepository commandRepository;
|
||||
@@ -52,13 +61,43 @@ abstract class CommandBaseTest {
|
||||
|
||||
@DynamicPropertySource
|
||||
protected static void configure(DynamicPropertyRegistry registry) {
|
||||
postgres(registry);
|
||||
// mariadb(registry);
|
||||
// mysql(registry);
|
||||
}
|
||||
|
||||
private static void postgres(DynamicPropertyRegistry registry) {
|
||||
POSTGRES_CONTAINER.start();
|
||||
|
||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||
registry.add("spring.datasource.username", () -> "root");
|
||||
registry.add("spring.datasource.password", () -> "mifos");
|
||||
registry.add("spring.datasource.url", () -> "jdbc:postgresql://" + POSTGRES_CONTAINER.getHost() + ":"
|
||||
+ POSTGRES_CONTAINER.getMappedPort(5432) + "/fineract-test");
|
||||
registry.add("spring.datasource.driver-class-name", POSTGRES_CONTAINER::getDriverClassName);
|
||||
registry.add("spring.datasource.username", POSTGRES_CONTAINER::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES_CONTAINER::getPassword);
|
||||
registry.add("spring.datasource.url", POSTGRES_CONTAINER::getJdbcUrl);
|
||||
registry.add("spring.datasource.platform", () -> "postgresql");
|
||||
registry.add("spring.liquibase.contexts", () -> "postgresql");
|
||||
}
|
||||
|
||||
@SuppressWarnings("UnusedMethod")
|
||||
private static void mariadb(DynamicPropertyRegistry registry) {
|
||||
MARIADB_CONTAINER.start();
|
||||
|
||||
registry.add("spring.datasource.driver-class-name", MARIADB_CONTAINER::getDriverClassName);
|
||||
registry.add("spring.datasource.username", MARIADB_CONTAINER::getUsername);
|
||||
registry.add("spring.datasource.password", MARIADB_CONTAINER::getPassword);
|
||||
registry.add("spring.datasource.url", MARIADB_CONTAINER::getJdbcUrl);
|
||||
registry.add("spring.datasource.platform", () -> "mysql");
|
||||
registry.add("spring.liquibase.contexts", () -> "mysql");
|
||||
}
|
||||
|
||||
@SuppressWarnings("UnusedMethod")
|
||||
private static void mysql(DynamicPropertyRegistry registry) {
|
||||
MYSQL_CONTAINER.start();
|
||||
|
||||
registry.add("spring.datasource.driver-class-name", MYSQL_CONTAINER::getDriverClassName);
|
||||
registry.add("spring.datasource.username", MYSQL_CONTAINER::getUsername);
|
||||
registry.add("spring.datasource.password", MYSQL_CONTAINER::getPassword);
|
||||
registry.add("spring.datasource.url", MYSQL_CONTAINER::getJdbcUrl);
|
||||
registry.add("spring.datasource.platform", () -> "mysql");
|
||||
registry.add("spring.liquibase.contexts", () -> "mysql");
|
||||
}
|
||||
}
|
||||
|
||||
+22
-4
@@ -18,10 +18,13 @@
|
||||
*/
|
||||
package org.apache.fineract.command;
|
||||
|
||||
import java.util.UUID;
|
||||
import static org.apache.fineract.command.persistence.domain.CommandEntityState.ERROR;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.sample.command.DummyCommand;
|
||||
import org.apache.fineract.command.sample.data.DummyRequest;
|
||||
import org.apache.fineract.command.sample.data.DummyResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
@@ -35,10 +38,25 @@ class CommandPersistenceTest extends CommandBaseTest {
|
||||
void save() {
|
||||
var content = "hello";
|
||||
var command = new DummyCommand();
|
||||
command.setId(UUID.randomUUID());
|
||||
command.setPayload(DummyRequest.builder().content(content).build());
|
||||
var now = Instant.now();
|
||||
|
||||
var commandEntity = commandMapper.map(command);
|
||||
command.setPayload(DummyRequest.builder().content(content).build());
|
||||
command.setIdempotencyKey("1234567890");
|
||||
command.setIpAddress("127.0.0.1");
|
||||
command.setCreatedAt(now);
|
||||
command.setExecutedAt(now);
|
||||
command.setApprovedAt(now);
|
||||
command.setRejectedAt(now);
|
||||
command.setInitiatedByUsername("abc");
|
||||
command.setExecutedByUsername("abc");
|
||||
command.setApprovedByUsername("abc");
|
||||
command.setRejectedByUsername("abc");
|
||||
|
||||
var response = DummyResponse.builder().tenantId("dummy").content(content).build();
|
||||
|
||||
var commandEntity = commandMapper.map(command, response);
|
||||
commandEntity.setState(ERROR);
|
||||
commandEntity.setError("Some error message");
|
||||
|
||||
var result = commandRepository.save(commandEntity);
|
||||
|
||||
|
||||
+26
-12
@@ -18,27 +18,31 @@
|
||||
*/
|
||||
package org.apache.fineract.command;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lmax.disruptor.YieldingWaitStrategy;
|
||||
import com.lmax.disruptor.dsl.Disruptor;
|
||||
import com.lmax.disruptor.dsl.ProducerType;
|
||||
import com.lmax.disruptor.util.DaemonThreadFactory;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.CommandPipeline;
|
||||
import org.apache.fineract.command.core.CommandRouter;
|
||||
import org.apache.fineract.command.core.CommandProperties;
|
||||
import org.apache.fineract.command.implementation.DefaultCommandAuditor;
|
||||
import org.apache.fineract.command.implementation.DefaultCommandPipeline;
|
||||
import org.apache.fineract.command.implementation.DefaultCommandRouter;
|
||||
import org.apache.fineract.command.implementation.DisruptorCommandExecutor;
|
||||
import org.apache.fineract.command.persistence.domain.CommandRepository;
|
||||
import org.apache.fineract.command.persistence.mapping.CommandJsonMapper;
|
||||
import org.apache.fineract.command.persistence.mapping.CommandMapperImpl;
|
||||
import org.apache.fineract.command.sample.command.DummyCommand;
|
||||
import org.apache.fineract.command.sample.data.DummyRequest;
|
||||
import org.apache.fineract.command.sample.data.DummyResponse;
|
||||
import org.apache.fineract.command.sample.handler.DummyCommandHandler;
|
||||
import org.apache.fineract.command.sample.middleware.DummyIdempotencyMiddleware;
|
||||
import org.apache.fineract.command.sample.middleware.DummyMiddleware;
|
||||
import org.apache.fineract.command.sample.service.DefaultDummyService;
|
||||
import org.apache.fineract.command.sample.service.DefaultDummyTenantService;
|
||||
import org.openjdk.jmh.annotations.Benchmark;
|
||||
@@ -58,26 +62,37 @@ import org.openjdk.jmh.annotations.TearDown;
|
||||
@SuppressWarnings({ "raw" })
|
||||
public class CommandPipelineBenchmark {
|
||||
|
||||
private CommandRouter router;
|
||||
private Disruptor<DisruptorCommandExecutor.CommandEvent> disruptor;
|
||||
|
||||
private CommandPipeline pipeline;
|
||||
|
||||
@Setup(Level.Iteration)
|
||||
public void setUp() {
|
||||
this.router = new DefaultCommandRouter(List.of(new DummyCommandHandler(new DefaultDummyService(new DefaultDummyTenantService()))));
|
||||
// create router
|
||||
var router = new DefaultCommandRouter(List.of(new DummyCommandHandler(new DefaultDummyService(new DefaultDummyTenantService()))));
|
||||
|
||||
// Create the disruptor
|
||||
// create mapper
|
||||
var mapper = new CommandMapperImpl(new CommandJsonMapper(new ObjectMapper()));
|
||||
|
||||
// mock repository
|
||||
var repository = mock(CommandRepository.class);
|
||||
|
||||
// default properties
|
||||
var properties = new CommandProperties();
|
||||
|
||||
// create auditor
|
||||
var auditor = new DefaultCommandAuditor(mapper, repository, properties, new ObjectMapper());
|
||||
|
||||
// create the disruptor
|
||||
this.disruptor = new Disruptor<>(DisruptorCommandExecutor.CommandEvent::new, 2048, DaemonThreadFactory.INSTANCE, ProducerType.MULTI,
|
||||
new YieldingWaitStrategy());
|
||||
|
||||
disruptor.handleEventsWith(new DisruptorCommandExecutor.CompleteableCommandEventHandler(
|
||||
List.of(new DummyMiddleware(), new DummyIdempotencyMiddleware()), router));
|
||||
disruptor.handleEventsWith(new DisruptorCommandExecutor.CompleteableCommandEventHandler(router));
|
||||
|
||||
// Start the disruptor
|
||||
// start the disruptor
|
||||
disruptor.start();
|
||||
|
||||
pipeline = new DefaultCommandPipeline(new DisruptorCommandExecutor(disruptor));
|
||||
pipeline = new DefaultCommandPipeline(new DisruptorCommandExecutor(disruptor, router, auditor), properties);
|
||||
}
|
||||
|
||||
@TearDown(Level.Iteration)
|
||||
@@ -89,7 +104,6 @@ public class CommandPipelineBenchmark {
|
||||
@Benchmark
|
||||
public void processCommand() {
|
||||
var command = new DummyCommand();
|
||||
command.setId(UUID.randomUUID());
|
||||
command.setPayload(DummyRequest.builder().content("hello").build());
|
||||
|
||||
Supplier<DummyResponse> result = pipeline.send(command);
|
||||
|
||||
+43
-12
@@ -21,14 +21,20 @@ package org.apache.fineract.command;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.CommandPipeline;
|
||||
import org.apache.fineract.command.persistence.domain.CommandRepository;
|
||||
import org.apache.fineract.command.sample.command.DummyCommand;
|
||||
import org.apache.fineract.command.sample.command.DummyErrorCommand;
|
||||
import org.apache.fineract.command.sample.data.DummyErrorRequest;
|
||||
import org.apache.fineract.command.sample.data.DummyErrorResponse;
|
||||
import org.apache.fineract.command.sample.data.DummyRequest;
|
||||
import org.apache.fineract.command.sample.data.DummyResponse;
|
||||
import org.apache.fineract.command.sample.exception.DummyException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -42,29 +48,54 @@ class CommandPipelineTest extends CommandBaseTest {
|
||||
@Autowired
|
||||
private CommandPipeline pipeline;
|
||||
|
||||
@Autowired
|
||||
private CommandRepository repository;
|
||||
|
||||
@Test
|
||||
void processCommand() {
|
||||
void process() {
|
||||
var content = "hello";
|
||||
var command = new DummyCommand();
|
||||
command.setId(UUID.randomUUID());
|
||||
command.setPayload(DummyRequest.builder().content(content).build());
|
||||
|
||||
var result = pipeline.send(command);
|
||||
command.setPayload(DummyRequest.builder().content(content).build());
|
||||
command.setIdempotencyKey("1234567890");
|
||||
command.setIpAddress("127.0.0.1");
|
||||
command.setInitiatedByUsername("abc");
|
||||
|
||||
Supplier<DummyResponse> result = pipeline.send(command);
|
||||
|
||||
assertNotNull(result, "Response should not be null.");
|
||||
|
||||
Object response = result.get();
|
||||
var response = result.get();
|
||||
|
||||
assertNotNull(response, "Response should not be null.");
|
||||
|
||||
assertInstanceOf(DummyResponse.class, response, "Response is of wrong type.");
|
||||
|
||||
if (response instanceof DummyResponse dummyResponse) {
|
||||
log.warn("Result: {}", dummyResponse);
|
||||
log.info("Result: {}", response);
|
||||
log.info("Command ID: {}", command.getCommandId());
|
||||
|
||||
assertNotNull(dummyResponse.getContent(), "Response body should not be null.");
|
||||
assertNotNull(dummyResponse.getRequestId(), "Request ID should not be null.");
|
||||
assertEquals(content.toUpperCase(Locale.ROOT), dummyResponse.getContent(), "Wrong response content.");
|
||||
}
|
||||
assertNotNull(command.getCommandId(), "Command should have an ID after processing.");
|
||||
assertNotNull(response.getContent(), "Response body should not be null.");
|
||||
assertEquals(content.toUpperCase(Locale.ROOT), response.getContent(), "Wrong response content.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void error() {
|
||||
var content = "hello error";
|
||||
var command = new DummyErrorCommand();
|
||||
|
||||
command.setPayload(DummyErrorRequest.builder().content(content).build());
|
||||
|
||||
Supplier<DummyErrorResponse> result = pipeline.send(command);
|
||||
|
||||
var exception = assertThrows(DummyException.class, () -> {
|
||||
var response = result.get();
|
||||
});
|
||||
|
||||
assertNotNull(exception, "Expected exception!");
|
||||
|
||||
var commands = repository.findAll();
|
||||
|
||||
log.info("All commands: {}", commands);
|
||||
}
|
||||
}
|
||||
|
||||
+68
-15
@@ -18,7 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.command;
|
||||
|
||||
import static org.apache.fineract.command.core.CommandConstants.COMMAND_REQUEST_ID;
|
||||
import static org.apache.fineract.command.core.CommandConstants.COMMAND_HTTP_HEADER_REQUEST_ID;
|
||||
import static org.apache.fineract.command.core.CommandConstants.COMMAND_HTTP_HEADER_TENANT_ID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -35,14 +36,19 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.CommandAuditor;
|
||||
import org.apache.fineract.command.core.CommandProperties;
|
||||
import org.apache.fineract.command.sample.data.DummyRequest;
|
||||
import org.apache.fineract.command.sample.data.DummyResponse;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
@@ -63,13 +69,19 @@ class CommandSampleApiTest extends CommandBaseTest {
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private CommandAuditor auditor;
|
||||
|
||||
@Autowired
|
||||
private CommandProperties properties;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
this.baseUrl = "http://localhost:" + port + "/test/dummy";
|
||||
this.interceptors = Collections.singletonList((request, body, execution) -> {
|
||||
this.interceptors = List.of((request, body, execution) -> {
|
||||
var headers = request.getHeaders();
|
||||
headers.add(COMMAND_REQUEST_ID, UUID.randomUUID().toString());
|
||||
headers.add("x-fineract-tenant-id", "dummy");
|
||||
headers.add(COMMAND_HTTP_HEADER_REQUEST_ID, UUID.randomUUID().toString());
|
||||
headers.add(COMMAND_HTTP_HEADER_TENANT_ID, "dummy");
|
||||
headers.add(CONTENT_TYPE, APPLICATION_JSON_VALUE);
|
||||
headers.addAll(ACCEPT, List.of(APPLICATION_JSON_VALUE, APPLICATION_PROBLEM_JSON_VALUE));
|
||||
return execution.execute(request, body);
|
||||
@@ -88,45 +100,86 @@ class CommandSampleApiTest extends CommandBaseTest {
|
||||
|
||||
@Test
|
||||
void dummyApiSync() {
|
||||
var content = "test-sync";
|
||||
final var content = "test-sync";
|
||||
final var idempotencyKey = UUID.randomUUID().toString();
|
||||
|
||||
restTemplate.getRestTemplate().setInterceptors(interceptors);
|
||||
var result = restTemplate.postForObject(baseUrl + "/sync", DummyRequest.builder().content(content).build(), DummyResponse.class);
|
||||
|
||||
log.warn("Result (sync) : {} ({})", result.getContent(), result.getRequestId());
|
||||
final var headers = new HttpHeaders();
|
||||
headers.add(properties.getIdemPotencyKeyHeaderName(), idempotencyKey);
|
||||
|
||||
final var request = new HttpEntity<>(DummyRequest.builder().content(content).build(), headers);
|
||||
|
||||
final var result = restTemplate.postForObject(baseUrl + "/sync", request, DummyResponse.class);
|
||||
|
||||
log.warn("Result (sync) : {}", result.getContent());
|
||||
|
||||
assertNotNull(result, "Response should not be null.");
|
||||
assertNotNull(result.getContent(), "Response body should not be null.");
|
||||
assertNotNull(result.getRequestId(), "Request ID should not be null.");
|
||||
assertNotNull(result.getTenantId(), "Tenant ID should not be null.");
|
||||
assertEquals("dummy", result.getTenantId(), "Unexpected tenant ID.");
|
||||
assertEquals(content.toUpperCase(Locale.ROOT), result.getContent(), "Wrong response content.");
|
||||
|
||||
var response = auditor.getResponseByIdempotencyKey(idempotencyKey);
|
||||
|
||||
if (response instanceof DummyResponse dummyResponse) {
|
||||
assertNotNull(dummyResponse, "There should be a command entity stored with a dummyResponse.");
|
||||
assertNotNull(dummyResponse, "Response should not be null.");
|
||||
assertNotNull(dummyResponse.getContent(), "Response body should not be null.");
|
||||
assertNotNull(dummyResponse.getTenantId(), "Tenant ID should not be null.");
|
||||
assertEquals("dummy", dummyResponse.getTenantId(), "Unexpected tenant ID.");
|
||||
assertEquals(content.toUpperCase(Locale.ROOT), dummyResponse.getContent(), "Wrong response content.");
|
||||
}
|
||||
|
||||
log.warn("Command response: {}", response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dummyApiAsync() {
|
||||
var content = "test-async";
|
||||
final var content = "test-async";
|
||||
final var idempotencyKey = UUID.randomUUID().toString();
|
||||
|
||||
restTemplate.getRestTemplate().setInterceptors(interceptors);
|
||||
var result = restTemplate.postForObject(baseUrl + "/async", DummyRequest.builder().content(content).build(), DummyResponse.class);
|
||||
|
||||
log.warn("Result (async): {} ({})", result.getContent(), result.getRequestId());
|
||||
final var headers = new HttpHeaders();
|
||||
headers.add(properties.getIdemPotencyKeyHeaderName(), idempotencyKey);
|
||||
|
||||
final var request = new HttpEntity<>(DummyRequest.builder().content(content).build(), headers);
|
||||
|
||||
final var result = restTemplate.postForObject(baseUrl + "/async", request, DummyResponse.class);
|
||||
|
||||
log.warn("Result (async): {}", result.getContent());
|
||||
|
||||
assertNotNull(result, "Response should not be null.");
|
||||
assertNotNull(result.getContent(), "Response body should not be null.");
|
||||
assertNotNull(result.getRequestId(), "Request ID should not be null.");
|
||||
// TODO: make sure all ThreadLocal variables are available
|
||||
// assertNotNull(result.getTenantId(), "Tenant ID should not be null.");
|
||||
// assertEquals("dummy", result.getTenantId(), "Unexpected tenant ID.");
|
||||
assertEquals(content.toUpperCase(Locale.ROOT), result.getContent(), "Wrong response content.");
|
||||
|
||||
var response = auditor.getResponseByIdempotencyKey(idempotencyKey);
|
||||
|
||||
// TODO: always null; need to fix this before we release async mode
|
||||
if (response instanceof DummyResponse dummyResponse) {
|
||||
assertNotNull(dummyResponse, "There should be a command entity stored with a dummyResponse.");
|
||||
assertNotNull(dummyResponse, "Response should not be null.");
|
||||
assertNotNull(dummyResponse.getContent(), "Response body should not be null.");
|
||||
assertNotNull(dummyResponse.getTenantId(), "Tenant ID should not be null.");
|
||||
assertEquals("dummy", dummyResponse.getTenantId(), "Unexpected tenant ID.");
|
||||
assertEquals(content.toUpperCase(Locale.ROOT), dummyResponse.getContent(), "Wrong response content.");
|
||||
}
|
||||
|
||||
log.warn("Command response: {}", response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Disabled // TODO: implement idempotency properly with backwards compatibility
|
||||
void dummyApiIdempotencyAsync() {
|
||||
dummyApiIdempotency("/async");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Disabled // TODO: implement idempotency properly with backwards compatibility
|
||||
void dummyApiIdempotencySync() {
|
||||
dummyApiIdempotency("/sync");
|
||||
}
|
||||
@@ -137,8 +190,8 @@ class CommandSampleApiTest extends CommandBaseTest {
|
||||
var request = DummyRequest.builder().content(content).build();
|
||||
List<ClientHttpRequestInterceptor> interceptors = Collections.singletonList((req, body, execution) -> {
|
||||
var headers = req.getHeaders();
|
||||
headers.add(COMMAND_REQUEST_ID, id);
|
||||
headers.add("x-fineract-tenant-id", "dummy");
|
||||
headers.add(COMMAND_HTTP_HEADER_REQUEST_ID, id);
|
||||
headers.add(COMMAND_HTTP_HEADER_TENANT_ID, "dummy");
|
||||
headers.add(CONTENT_TYPE, APPLICATION_JSON_VALUE);
|
||||
headers.addAll(ACCEPT, List.of(APPLICATION_JSON_VALUE, APPLICATION_PROBLEM_JSON_VALUE));
|
||||
return execution.execute(req, body);
|
||||
@@ -154,7 +207,7 @@ class CommandSampleApiTest extends CommandBaseTest {
|
||||
// second request fails, because we are using the same request ID in both cases
|
||||
response = restTemplate.postForEntity(baseUrl + path, request, Map.class);
|
||||
|
||||
log.warn("Body: {} - {}", response.getBody(), response.getStatusCode());
|
||||
log.info("Body: {} - {}", response.getBody(), response.getStatusCode());
|
||||
|
||||
// Assert HTTP status
|
||||
assertThat(response.getStatusCode()).isEqualTo(INTERNAL_SERVER_ERROR);
|
||||
|
||||
@@ -21,19 +21,16 @@ package org.apache.fineract.command;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.CommandProperties;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.PropertySource;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({ CommandProperties.class, JpaProperties.class })
|
||||
@EnableConfigurationProperties({ CommandProperties.class })
|
||||
@EnableAutoConfiguration
|
||||
@EnableJpaRepositories(basePackages = { "org.apache.fineract.command.persistence" })
|
||||
@EnableAsync
|
||||
@PropertySource("classpath:application-test.properties")
|
||||
@ComponentScan("org.apache.fineract.command.sample")
|
||||
|
||||
+5
-12
@@ -18,13 +18,10 @@
|
||||
*/
|
||||
package org.apache.fineract.command.sample.api;
|
||||
|
||||
import static org.apache.fineract.command.core.CommandConstants.COMMAND_REQUEST_ID;
|
||||
import static org.apache.fineract.command.core.CommandConstants.COMMAND_HTTP_HEADER_TENANT_ID;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
|
||||
import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -47,19 +44,16 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping(value = "/test/dummy", consumes = APPLICATION_JSON_VALUE, produces = { APPLICATION_JSON_VALUE,
|
||||
APPLICATION_PROBLEM_JSON_VALUE })
|
||||
class DummyApiController {
|
||||
// "application/vnd.fineract+json;charset=UTF-8;version=1.0"
|
||||
|
||||
private final DefaultDummyTenantService tenantService;
|
||||
|
||||
private final CommandPipeline pipeline;
|
||||
|
||||
@PostMapping("/sync")
|
||||
DummyResponse dummySync(@RequestHeader(value = COMMAND_REQUEST_ID, required = false) UUID requestId,
|
||||
@RequestHeader(value = "x-fineract-tenant-id", required = false) String tenantId, @RequestBody DummyRequest request) {
|
||||
DummyResponse dummySync(@RequestHeader(value = COMMAND_HTTP_HEADER_TENANT_ID, required = false) String tenantId,
|
||||
@RequestBody DummyRequest request) {
|
||||
var command = new DummyCommand();
|
||||
command.setId(requestId);
|
||||
command.setPayload(request);
|
||||
command.setCreatedAt(OffsetDateTime.now(ZoneId.of("UTC")));
|
||||
|
||||
tenantService.set(tenantId);
|
||||
|
||||
@@ -70,10 +64,9 @@ class DummyApiController {
|
||||
|
||||
@Async
|
||||
@PostMapping("/async")
|
||||
CompletableFuture<DummyResponse> dummyAsync(@RequestHeader(value = COMMAND_REQUEST_ID, required = false) UUID requestId,
|
||||
@RequestHeader(value = "x-fineract-tenant-id", required = false) String tenantId, @RequestBody DummyRequest request) {
|
||||
CompletableFuture<DummyResponse> dummyAsync(@RequestHeader(value = COMMAND_HTTP_HEADER_TENANT_ID, required = false) String tenantId,
|
||||
@RequestBody DummyRequest request) {
|
||||
var command = new DummyCommand();
|
||||
command.setId(requestId);
|
||||
command.setPayload(request);
|
||||
|
||||
tenantService.set(tenantId);
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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.sample.command;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.sample.data.DummyErrorRequest;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class DummyErrorCommand extends Command<DummyErrorRequest> {}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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.sample.data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@FieldNameConstants
|
||||
public class DummyErrorRequest implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@NotBlank(message = "{org.apache.fineract.dummy.request.content.not-empty}")
|
||||
private String content;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.sample.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@FieldNameConstants
|
||||
public class DummyErrorResponse implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String content;
|
||||
private String error;
|
||||
}
|
||||
-3
@@ -21,7 +21,6 @@ package org.apache.fineract.command.sample.data;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.UUID;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -38,8 +37,6 @@ public class DummyRequest implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private UUID id;
|
||||
|
||||
@NotBlank(message = "{org.apache.fineract.dummy.request.content.not-empty}")
|
||||
private String content;
|
||||
}
|
||||
|
||||
-5
@@ -20,7 +20,6 @@ package org.apache.fineract.command.sample.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.UUID;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -37,11 +36,7 @@ public class DummyResponse implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private UUID requestId;
|
||||
|
||||
private String tenantId;
|
||||
|
||||
private String content;
|
||||
|
||||
private String error;
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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.sample.exception;
|
||||
|
||||
public class DummyException extends RuntimeException {
|
||||
|
||||
public DummyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+10
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.command.sample.handler;
|
||||
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
@@ -26,6 +27,7 @@ import org.apache.fineract.command.sample.data.DummyRequest;
|
||||
import org.apache.fineract.command.sample.data.DummyResponse;
|
||||
import org.apache.fineract.command.sample.service.DummyService;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@@ -34,8 +36,16 @@ public class DummyCommandHandler implements CommandHandler<DummyRequest, DummyRe
|
||||
|
||||
private final DummyService dummyService;
|
||||
|
||||
@Retry(name = "commandDummy", fallbackMethod = "fallback")
|
||||
@Transactional
|
||||
@Override
|
||||
public DummyResponse handle(Command<DummyRequest> command) {
|
||||
return dummyService.process(command.getPayload());
|
||||
}
|
||||
|
||||
@Override
|
||||
public DummyResponse fallback(Command<DummyRequest> command, Throwable t) {
|
||||
// NOTE: fallback method needs to be in the same class
|
||||
return CommandHandler.super.fallback(command, t);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-19
@@ -16,35 +16,24 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.command.sample.middleware;
|
||||
package org.apache.fineract.command.sample.handler;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandMiddleware;
|
||||
import org.apache.fineract.command.core.exception.CommandIllegalArgumentException;
|
||||
import org.apache.fineract.command.sample.command.DummyCommand;
|
||||
import org.apache.fineract.command.core.CommandHandler;
|
||||
import org.apache.fineract.command.sample.data.DummyErrorRequest;
|
||||
import org.apache.fineract.command.sample.data.DummyErrorResponse;
|
||||
import org.apache.fineract.command.sample.exception.DummyException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class DummyIdempotencyMiddleware implements CommandMiddleware {
|
||||
|
||||
// NOTE: in production you would use of course a database or Redis
|
||||
private static final List<UUID> IDS = new ArrayList<>();
|
||||
public class DummyErrorCommandHandler implements CommandHandler<DummyErrorRequest, DummyErrorResponse> {
|
||||
|
||||
@Override
|
||||
public void invoke(Command<?> command) {
|
||||
if (command instanceof DummyCommand c) {
|
||||
if (IDS.contains(c.getId())) {
|
||||
throw new CommandIllegalArgumentException(c, "Duplicate request ID: " + c.getId());
|
||||
}
|
||||
|
||||
IDS.add(c.getId());
|
||||
}
|
||||
public DummyErrorResponse handle(Command<DummyErrorRequest> command) {
|
||||
throw new DummyException("Simulating a business logic error: " + command.getPayload().getContent());
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -34,7 +34,6 @@ public class DefaultDummyService implements DummyService {
|
||||
|
||||
@Override
|
||||
public DummyResponse process(DummyRequest request) {
|
||||
return DummyResponse.builder().requestId(request.getId()).tenantId(tenantService.get())
|
||||
.content(request.getContent().toUpperCase(Locale.ROOT)).build();
|
||||
return DummyResponse.builder().tenantId(tenantService.get()).content(request.getContent().toUpperCase(Locale.ROOT)).build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ fineract.command.enabled=true
|
||||
fineract.command.executor=sync
|
||||
fineract.command.ring-buffer-size=1024
|
||||
fineract.command.producer-type=single
|
||||
fineract.command.auditable=true
|
||||
|
||||
spring.liquibase.enabled=true
|
||||
spring.liquibase.drop-first=true
|
||||
spring.liquibase.default-schema=public
|
||||
spring.liquibase.contexts=postgresql
|
||||
spring.liquibase.change-log=classpath:/db/custom-changelog/0001_command_init.xml
|
||||
spring.liquibase.change-log=classpath:/db/changelog/tenant/module/command/module-changelog-master.xml
|
||||
|
||||
Reference in New Issue
Block a user