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:
Aleksandar Vidakovic
2026-02-05 12:55:05 +01:00
committed by Aleksandar Vidakovic
co-authored by Adam Saghy Yu Wati Nyi
parent 1d1876edfd
commit 8d064ddf6c
73 changed files with 1217 additions and 363 deletions
@@ -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");
}
}
@@ -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);
@@ -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);
@@ -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);
}
}
@@ -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")
@@ -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);
@@ -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> {}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
@@ -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);
}
}
@@ -16,24 +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 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 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 DummyMiddleware implements CommandMiddleware {
public class DummyErrorCommandHandler implements CommandHandler<DummyErrorRequest, DummyErrorResponse> {
@Override
public void invoke(Command<?> command) {
if (command instanceof DummyCommand c) {
c.getPayload().setId(command.getId());
}
public DummyErrorResponse handle(Command<DummyErrorRequest> command) {
throw new DummyException("Simulating a business logic error: " + command.getPayload().getContent());
}
}
@@ -1,50 +0,0 @@
/**
* 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.middleware;
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.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<>();
@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());
}
}
}
@@ -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