FINERACT-2169: New command processing infrastructure

This commit is contained in:
Aleksandar Vidakovic
2025-04-22 21:50:28 +02:00
committed by Aleksandar Vidakovic
parent fd45f43f36
commit 8d83b5547c
69 changed files with 3069 additions and 16 deletions
+3 -1
View File
@@ -28,6 +28,7 @@ buildscript {
[
'fineract-api',
'fineract-core',
'fineract-command',
'fineract-accounting',
'fineract-provider',
'fineract-branch',
@@ -103,7 +104,7 @@ plugins {
id 'com.gorylenko.gradle-git-properties' version '2.4.2' apply false
id 'org.asciidoctor.jvm.convert' version '3.3.2' apply false
id 'org.asciidoctor.jvm.pdf' version '3.3.2' apply false
id 'com.google.cloud.tools.jib' version '3.4.4' apply false
id 'com.google.cloud.tools.jib' version '3.4.5' apply false
id 'org.sonarqube' version '6.0.1.5171'
id 'com.github.andygoossens.modernizer' version '1.10.0' apply false
// TODO: upgrade to 6.0.4
@@ -112,6 +113,7 @@ plugins {
id "com.github.davidmc24.gradle.plugin.avro-base" version "1.9.1" apply false
id 'org.openapi.generator' version '7.8.0' apply false
id 'com.gradleup.shadow' version '8.3.5' apply false
id 'me.champeau.jmh' version '0.7.1' apply false
}
apply from: "${rootDir}/buildSrc/src/main/groovy/org.apache.fineract.release.gradle"
@@ -37,6 +37,7 @@ dependencyManagement {
mavenBom 'org.mockito:mockito-bom:5.14.2'
mavenBom 'software.amazon.awssdk:bom:2.29.9'
mavenBom 'io.github.resilience4j:resilience4j-bom:2.2.0'
mavenBom 'org.testcontainers:testcontainers-bom:1.20.4'
}
dependencies {
@@ -235,6 +236,8 @@ dependencyManagement {
//v42.7.5: performance issue: https://github.com/pgjdbc/pgjdbc/issues/3511#issuecomment-2637277977
dependency 'org.postgresql:postgresql:42.7.4'
dependency 'com.mysql:mysql-connector-j:9.2.0'
dependency 'org.assertj:assertj-core:3.26.3'
dependency 'org.apache.commons:commons-math3:3.6.1'
@@ -249,5 +252,18 @@ dependencyManagement {
dependency 'io.cucumber:cucumber-spring:7.20.1'
dependency 'org.reflections:reflections:0.10.2'
dependency 'org.openjdk.jmh:jmh-core:1.37'
dependency 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
dependency 'org.springframework.restdocs:spring-restdocs-asciidoctor:3.0.3'
dependency 'org.springframework.restdocs:spring-restdocs-mockmvc:3.0.3'
dependency 'org.springframework.restdocs:spring-restdocs-webtestclient:3.0.3'
dependency 'org.springframework.restdocs:spring-restdocs-restassured:3.0.3'
dependency 'com.lmax:disruptor:3.4.4'
dependency 'com.ibm.icu:icu4j:76.1'
dependency 'org.yakworks:spring-icu4j:0.4.2'
}
}
+4
View File
@@ -29,4 +29,8 @@
<suppress files="[/\\]build[/\\]generated[/\\]" checks=".*"/>
<!-- TODO: fix integration test formatting -->
<suppress files="[/\\]integration-tests[/\\]src[/\\]test[/\\]main[/\\]" checks=".*"/>
<!-- TODO: fix generic type name constraints -->
<suppress files="[/\\]fineract-command[/\\]src[/\\]main[/\\]java[/\\]" checks="MethodTypeParameterName"/>
<suppress files="[/\\]fineract-command[/\\]src[/\\]main[/\\]java[/\\]" checks="ClassTypeParameterName"/>
<suppress files="[/\\]fineract-command[/\\]src[/\\]main[/\\]java[/\\]" checks="HideUtilityClassConstructor"/>
</suppressions>
+1 -1
View File
@@ -76,7 +76,7 @@ jib {
}
// NOTE: other custom dependencies
implementation 'org.mariadb.jdbc:mariadb-java-client'
implementation 'mysql:mysql-connector-java:8.0.33'
implementation 'com.mysql:mysql-connector-j'
implementation 'org.postgresql:postgresql'
annotationProcessor('org.springframework.boot:spring-boot-autoconfigure-processor')
annotationProcessor('org.springframework.boot:spring-boot-configuration-processor')
+137
View File
@@ -0,0 +1,137 @@
Background and Motivation
-------------------------
Fineract accumulated some technical debt over the years. One area that is implicated is type-safety of internal and external facing APIs, the most prominent of which is Fineract's REST API. In general the package layout of the project reflects a more or less classic layered architecture (REST API, data transfer/value objects, business logic services, storage/repositories). The project predates some of the more modern frameworks and best practices that are available today and on occasions the data structures that are exchanged offer some challenges (e.g. generic types). Fineract's code base reflects that, especially where JSON de-/serialization is involved. Nowadays, this task would be simply delegated to the Jackson framework, but when Fineract (Mifos) started the decision was made to use Google's GSON library and create handcrafted helper classes to deal with JSON parsing. While this provided a lot of flexibility this approach had some downsides:
- the lowest common denominator is the string type (aka JSON blob); this is where we lose the type information
- the strings are transformed into JSONObjects; a little bit better than raw strings, but barely more than a hash map
- a ton of "magic" strings are needed to get/set values
- this approach makes refactoring unnecessarily more difficult
- to be able to serve an OpenAPI descriptor (as JSON and YAML) we had to re-introduce the type information at the REST API level with dummy classes that contain only the specified attributes; those classes are only used with the Swagger annotations and no were else
- some developers skipped the layered architecture and found it too tedious to maintain DTOs and JSON helper classes, and as a result just passed JSONObjects right to the business logic layer
- now the business logic is unnecessarily aware of how Fineract communicates to the outside world and makes replacing/enhancing the communication protocol (e.g. with GRPC) pretty much impossible
The list doesn't end here, but in the end things boil down to two main points:
- poor developer experience: boilerplate code and missing type safety cost more time
- bugs: the more code the more likely errors get introduced, especially when type safety is missing and we have to rely on runtime errors (vs. compile time).
There has been already some preparatory work done concerning type safety, but until now we avoided dealing with the real source of this issue. Fineract's architectures devises read from write requests ("CQRS", https://martinfowler.com/bliki/CQRS.html) for improved scalability.
The read requests are not that problematic, but all write requests pass through a component/service that is called "SynchronousCommandProcessingService. As the name suggests the execution of business logic is synchronous (mostly) due to this part of the architecture. This is not necessarily a problem (not immediately at least), but it's nevertheless a central bottleneck in the system. Even more important: this service is responsible to route incoming commands to their respective handler classes which in turn execute functions on one or more business logic services. The payload of these commands are obviously not always the same... which is the main reason why we decided to use the lowest common denominator to be able to handle these various types and rendered all payloads as strings. This compromise bubbles now up in the REST API and the business logic layers (and actually everything in between).
Over the years we've also added additional features (e.g. idempotency guarantees for incoming write requests) that make it now very hard to reason about the execution flow. Testing the performance impact of such additions to the critical execution path even can't be properly measured. Note: the current implementation of idempotency relies on database lookups (quite often, for each incoming request) and none of those queries are cached. If we wanted to store already processed requests (IDs) in a faster system (let's Redis) then this can't be done without major refactoring.
In conclusion, if we really want to fix those issues that are not only cosmetic and affect the performance and the developer experience equally then we urgently need to fix the way how we process write requests aka commands.
Target Personas
---------------
- developers
- integrators
- end users
- BaaS
Goals
-----
- new command processing will run independently next to the legacy mechanics
- self contained
- fully tested
- ensure that the REST API is 100% backward compatible
- try to contain the migration and make it as easy as possible for the community to integrate those changes
- introduce types where needed and migrate the (old) JAX-RS REST resource classes to Spring Web MVC (better performance and better testability)
- introduce DTOs if not already available and make sure if they exist that they are not outdated
- assemble one DTO as command payload from all incoming REST API parameters (headers, query/path paramters, request bodies)
- annotate attributes in the DTOs with Jakarta Validation annotations to enforce constraints on their values
- wired REST API to the new command processing, one service at a time/pull request
- take a non-critical service (like document management) and migrate it to the new command processing mechanics from top (REST API) to bottom (business logic service)
- refactor command handlers to new internal API
- make sure that the business service logic classes/functions take only one DTO request input parameter (aka don't let a function have 12 input parameters of type string...)
- when all integration tests run successfully then remove all legacy boilerplate code that is not used anymore
- make an ordered list of modules/features (easiest, lowest hanging fruit first)
- maintain at least the same performance as the current implementation
- optional: improve performance if it can be done in a reasonable time frame
- optional: improve resilience if it can be done in a reasonable time frame
Non-Goals
---------
- current command processing will stay untouched, will run independently of new infrastructure
- don't try cleaning up the storage layer; that's a separate effort for later (type safe queries, query peformance, clean entity classes)
- doesn't need to be optimized for speed immediately
- no changes in the integration tests
Proposed API Changes
--------------------
1. Command Wrapper
TBD
Class contains some generic atttributes like:
- username
- tenant ID
- timestamp
The actual payload (aka command input parameters) are defined as a generic parameter. It is expected that the modules implement classes that introduce the payload types and inherit from the abstract command class.
2. Command Processing Service
TBD
- synchronously (required): this is pretty much as we do right now (use virtual threads optionally)
- asynchronously (optional): with executor service and completable futures (use virtual threads optionally)
- non-blocking (optional): high perfomance LMAX Disruptor non-blocking implementation
These different perfromance level implementations need to be absolute drop-in replacements (for each other). It is expected that more performant implementations need more testing due to increased complexity and possible unforseen side effects. In case any problems show up we can always roll back to the required default implementation (synchronous).
NOTE: we should consider providing a command processing implementation based on Apache Camel once this concept is approved and we migrated already a couple of services. They are specialized for exactly this kind of use cases and have more dedicated people working on it's implementation. Could give more flexibility without us needing to maintain code.
3. Middlewares
TBD
4. Command Handlers
TBD
5. References to users (aka AppUser)
Keep things lightweight and only reference users by their user names.f
Risks
-----
TBD
- feature creep
ETA
---
A first prototype of the a new command processing component is ready for evaluation. There is also an initial smoke test (JMH) available.
You can try it out with the following instructions (it's still in a private repository, but will be published soon as an official PR):
```
git clone git@github.com:vidakovic/fineract.git
cd fineract
git checkout feature/FINERACT-2169
./gradlew :fineract-command:build
./gradlew :fineract-command:jmh
```
Diagrams
--------
TBD
Related Jira Tickets
--------------------
- https://issues.apache.org/jira/browse/FINERACT-2169
- https://issues.apache.org/jira/browse/FINERACT-2021
- https://issues.apache.org/jira/browse/FINERACT-1744
- https://issues.apache.org/jira/browse/FINERACT-1909
+337
View File
@@ -0,0 +1,337 @@
Refactoring Instructions
========================
General
--------
1. POJOs
Please make sure that all POJOs (for request and response types) have a similar structure. They should:
- use Lombok to reduce boilerplate code
- make sure that all annotations are always in the same order (see example code)
- avoid `record`s for now (we might or might not migrate later from Lombok to `record`)
- each class should implement `java.io.Serializable`
- each class should contain a serialization version set to `1L`
Example:
```
package org.apache.fineract.command.sample.data;
...
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
public class DummyRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private UUID id;
private String content;
}
```
2. Transformation between types
If you need to transform between two POJOs then use MapStruct wherever possible. Place the MapStruct interfaces under a sibling package `mapping` next to the `data` package.
3. Package structure (`org.apache.fineract.xxx.*`)
Please make sure to always follow this package structure pattern:
- `api`: contains all REST JAX-RS resource classes (later Spring Web MVC controllers)
- `command`: primarily used for command specific child class implementations of `org.apache.fineract.command.core.Command` (see section "Command Pipeline preserving Type Information")
- `data`: contains all DTOs (request and response types)
- `domain`: contains all entity/table mapping classes
- `handler`: contains all command handlers
- `service`: contains business logic services
- `mapping`: contains MapStruct interfaces
- `middleware`: contains middleware related to command processing; I am not expecting that we will need any of this during the refactoring process
- `security`: might contain later so called Spring Security "authorization managers" for more complex use cases
- `serialization`: technically we should not need this package anymore after we are done with the refactorings; in theory there could be very complex data structures that are not easily digestable by Jackson; for those case we could still use this package to add de-/serialization helpers (Jackson provides a proper API for this)
- `starter`: Spring Java configuration that allows us to make Fineract customizable (make parts of the system replaceable)
- `validation`: contains custom Jakarta Validation components/annotations
In general avoid too many nesting levels. Ideally we would have only one additional underneath the base package (`org.apache.fineract.*`) where in turn only the above package patterns are used.
REST API
--------
1. Read Requests
The **read requests** (HTTP **GET**) usually only require to refactor (if at all) the business logic services in case they don't return proper Java POJOs. Please name all return types for these read requests consistently. We propose to add always a suffix `Response`; this makes it immediately clear that we are dealing with a data transfer object (vs database mapping entities) and that it's something that we return to the clients (vs incoming requests). Historically we've used `Data` as a suffix, but this doesn't make it clear if it's used as input or output. Let's see an example.
This is how the legacy code looks like:
```
public class BusinessDateApiResource {
...
@GET
public String getBusinessDates(@Context final UriInfo uriInfo) {
securityContext.authenticatedUser().validateHasReadPermission("BUSINESS_DATE");
final List<BusinessDateData> foundBusinessDates = this.readPlatformService.findAll();
ApiRequestJsonSerializationSettings settings = parameterHelper.process(uriInfo.getQueryParameters());
return this.jsonSerializer.serialize(settings, foundBusinessDates);
}
...
}
```
This is how the refactored code should look like; there is absolutely no need to manually serialize:
```
public class BusinessDateApiResource {
...
@GET
public List<BusinessDateResponse> getBusinessDates() {
securityContext.authenticatedUser().validateHasReadPermission(BUSINESS_DATE);
return this.readPlatformService.findAll();
}
...
}
```
[!IMPORTANT] We should make sure that all response DTOs should reside in a package `org.apache.fineract.xxx.data`; for above example `org.apache.fineract.infrastructure.businessdate.data.BusinessDateResponse`!
Bonus: very often you'll see that we are using our homegrown `PlatformSecurityContext` to validate read permissions. This is the **WRONG** way to do it. Fortunately, fixing this is a low hanging fruit. We just need to introduce an "Ant matcher" configuration in the web configuration. For above example we should add an entry in `org.apache.fineract.infrastructure.core.config.SecurityConfig`. The change should look something like this:
```
@Configuration
@ConditionalOnProperty("fineract.security.basicauth.enabled")
@EnableMethodSecurity
public class SecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.securityMatcher(antMatcher("/api/**")).authorizeHttpRequests((auth) -> {
auth.requestMatchers(antMatcher(HttpMethod.OPTIONS, "/api/**")).permitAll()
.requestMatchers(antMatcher(HttpMethod.POST, "/api/*/echo")).permitAll()
.requestMatchers(antMatcher(HttpMethod.POST, "/api/*/authentication")).permitAll()
.requestMatchers(antMatcher(HttpMethod.POST, "/api/*/self/authentication")).permitAll()
.requestMatchers(antMatcher(HttpMethod.POST, "/api/*/self/registration")).permitAll()
.requestMatchers(antMatcher(HttpMethod.POST, "/api/*/self/registration/user")).permitAll()
// NOTE: enforce read permission for get business date by type
.requestMatchers(antMatcher(HttpMethod.GET, "/api/v1/businessdate/*")).hasPermission("ALL_FUNCTIONS", "ALL_FUNCTIONS_READ", "READ_BUSINESS_DATE")
// ... insert more like above...
.requestMatchers(antMatcher(HttpMethod.PUT, "/api/*/instance-mode")).permitAll()
.requestMatchers(antMatcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
.requestMatchers(antMatcher("/api/*/twofactor")).fullyAuthenticated()
.requestMatchers(antMatcher("/api/**"))
.access(allOf(fullyAuthenticated(), hasAuthority("TWOFACTOR_AUTHENTICATED")));
}).httpBasic((httpBasic) -> httpBasic.authenticationEntryPoint(basicAuthenticationEntryPoint()))
.cors(Customizer.withDefaults()).csrf((csrf) -> csrf.disable())
.sessionManagement((smc) -> smc.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class);
if (!Objects.isNull(loanCOBFilterHelper)) {
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class)
.addFilterAfter(idempotencyStoreFilter(), LoanCOBApiFilter.class);
} else {
http.addFilterAfter(idempotencyStoreFilter(), FineractInstanceModeApiFilter.class);
}
if (fineractProperties.getSecurity().getTwoFactor().isEnabled()) {
http.addFilterAfter(twoFactorAuthenticationFilter(), CorrelationHeaderFilter.class);
} else {
http.addFilterAfter(insecureTwoFactorAuthenticationFilter(), CorrelationHeaderFilter.class);
}
if (serverProperties.getSsl().isEnabled()) {
http.requiresChannel(channel -> channel.requestMatchers(antMatcher("/api/**")).requiresSecure());
}
return http.build();
}
...
}
```
The nice side effect is that we'll have all security rules that we are enforcing in one place. This will enable more flexible customizations around security (will be handled in the modular security proposal).
The final refactored function should look like this:
```
public class BusinessDateApiResource {
...
@GET
public List<BusinessDateResponse> getBusinessDates() {
return this.readPlatformService.findAll();
}
...
}
```
That way we lose immediately dependencies on the security context, the GSON configuration and the handcrafted serialization helper for GSON.
[!IMPORTANT] We have to make sure that the Jackson parser configuration matches the one for GSON (especially dates etc.)!
2. Write Requests
The **write requests** (HTTP **PUT** and **POST**) are the ones that affect the command processing infrastructure. The JSON body in pretty much all of the legacy cases is probably represented as a simple string variable that gets passed to a command wrapper class. All these variables need to be replaced by proper POJO classes that represent the request body. You can get hints how these classes should look like by checking the OpenAPI dummy classes we created to re-introduce the type information for the OpenAPI descriptor (see Swagger annotations).
Usually you'll have 2 classes to take care of: **requests** (incoming input parameters) and **responses** (outgoing results). These classes are technically **DTO**s (data transfer objects) or **VO**s (value objects) and to make them more recognizable as such I would start standardizing the naming. E.g. if I have a use case aka REST endpoint that creates client data we would name that DTO class `CreateClientRequest`; and if any data needs to be sent back to the client then that class should be called `CreateClientResponse`. This is a very simple mechanic that helps identifying immediately what is what and doesn't require the developers to come up with any naming stunts. Do not try to re-use these DTOs on multiple endpoints, it's not worth it. Create a separate DTO for each endpoint. Nice side effect: all this becomes then nicer to read (both in Java code and in the OpenAPI descriptor and the resulting Asciidoc documentation) and this will make it less likely that names are clashing in the OpenAPI descriptor (and during code generation for the Fineract Java Client).
Only new thing that needs to be injected in those REST API resource classes (JAX-RS) or controllers (Spring Web MVC) is the `CommandPipeline` component that allows you to send requests to the **command pipeline** which in turn will be processed by **handlers** that eventually call one or more **business logic services**. Results are sent back to the pipeline and are received in the REST API aka returned by `CommandPipeline` as so called **supplier objects**. (Java) Supplier is a functional interface, i.e. there is only one function to be implemented (`get()`). This small abstraction helps us to standardize how the results are delivered (**synchronous**, **asynchronous**, **non-blocking**) and maintain the same internal API.
3. Jakarta Validation
TBD
Example POJO:
```
package org.apache.fineract.command.sample.data;
...
import jakarta.validation.constraints.NotEmpty;
...
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
public class DummyRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private UUID id;
@NotEmpty
private String content;
}
```
Create message resource bundles for the validation errors.
TBD
4. **Skip**: Spring Web MVC
We will not do this right now, but later we might move away from JAX-RS and introduce **Spring Web MVC** which is mostly just changing the annotations (e.g. `@POST` vs `@PostRequest`). This process should be pretty straight forward and could be even (semi-) automated by using **OpenRewrite** recipes. Once we have proper POJOs for requests and response we can also use **Jakarta Validation** annotations to validate the request content. You'll see that currently we have an explicit service injected everywhere that does those checks manually (that includes some JSON parsing); very tedious and work intensive and hard to refactor. The first setup with Jakarta Validation take a little longer, but once that is working for one use case it should be pretty much rinse and repeat for the rest. Occasionally for very exotic validations there might be a need to implement **custom validations**. Another advantage of this approach: we can finally add the proper **internationalized error messages** on the server side, not only the translation keys. This removes a ton of code on the client side and ensures that the clients always have the correct messages. Read the Jakarta Validation documentation on how that works and use **Hibernate Validation** to implement this (pretty much the defacto standard library).
5. Security Enforcement
very often you'll see that we are using our homegrown to validate read permissions. This is the **WRONG** way to do it. Fortunately, fixing this is a low hanging fruit. We just need to introduce an "Ant matcher" configuration in the web configuration. For above example we should add an entry in `org.apache.fineract.infrastructure.core.config.SecurityConfig`. The change should look something like this:
Another relatively low hanging fruit that can be tackled here is the way we enforce security aka authorization with `org.apache.fineract.infrastructure.security.service.PlatformSecurityContext`. At the moment we use an explicitly injected security service that we call pretty much everywhere in the REST API layer and then decide in numerous if-then-else constructs if we should execute the commands (or read requests for that matter). This is the **WRONG** way to do it. What we should do is to use Spring Security's APIs and define the authorization requirements in the web configuration where you have then all the security related definitions in one place (instead of chasing them down in all the controller classes); in some more complex cases we might need to implement so called `AuthorizationManager` components to enforce a certain authorization; in any case this will all be visible in one place, the web configuration which makes adapting to specific custom situations also a **LOT** easier.
Because all JSON parsing was done up until now manually some developers got tired and decided to pass the JSON data structures (`CommandWrapper`, `JsonCommand`) directly to the business logic services. This is **WRONG**. The only parameter those business logic functions should receive is exactly ONE Java Pojo that contains all necessary input parameter for that function to execute. **DO NOT** define functions with primitive/base types. It is impossible to understand the order of those parameters once you have e.g. 17 (example to make a point) string variables. Ideally, you'd just pass the request POJOs to those functions. If you need to transform something then use **MapStruct** instead of manual code. The refactoring should not be too bad. Once proper Java POJOs are defined for the functions we just need to replace the manual JSON parsing boilerplate in the service classes and just call the getters/setters of the request POJOs (instead the manual JSON parsing). The results should also be sent back in proper POJOs (responses). I would go as far that even if you have a single string value (example) that you **should** wrap it in a response class (same for the incoming input parameters aka requests). This will make the service interfaces more stable if we add/remove parameters over time; in other words: this will avoid all those refactoring fests we have upstream when we have functions with 12 base type (example) parameters and need to add/remove something. Obviously you need to check where else in Fineract's code base those service functions are called and adapt accordingly, but usually those functions are called in just one place (handlers).
Command Pipeline preserving Type Information
--------------------------------------------
`CommandPipeline` needs to be injected where needed (usually only REST API controllers). By default everything is configured for `sync`(-hronous) processing. Other modes (`async`, `disruptor`) can be easily configured via application.properties, but need more testing and are out of scope for now. As you can see the command object just contains some of metadata (`createdAt`, `username` etc.) and the payload aka request object. Obviously we have different use cases so that **payload** attribute is defined as a generic **type**. Please check the unit test code how to create a command object with payload/request properly.
```
public class DummyApiResource {
...
@GET
DummyResponse dummy(@HeaderParam("x-fineract-request-id") UUID requestId, @HeaderParam("x-fineract-tenant-id") String tenantId, DummyRequest request) {
var command = new DummyCommand();
command.setId(requestId);
command.setPayload(request);
tenantService.set(tenantId);
Supplier<DummyResponse> result = pipeline.send(command);
return result.get();
}
...
}
```
Make sure to create command specific child class of the generic (and abstract) `org.apache.fineract.command.core.Command` class. Example:
```
...
@Data
@EqualsAndHashCode(callSuper = true)
public class DummyCommand extends Command<DummyRequest> {}
```
If everything is done correctly then no type information will be lost and everything can be parsed without further help by the Jackson parser. Eventually all handcrafted boilerplate JSON parsing code can be dumped. In rare cases we might need to add de-/serialization helper classes (a concept provided by Jackson) to help the parser identify the types properly.
When we use **Spring Web MVC unit testing** (actually integration testing) gets very easy. Just a couple of annotations and you can execute them pretty much like simple unit tests in your IDE (because Spring Web MVC is a first class citizen obviously in the Spring Framework). NONE of that handcrafted client code like in our current integration tests is required; the tests should be easier to refactor and easier to understand. Writing those tests is **optional** for now, because we have already over **1000 integration tests**. After all those refactorings the REST API should be 100% compatible to upstream even if it uses a different technology stack. If everything passes those ""old"" integration tests then we can be pretty confident that we didn't mess something up. Migrating the integration tests to simpler Spring Web tests can be done later."
Command Handlers
----------------
In the current CQRS implementation we have already a concept that is called **handlers**. Those handlers are responsible to receive the command objects with their (request) payloads and transform the requests as needed an pass them to one or more business logic services. The refactoring of those handlers should not be too complicated, they just need to implement the Java interface `CommandHandler`. Look at my test samples to see how the implementation details look like.
The old handlers look somewhat like this:
```
@Service
@CommandType(entity = "PAYMENTTYPE", action = "CREATE")
public class CreatePaymentTypeCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Autowired
public CreatePaymentTypeCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Override
@Transactional
public CommandProcessingResult processCommand(JsonCommand command) {
return this.paymentTypeWriteService.createPaymentType(command);
}
}
```
... and this is how the refactored handler could look like:
```
@Slf4j
@RequiredArgsConstructor
@Component
public class CreatePaymentTypeCommandHandler implements CommandHandler<CreatePaymentTypeRequest, CreatePaymentTypeResponse> {
private final PaymentTypeWriteService paymentTypeWriteService;
@Override
public CreatePaymentTypeResponse handle(Command<CreatePaymentTypeRequest> command) {
// TODO: refactor business logic service to accept properly typed request objects as input
return paymentTypeWriteService.createPaymentType(command.getPayload());
}
}
```
Execution Mode
--------------
1. Sync Execution
This is the default execution mode. Performance is to be expected on par with the current legacy implementation all tests need to work with this mode.
2. **Skip**: Async Execution
Already included in the current implementation. Just needs a proper **configuration** in **application.properties** (see unit tests). One thing that might need some additional coding: the use of **thread local variables in multi threaded environments** needs some special care to properly work (we use this to identify the current tenant). Also: we should upgrade to JDK 21 and make use of virtual threads (very easy in Spring Boot, simple configuration property). This allows for massive parallel execution that is not bound by physical CPU cores without (take this with a pinch of salt) performance penalties (read: use millions of threads).
3. **Skip**: Non-blocking Execution
Already included in the current implementation and configurable. I've tried a couple of combinations with LMAX disruptor, but this needs more testing to figure out optimal an configuration (see also https://lmax-exchange.github.io/disruptor/user-guide/). Would be worth to create more realistic JMH benchmarks. I have added a simple one to get a first idea how the mechanics are working.
Maker-Checker
-------------
This will be part of a separate proposal. The only related feature I've added here was command persistence so that you can save commands for deferred execution. Other than that I want to keep this concept (command processing) clean and avoid mixing to many concepts/concerns in one place. This ensures better maintainability. Maker-checker is actually a security related concept and should probably be handled with the proper Spring Security APIs (`AuthorizationManager` interface comes to mind). But again, different proposal and can be ignored here for now.
When we encounter the first need to take care of Maker-Checker then let's figure out a solution that has minimal impact and do a proper cleanup when the Maker-Checker proposal is available.
[!NOTE] The best guess is that Maker-Checker will probably implemented
+82
View File
@@ -0,0 +1,82 @@
/**
* 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.
*/
description = 'Fineract Command'
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'me.champeau.jmh'
configurations {
asciidoctorExtensions
providedRuntime // needed for Spring Boot executable WAR
providedCompile
compile() {
exclude module: 'hibernate-entitymanager'
exclude module: 'hibernate-validator'
exclude module: 'activation'
exclude module: 'bcmail-jdk14'
exclude module: 'bcprov-jdk14'
exclude module: 'bctsp-jdk14'
exclude module: 'c3p0'
exclude module: 'stax-api'
exclude module: 'jaxb-api'
exclude module: 'jaxb-impl'
exclude module: 'jboss-logging'
exclude module: 'itext-rtf'
exclude module: 'classworlds'
}
runtime
}
apply from: 'dependencies.gradle'
// Configuration for the modernizer plugin
// https://github.com/andygoossens/gradle-modernizer-plugin
modernizer {
ignoreClassNamePatterns = [
'.*AbstractPersistableCustom',
'.*EntityTables',
'.*domain.*'
]
}
// If we are running Gradle within Eclipse to enhance classes with OpenJPA,
// set the classes directory to point to Eclipse's default build directory
if (project.hasProperty('env') && project.getProperty('env') == 'eclipse') {
sourceSets.main.java.outputDir = new File(rootProject.projectDir, "fineract-command/bin/main")
}
if (!(project.hasProperty('env') && project.getProperty('env') == 'dev')) {
sourceSets {
test {
java {
exclude '**/core/boot/tests/**'
}
}
}
}
jmh {
// include = ['.*'] // Include all benchmarks (regex-based filter)
warmupIterations = 2 // Number of warm-up iterations
iterations = 3 // Number of measurement iterations
fork = 1 // Number of forks
timeOnIteration = '2s' // Time per iteration
benchmarkMode = ['thrpt'] // Default benchmark mode
}
+73
View File
@@ -0,0 +1,73 @@
/**
* 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.
*/
dependencies {
implementation(
'org.springframework.boot:spring-boot-starter',
'org.springframework.boot:spring-boot-starter-validation',
'org.springframework.boot:spring-boot-starter-data-jpa',
'io.github.resilience4j:resilience4j-spring-boot3',
'org.liquibase:liquibase-core',
'com.mysql:mysql-connector-j',
'org.postgresql:postgresql',
'com.fasterxml.jackson.core:jackson-databind',
'com.google.guava:guava',
'org.apache.commons:commons-lang3',
'com.github.spotbugs:spotbugs-annotations',
'org.mapstruct:mapstruct',
'com.lmax:disruptor',
'com.ibm.icu:icu4j',
'org.yakworks:spring-icu4j',
)
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.mapstruct:mapstruct-processor'
jmh 'org.openjdk.jmh:jmh-core'
jmh 'org.openjdk.jmh:jmh-generator-annprocess'
annotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess'
jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess'
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation ('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'com.jayway.jsonpath', module: 'json-path'
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
exclude group: 'jakarta.activation'
exclude group: 'javax.activation'
exclude group: 'org.skyscreamer'
}
testImplementation (
'org.springframework.boot:spring-boot-starter-web',
'org.mockito:mockito-inline',
'org.openjdk.jmh:jmh-core',
'org.testcontainers:junit-jupiter',
'org.testcontainers:postgresql',
'org.testcontainers:mysql',
'org.springframework.restdocs:spring-restdocs-mockmvc',
'org.springframework.restdocs:spring-restdocs-webtestclient',
'org.springframework.restdocs:spring-restdocs-restassured',
)
}
@@ -0,0 +1,44 @@
/**
* 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;
import java.io.Serial;
import java.io.Serializable;
import java.time.OffsetDateTime;
import java.util.UUID;
import lombok.Data;
import lombok.experimental.FieldNameConstants;
@Data
@FieldNameConstants
public class Command<T> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private UUID id;
private OffsetDateTime createdAt;
private String tenantId;
private String username;
private T payload;
}
@@ -0,0 +1,27 @@
/**
* 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;
import lombok.experimental.UtilityClass;
@UtilityClass
public final class CommandConstants {
public static final String COMMAND_REQUEST_ID = "x-fineract-request-id";
}
@@ -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.core;
import java.util.function.Supplier;
public interface CommandExecutor {
<REQ, RES> Supplier<RES> execute(Command<REQ> command);
}
@@ -0,0 +1,32 @@
/**
* 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;
import com.google.common.reflect.TypeToken;
public interface CommandHandler<REQ, RES> {
RES handle(Command<REQ> command);
default boolean matches(Command<REQ> command) {
TypeToken<REQ> handlerType = new TypeToken<>(getClass()) {};
return handlerType.getRawType().isAssignableFrom(command.getPayload().getClass());
}
}
@@ -0,0 +1,25 @@
/**
* 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;
@FunctionalInterface
public interface CommandMiddleware {
void invoke(Command<?> command);
}
@@ -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.core;
import java.util.function.Supplier;
public interface CommandPipeline {
<REQ, RES> Supplier<RES> send(Command<REQ> command);
}
@@ -0,0 +1,57 @@
/**
* 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;
import com.lmax.disruptor.dsl.ProducerType;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldNameConstants;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
@ConfigurationProperties(prefix = "fineract.command")
public final class CommandProperties implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Builder.Default
private Boolean enabled = true;
@Builder.Default
private CommandExecutorType executor = CommandExecutorType.sync;
@Builder.Default
private Integer ringBufferSize = 1024;
@Builder.Default
private ProducerType producerType = ProducerType.SINGLE;
public enum CommandExecutorType {
sync, async, disruptor
}
}
@@ -0,0 +1,25 @@
/**
* 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;
@FunctionalInterface
public interface CommandRouter {
<REQ, RES> CommandHandler<REQ, RES> route(Command<REQ> command);
}
@@ -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.core.exception;
import org.apache.fineract.command.core.Command;
public class CommandHandlerNotFoundException extends RuntimeException {
public CommandHandlerNotFoundException(Command<?> command) {
super("Cannot find a matching handler for " + (command != null ? command.getClass().getSimpleName() : "UKNOWN") + " command");
}
}
@@ -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.core.exception;
import org.apache.fineract.command.core.Command;
public class CommandIllegalArgumentException extends RuntimeException {
public CommandIllegalArgumentException(Command<?> command, String message) {
super("Illegal argument for " + command.getClass().getSimpleName() + " command: " + message);
}
}
@@ -0,0 +1,58 @@
/**
* 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 java.util.List;
import java.util.concurrent.CompletableFuture;
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.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 {
private final List<CommandMiddleware> middlewares;
private final CommandRouter router;
@Override
public <REQ, RES> Supplier<RES> execute(Command<REQ> command) {
CompletableFuture<RES> future = CompletableFuture.supplyAsync(() -> {
for (CommandMiddleware middleware : middlewares) {
middleware.invoke(command);
}
CommandHandler<REQ, RES> handler = router.route(command);
return handler.handle(command);
});
return future::join;
}
}
@@ -0,0 +1,46 @@
/**
* 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.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.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
@ConditionalOnBean(CommandPipeline.class)
public class DefaultCommandPipeline implements CommandPipeline {
private final CommandExecutor executor;
@Override
public <REQ, RES> Supplier<RES> send(final Command<REQ> command) {
requireNonNull(command, "Command must not be null");
return executor.execute(command);
}
}
@@ -0,0 +1,50 @@
/**
* 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 java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.command.core.CommandRouter;
import org.apache.fineract.command.core.exception.CommandHandlerNotFoundException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
@ConditionalOnBean(CommandRouter.class)
@SuppressWarnings({ "unchecked", "raw" })
public class DefaultCommandRouter implements CommandRouter {
private final List<CommandHandler> commandHandlers;
@Override
public <REQ, RES> CommandHandler<REQ, RES> route(Command<REQ> command) {
// TODO: make sure there are no duplicate handlers
if (command == null) {
throw new CommandHandlerNotFoundException(command);
}
return commandHandlers.stream().filter(handler -> handler.matches(command)).findFirst()
.orElseThrow(() -> new CommandHandlerNotFoundException(command));
}
}
@@ -0,0 +1,104 @@
/**
* 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 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;
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.CommandRouter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
@ConditionalOnProperty(value = "fineract.command.executor", havingValue = "disruptor")
@SuppressWarnings({ "unchecked", "rawtypes" })
public class DisruptorCommandExecutor implements CommandExecutor, Closeable {
private final Disruptor<CommandEvent> disruptor;
@Override
public <REQ, RES> Supplier<RES> execute(Command<REQ> command) {
CommandEvent<REQ, RES> processedEvent = next(command);
return processedEvent.getFuture()::join;
}
@Override
public void close() throws IOException {
disruptor.shutdown();
}
@SuppressWarnings({ "unchecked" })
private <REQ, RES> CommandEvent<REQ, RES> next(Command<REQ> command) {
var ringBuffer = disruptor.getRingBuffer();
var sequenceId = ringBuffer.next();
CommandEvent<REQ, RES> event = ringBuffer.get(sequenceId);
event.setCommand(command);
ringBuffer.publish(sequenceId);
return event;
}
@Getter
@Setter
public static class CommandEvent<REQ, RES> {
private Command<REQ> command;
private CompletableFuture<RES> future = new CompletableFuture<>();
}
@RequiredArgsConstructor
@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 {
try {
for (CommandMiddleware middleware : middlewares) {
middleware.invoke(event.getCommand());
}
var handler = router.route(event.getCommand());
event.getFuture().complete(handler.handle(event.getCommand()));
} catch (Exception e) {
event.getFuture().completeExceptionally(e);
}
}
}
}
@@ -0,0 +1,54 @@
/**
* 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 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.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 {
private final List<CommandMiddleware> middlewares;
private final CommandRouter router;
@Override
public <REQ, RES> Supplier<RES> execute(Command<REQ> command) {
for (CommandMiddleware middleware : middlewares) {
middleware.invoke(command);
}
CommandHandler<REQ, RES> handler = router.route(command);
return () -> handler.handle(command);
}
}
@@ -0,0 +1,25 @@
/**
* 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.persistence;
import org.apache.fineract.command.persistence.model.CommandEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface CommandRepository extends JpaRepository<CommandEntity, Long>, JpaSpecificationExecutor<CommandEntity> {}
@@ -0,0 +1,59 @@
/**
* 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.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;
@Slf4j
@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
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;
}
}
@@ -0,0 +1,64 @@
/**
* 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.persistence.mapping;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public final class CommandJsonMapper {
private static final String CLASS_ATTRIBUTE = "@class";
private final ObjectMapper mapper;
public <T> T map(JsonNode source) {
if (source == null) {
return null;
}
var canonicalName = source.get(CLASS_ATTRIBUTE).asText();
try {
return (T) mapper.convertValue(source, Class.forName(canonicalName));
} catch (Exception e) {
log.error("Error while mapping json node", e);
}
return null;
}
public JsonNode map(Object source) {
if (source == null) {
return null;
}
var json = mapper.convertValue(source, ObjectNode.class);
json.set(CLASS_ATTRIBUTE, new TextNode(source.getClass().getCanonicalName()));
return json;
}
}
@@ -0,0 +1,43 @@
/**
* 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.persistence.mapping;
import static org.mapstruct.InjectionStrategy.CONSTRUCTOR;
import static org.mapstruct.MappingConstants.ComponentModel.SPRING;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.persistence.model.CommandEntity;
import org.mapstruct.InheritInverseConfiguration;
import org.mapstruct.Mapper;
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 = "createdAt", target = "createdAt")
@Mapping(source = "tenantId", target = "tenantId")
@Mapping(source = "username", target = "username")
@Mapping(source = "payload", target = "payload")
CommandEntity map(Command source);
@InheritInverseConfiguration
Command map(CommandEntity source);
}
@@ -0,0 +1,83 @@
/**
* 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.persistence.model;
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 lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
import org.apache.fineract.command.persistence.converter.JsonAttributeConverter;
@Getter
@Setter
@ToString
@FieldNameConstants
@Entity
@Table(name = "m_command")
public class CommandEntity implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@jakarta.persistence.Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Transient
@Setter(value = AccessLevel.NONE)
private boolean isNew = true;
@Column(name = "command_id")
private UUID commandId;
@Column(name = "created_at")
private OffsetDateTime createdAt;
@Column(name = "tenant_id")
private String tenantId;
@Column(name = "username")
private String username;
@Column(name = "payload")
@Convert(converter = JsonAttributeConverter.class)
private JsonNode payload;
@PrePersist
@PostLoad
void markNotNew() {
this.isNew = false;
}
}
@@ -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.starter;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Import;
@AutoConfiguration
@Import({ CommandConfiguration.class, CommandPersistenceConfiguration.class })
public class CommandAutoConfiguration {}
@@ -0,0 +1,66 @@
/**
* 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.starter;
import com.lmax.disruptor.IgnoreExceptionHandler;
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;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(CommandProperties.class)
@ComponentScan("org.apache.fineract.command.core")
@ComponentScan("org.apache.fineract.command.implementation")
class CommandConfiguration {
@Bean
@ConditionalOnMissingBean
WaitStrategy waitStrategy() {
return new YieldingWaitStrategy();
}
@Bean
Disruptor<?> disruptor(CommandProperties properties, WaitStrategy waitStrategy, List<CommandMiddleware> middlewares,
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.setDefaultExceptionHandler(new IgnoreExceptionHandler());
// Start the disruptor
disruptor.start();
return disruptor;
}
}
@@ -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.starter;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan("org.apache.fineract.command.persistence")
class CommandPersistenceConfiguration {}
@@ -0,0 +1 @@
org.apache.fineract.command.starter.CommandAutoConfiguration
@@ -0,0 +1,49 @@
<?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-00001" context="postgresql">
<createTable tableName="m_command">
<column autoIncrement="true" name="id" type="bigint" remarks="Internal ID">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="created_at" type="timestamp with time zone" />
<column name="command_id" type="uuid" />
<column name="tenant_id" type="varchar(255)" />
<column name="username" type="varchar(255)" />
<column name="payload" type="text" />
</createTable>
</changeSet>
<changeSet author="fineract" id="command-00002" context="mysql">
<createTable tableName="command">
<column autoIncrement="true" name="id" type="bigint" remarks="Internal ID">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="created_at" type="datetime" />
<column name="command_id" type="uuid" />
<column name="tenant_id" type="varchar(255)" />
<column name="username" type="varchar(255)" />
<column name="payload" type="text" />
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,64 @@
/**
* 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;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.persistence.CommandRepository;
import org.apache.fineract.command.persistence.mapping.CommandMapper;
import org.springframework.beans.factory.annotation.Autowired;
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.Network;
import org.testcontainers.junit.jupiter.Container;
@Slf4j
@SpringBootTest
@ContextConfiguration(classes = TestConfiguration.class)
@SuppressWarnings({ "unchecked", "rawtypes" })
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"));
@Autowired
protected CommandRepository commandRepository;
@Autowired
protected CommandMapper commandMapper;
@DynamicPropertySource
protected static void configure(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.platform", () -> "postgresql");
}
}
@@ -0,0 +1,51 @@
/**
* 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;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.sample.command.DummyCommand;
import org.apache.fineract.command.sample.data.DummyRequest;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;
@Slf4j
@SpringBootTest
@ContextConfiguration(classes = TestConfiguration.class)
class CommandPersistenceTest extends CommandBaseTest {
@Test
void save() {
var content = "hello";
var command = new DummyCommand();
command.setId(UUID.randomUUID());
command.setPayload(DummyRequest.builder().content(content).build());
var commandEntity = commandMapper.map(command);
var result = commandRepository.save(commandEntity);
log.info("Saved command: {}", result);
var found = commandRepository.findById(result.getId());
log.info("Found command: {}", found.orElse(null));
}
}
@@ -0,0 +1,100 @@
/**
* 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;
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.implementation.DefaultCommandPipeline;
import org.apache.fineract.command.implementation.DefaultCommandRouter;
import org.apache.fineract.command.implementation.DisruptorCommandExecutor;
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;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
@Slf4j
@BenchmarkMode(Mode.Throughput) // Measures operations per second
@State(Scope.Benchmark) // Benchmark state for each thread
@OutputTimeUnit(TimeUnit.SECONDS) // Output results in seconds
@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 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));
// Start the disruptor
disruptor.start();
pipeline = new DefaultCommandPipeline(new DisruptorCommandExecutor(disruptor));
}
@TearDown(Level.Iteration)
@SneakyThrows
public void tearDown() {
disruptor.shutdown(1, TimeUnit.SECONDS);
}
@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);
// NOTE: force yield
result.get();
}
}
@@ -0,0 +1,70 @@
/**
* 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;
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 java.util.Locale;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.CommandPipeline;
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.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;
@Slf4j
@SpringBootTest
@ContextConfiguration(classes = TestConfiguration.class)
class CommandPipelineTest extends CommandBaseTest {
@Autowired
private CommandPipeline pipeline;
@Test
void processCommand() {
var content = "hello";
var command = new DummyCommand();
command.setId(UUID.randomUUID());
command.setPayload(DummyRequest.builder().content(content).build());
var result = pipeline.send(command);
assertNotNull(result, "Response should not be null.");
Object 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);
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.");
}
}
}
@@ -0,0 +1,164 @@
/**
* 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;
import static org.apache.fineract.command.core.CommandConstants.COMMAND_REQUEST_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;
import static org.springframework.http.HttpHeaders.ACCEPT;
import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
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.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.ProblemDetail;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.test.context.ContextConfiguration;
@Slf4j
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(classes = TestConfiguration.class)
@SuppressWarnings("PMD.JUnitTestsShouldIncludeAssert")
class CommandSampleApiTest extends CommandBaseTest {
@LocalServerPort
private int port;
private String baseUrl;
private List<ClientHttpRequestInterceptor> interceptors;
@Autowired
private TestRestTemplate restTemplate;
@BeforeEach
public void setUp() {
this.baseUrl = "http://localhost:" + port + "/test/dummy";
this.interceptors = Collections.singletonList((request, body, execution) -> {
var headers = request.getHeaders();
headers.add(COMMAND_REQUEST_ID, UUID.randomUUID().toString());
headers.add("x-fineract-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);
});
}
@Test
void validation() {
restTemplate.getRestTemplate().setInterceptors(interceptors);
var problemDetail = restTemplate.postForObject(baseUrl + "/sync", DummyRequest.builder().build(), ProblemDetail.class);
log.warn("Problem detail (sync) : {} ({})", problemDetail.getDetail(), problemDetail.getProperties());
assertNotNull(problemDetail, "Response should not be null.");
}
@Test
void dummyApiSync() {
var content = "test-sync";
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());
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.");
}
@Test
void dummyApiAsync() {
var content = "test-async";
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());
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.");
}
@Test
void dummyApiIdempotencyAsync() {
dummyApiIdempotency("/async");
}
@Test
void dummyApiIdempotencySync() {
dummyApiIdempotency("/sync");
}
void dummyApiIdempotency(String path) {
var id = UUID.randomUUID().toString();
var content = "test-idempotency";
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(CONTENT_TYPE, APPLICATION_JSON_VALUE);
headers.addAll(ACCEPT, List.of(APPLICATION_JSON_VALUE, APPLICATION_PROBLEM_JSON_VALUE));
return execution.execute(req, body);
});
restTemplate.getRestTemplate().setInterceptors(interceptors);
// first request passes
var response = restTemplate.postForEntity(baseUrl + path, request, Map.class);
assertThat(response.getStatusCode()).isEqualTo(OK);
// 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());
// Assert HTTP status
assertThat(response.getStatusCode()).isEqualTo(INTERNAL_SERVER_ERROR);
log.info("Idempotency all good!");
}
}
@@ -0,0 +1,63 @@
/**
* 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;
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.MessageSource;
import org.springframework.context.annotation.Bean;
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;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import yakworks.i18n.icu.ICUBundleMessageSource;
@Slf4j
@Configuration
@EnableConfigurationProperties({ CommandProperties.class, JpaProperties.class })
@EnableAutoConfiguration
@EnableJpaRepositories(basePackages = { "org.apache.fineract.command.persistence" })
@EnableAsync
@PropertySource("classpath:application-test.properties")
@ComponentScan("org.apache.fineract.command.sample")
public class TestConfiguration {
@Bean
MessageSource messageSource() {
var messageSource = new ICUBundleMessageSource();
messageSource.setBasenames("classpath:org.apache.fineract.messages");
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}
@Bean
Validator validatorFactoryBean(MessageSource messageSource) {
var localValidatorFactoryBean = new LocalValidatorFactoryBean();
localValidatorFactoryBean.setValidationMessageSource(messageSource);
return localValidatorFactoryBean;
}
}
@@ -0,0 +1,86 @@
/**
* 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.api;
import static org.apache.fineract.command.core.CommandConstants.COMMAND_REQUEST_ID;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE;
import jakarta.validation.Valid;
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;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.CommandPipeline;
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.service.DefaultDummyTenantService;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RequiredArgsConstructor
@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, @Valid @RequestBody DummyRequest request) {
var command = new DummyCommand();
command.setId(requestId);
command.setPayload(request);
command.setCreatedAt(OffsetDateTime.now(ZoneId.of("UTC")));
tenantService.set(tenantId);
Supplier<DummyResponse> result = pipeline.send(command);
return result.get();
}
@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) {
var command = new DummyCommand();
command.setId(requestId);
command.setPayload(request);
tenantService.set(tenantId);
Supplier<DummyResponse> result = pipeline.send(command);
return CompletableFuture.supplyAsync(result);
}
}
@@ -0,0 +1,81 @@
/**
* 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.api;
import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.sample.data.DummyError;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.support.WebExchangeBindException;
@Slf4j
@RequiredArgsConstructor
@RestControllerAdvice
public class DummyExceptionHandler {
private final MessageSource messageSource;
@ExceptionHandler(Throwable.class)
public ResponseEntity<ProblemDetail> handleRuntimeException(Throwable ex) {
var problemDetails = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getLocalizedMessage());
problemDetails.setType(URI.create("https://fineract.apache.org/validation"));
problemDetails.setTitle(messageSource.getMessage("org.apache.fineract.common.validation.error", null, Locale.US)); // TODO:
// check
// this,
// get
// locale
List<DummyError> errors = new ArrayList<>();
if (ex instanceof ConstraintViolationException cve) {
for (ConstraintViolation<?> violation : cve.getConstraintViolations()) {
errors.add(new DummyError(violation.getPropertyPath().toString(), violation.getMessage(), violation.getMessageTemplate()));
}
} else if (ex instanceof WebExchangeBindException webe) {
for (FieldError fieldError : webe.getFieldErrors()) {
errors.add(new DummyError(fieldError.getField(), fieldError.getDefaultMessage(), fieldError.getCode()));
}
} else if (ex instanceof MethodArgumentNotValidException manve) {
for (FieldError fieldError : manve.getBindingResult().getFieldErrors()) {
errors.add(new DummyError(fieldError.getField(), fieldError.getDefaultMessage(), fieldError.getCode()));
}
}
if (!errors.isEmpty()) {
problemDetails.setProperty("errors", errors);
}
return ResponseEntity.badRequest().contentType(APPLICATION_PROBLEM_JSON).body(problemDetails);
}
}
@@ -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.DummyRequest;
@Data
@EqualsAndHashCode(callSuper = true)
public class DummyCommand extends Command<DummyRequest> {}
@@ -0,0 +1,44 @@
/**
* 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 DummyError implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String field;
private String message;
private String code;
}
@@ -0,0 +1,45 @@
/**
* 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 java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldNameConstants;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
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;
}
@@ -0,0 +1,47 @@
/**
* 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 java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldNameConstants;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
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,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.handler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
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;
@Slf4j
@RequiredArgsConstructor
@Component
public class DummyCommandHandler implements CommandHandler<DummyRequest, DummyResponse> {
private final DummyService dummyService;
@Override
public DummyResponse handle(Command<DummyRequest> command) {
return dummyService.process(command.getPayload());
}
}
@@ -0,0 +1,50 @@
/**
* 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());
}
}
}
@@ -0,0 +1,39 @@
/**
* 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 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.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class DummyMiddleware implements CommandMiddleware {
@Override
public void invoke(Command<?> command) {
if (command instanceof DummyCommand c) {
c.getPayload().setId(command.getId());
}
}
}
@@ -0,0 +1,40 @@
/**
* 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.service;
import java.util.Locale;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.sample.data.DummyRequest;
import org.apache.fineract.command.sample.data.DummyResponse;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
public class DefaultDummyService implements DummyService {
private final DefaultDummyTenantService tenantService;
@Override
public DummyResponse process(DummyRequest request) {
return DummyResponse.builder().requestId(request.getId()).tenantId(tenantService.get())
.content(request.getContent().toUpperCase(Locale.ROOT)).build();
}
}
@@ -0,0 +1,35 @@
/**
* 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.service;
import org.springframework.stereotype.Service;
@Service
public class DefaultDummyTenantService {
private static final ThreadLocal<String> tenantHolder = new ThreadLocal<>();
public void set(String tenantId) {
tenantHolder.set(tenantId);
}
public String get() {
return tenantHolder.get();
}
}
@@ -0,0 +1,27 @@
/**
* 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.service;
import org.apache.fineract.command.sample.data.DummyRequest;
import org.apache.fineract.command.sample.data.DummyResponse;
public interface DummyService {
DummyResponse process(DummyRequest request);
}
@@ -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.
#
#server.error.include-message=always
#server.error.include-binding-errors=always
#server.error.include-stacktrace=always
debug=true
fineract.command.enabled=true
fineract.command.executor=sync
fineract.command.ring-buffer-size=1024
fineract.command.producer-type=single
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
@@ -0,0 +1,71 @@
#
# 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.
#
# common
org.apache.fineract.common.validation.error=Validation Error
org.apache.fineract.common.validation.AssertFalse.message=must be false
org.apache.fineract.common.validation.AssertTrue.message=must be true
org.apache.fineract.common.validation.DecimalMax.message=must be less than ${inclusive == true ? 'or equal to ' : ''}{value}
org.apache.fineract.common.validation.DecimalMin.message=must be greater than ${inclusive == true ? 'or equal to ' : ''}{value}
org.apache.fineract.common.validation.Digits.message=numeric value out of bounds (<{integer} digits>.<{fraction} digits> expected)
org.apache.fineract.common.validation.Email.message=must be a well-formed email address
org.apache.fineract.common.validation.Future.message=must be a future date
org.apache.fineract.common.validation.FutureOrPresent.message=must be a date in the present or in the future
org.apache.fineract.common.validation.Max.message=must be less than or equal to {value}
org.apache.fineract.common.validation.Min.message=must be greater than or equal to {value}
org.apache.fineract.common.validation.Negative.message=must be less than 0
org.apache.fineract.common.validation.NegativeOrZero.message=must be less than or equal to 0
org.apache.fineract.common.validation.NotBlank.message=must not be blank
org.apache.fineract.common.validation.NotEmpty.message=must not be empty
org.apache.fineract.common.validation.NotNull.message=must not be null
org.apache.fineract.common.validation.Null.message=must be null
org.apache.fineract.common.validation.Past.message=must be a past date
org.apache.fineract.common.validation.PastOrPresent.message=must be a date in the past or in the present
org.apache.fineract.common.validation.Pattern.message=must match "{regexp}"
org.apache.fineract.common.validation.Positive.message=must be greater than 0
org.apache.fineract.common.validation.PositiveOrZero.message=must be greater than or equal to 0
org.apache.fineract.common.validation.Size.message=size must be between {min} and {max}
org.apache.fineract.common.validation.CreditCardNumber.message=invalid credit card number
org.apache.fineract.common.validation.Currency.message=invalid currency (must be one of {value})
org.apache.fineract.common.validation.EAN.message=invalid {type} barcode
org.apache.fineract.common.validation.ISBN.message=invalid ISBN
org.apache.fineract.common.validation.Length.message=length must be between {min} and {max}
org.apache.fineract.common.validation.CodePointLength.message=length must be between {min} and {max}
org.apache.fineract.common.validation.LuhnCheck.message=the check digit for ${validatedValue} is invalid, Luhn Modulo 10 checksum failed
org.apache.fineract.common.validation.Mod10Check.message=the check digit for ${validatedValue} is invalid, Modulo 10 checksum failed
org.apache.fineract.common.validation.Mod11Check.message=the check digit for ${validatedValue} is invalid, Modulo 11 checksum failed
org.apache.fineract.common.validation.ModCheck.message=the check digit for ${validatedValue} is invalid, {modType} checksum failed
org.apache.fineract.common.validation.Normalized.message=must be normalized
org.apache.fineract.common.validation.ParametersScriptAssert.message=script expression "{script}" didn't evaluate to true
org.apache.fineract.common.validation.Range.message=must be between {min} and {max}
org.apache.fineract.common.validation.ScriptAssert.message=script expression "{script}" didn't evaluate to true
org.apache.fineract.common.validation.UniqueElements.message=must only contain unique elements
org.apache.fineract.common.validation.URL.message=must be a valid URL
org.apache.fineract.common.validation.UUID.message=must be a valid UUID
org.apache.fineract.common.validation.br.CNPJ.message=invalid Brazilian corporate taxpayer registry number (CNPJ)
org.apache.fineract.common.validation.br.CPF.message=invalid Brazilian individual taxpayer registry number (CPF)
org.apache.fineract.common.validation.br.TituloEleitoral.message=invalid Brazilian Voter ID card number
org.apache.fineract.common.validation.pl.REGON.message=invalid Polish Taxpayer Identification Number (REGON)
org.apache.fineract.common.validation.pl.NIP.message=invalid VAT Identification Number (NIP)
org.apache.fineract.common.validation.pl.PESEL.message=invalid Polish National Identification Number (PESEL)
org.apache.fineract.common.validation.ru.INN.message=invalid Russian taxpayer identification number (INN)
org.apache.fineract.common.validation.time.DurationMax.message=must be shorter than${inclusive == true ? ' or equal to' : ''}${days == 0 ? '' : days == 1 ? ' 1 day' : ' ' += days += ' days'}${hours == 0 ? '' : hours == 1 ? ' 1 hour' : ' ' += hours += ' hours'}${minutes == 0 ? '' : minutes == 1 ? ' 1 minute' : ' ' += minutes += ' minutes'}${seconds == 0 ? '' : seconds == 1 ? ' 1 second' : ' ' += seconds += ' seconds'}${millis == 0 ? '' : millis == 1 ? ' 1 milli' : ' ' += millis += ' millis'}${nanos == 0 ? '' : nanos == 1 ? ' 1 nano' : ' ' += nanos += ' nanos'}
org.apache.fineract.common.validation.time.DurationMin.message=must be longer than${inclusive == true ? ' or equal to' : ''}${days == 0 ? '' : days == 1 ? ' 1 day' : ' ' += days += ' days'}${hours == 0 ? '' : hours == 1 ? ' 1 hour' : ' ' += hours += ' hours'}${minutes == 0 ? '' : minutes == 1 ? ' 1 minute' : ' ' += minutes += ' minutes'}${seconds == 0 ? '' : seconds == 1 ? ' 1 second' : ' ' += seconds += ' seconds'}${millis == 0 ? '' : millis == 1 ? ' 1 milli' : ' ' += millis += ' millis'}${nanos == 0 ? '' : nanos == 1 ? ' 1 nano' : ' ' += nanos += ' nanos'}
# dummy
org.apache.fineract.dummy.request.content.not-empty=Dummy request content must have a value
@@ -0,0 +1,70 @@
#
# 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.
#
# common
org.apache.fineract.common.validation.error=Fehler
org.apache.fineract.common.validation.AssertFalse.message=muss falsch sein
org.apache.fineract.common.validation.AssertTrue.message=muss wahr sein
org.apache.fineract.common.validation.DecimalMax.message=muss kleiner ${inclusive == true ? 'oder gleich ' : ''}{value} sein
org.apache.fineract.common.validation.DecimalMin.message=muss gr\u00f6\u00dfer ${inclusive == true ? 'oder gleich ' : ''}{value} sein
org.apache.fineract.common.validation.Digits.message=numerischer Wert au\u00dferhalb des g\u00fcltigen Bereichs (<{integer} digits>.<{fraction} digits> erwartet)
org.apache.fineract.common.validation.Email.message=muss eine korrekt formatierte E-Mail-Adresse sein
org.apache.fineract.common.validation.Future.message=muss ein Datum in der Zukunft sein
org.apache.fineract.common.validation.FutureOrPresent.message=muss ein Datum in der Gegenwart oder in der Zukunft sein
org.apache.fineract.common.validation.Max.message=muss kleiner-gleich {value} sein
org.apache.fineract.common.validation.Min.message=muss gr\u00f6\u00dfer-gleich {value} sein
org.apache.fineract.common.validation.Negative.message=muss kleiner als 0 sein
org.apache.fineract.common.validation.NegativeOrZero.message=muss kleiner-gleich 0 sein
org.apache.fineract.common.validation.NotBlank.message=darf nicht leer sein
org.apache.fineract.common.validation.NotEmpty.message=darf nicht leer sein
org.apache.fineract.common.validation.NotNull.message=darf nicht null sein
org.apache.fineract.common.validation.Null.message=muss null sein
org.apache.fineract.common.validation.Past.message=muss ein Datum in der Vergangenheit sein
org.apache.fineract.common.validation.PastOrPresent.message=muss ein Datum in der Vergangenheit oder in der Gegenwart sein
org.apache.fineract.common.validation.Pattern.message=muss mit "{regexp}" \u00fcbereinstimmen
org.apache.fineract.common.validation.Positive.message=muss gr\u00f6\u00dfer als 0 sein
org.apache.fineract.common.validation.PositiveOrZero.message=muss gr\u00f6\u00dfer-gleich 0 sein
org.apache.fineract.common.validation.Size.message=Gr\u00f6\u00dfe muss zwischen {min} und {max} sein
org.apache.fineract.common.validation.CreditCardNumber.message=ung\u00fcltige Kreditkartennummer
org.apache.fineract.common.validation.Currency.message=ung\u00fcltige W\u00e4hrung (muss eine der folgenden sein: {value})
org.apache.fineract.common.validation.EAN.message=ung\u00fcltiger {type}-Barcode
org.apache.fineract.common.validation.ISBN.message=ung\u00fcltige ISBN
org.apache.fineract.common.validation.Length.message=L\u00e4nge muss zwischen {min} und {max} sein
org.apache.fineract.common.validation.CodePointLength.message=L\u00e4nge muss zwischen {min} und {max} sein
org.apache.fineract.common.validation.LuhnCheck.message=die Pr\u00fcfziffer f\u00fcr ${validatedValue} ist ung\u00fcltig, Luhn Modulo 10-Kontrollsumme ist fehlgeschlagen
org.apache.fineract.common.validation.Mod10Check.message=die Pr\u00fcfziffer f\u00fcr ${validatedValue} ist ung\u00fcltig, Modulo 10-Kontrollsumme ist fehlgeschlagen
org.apache.fineract.common.validation.Mod11Check.message=die Pr\u00fcfziffer f\u00fcr ${validatedValue} ist ung\u00fcltig, Modulo 11-Kontrollsumme ist fehlgeschlagen
org.apache.fineract.common.validation.ModCheck.message=die Pr\u00fcfziffer f\u00fcr ${validatedValue} ist ung\u00fcltig, {modType}-Kontrollsumme ist fehlgeschlagen
org.apache.fineract.common.validation.ParametersScriptAssert.message=die Evaluierung des Scriptausdrucks "{script}" ergab nicht true
org.apache.fineract.common.validation.Range.message=muss zwischen {min} und {max} sein
org.apache.fineract.common.validation.ScriptAssert.message=die Evaluierung des Scriptausdrucks "{script}" ergab nicht true
org.apache.fineract.common.validation.UniqueElements.message=darf nur eindeutige Elemente enthalten
org.apache.fineract.common.validation.URL.message=muss eine g\u00fcltige URL sein
org.apache.fineract.common.validation.UUID.message=muss eine g\u00fcltige UUID sein
org.apache.fineract.common.validation.br.CNPJ.message=ung\u00fcltige brasilianische Registriernummer f\u00fcr K\u00f6rperschaftssteuerzahlungen (CNPJ)
org.apache.fineract.common.validation.br.CPF.message=ung\u00fcltige brasilianische Registriernummer f\u00fcr Steuerzahlungen nat\u00fcrlicher Personen (CPF)
org.apache.fineract.common.validation.br.TituloEleitoral.message=ung\u00fcltige brasilianische ID-Karte f\u00fcr Entscheidungsberechtigte
org.apache.fineract.common.validation.pl.REGON.message=ung\u00fcltige polnische Steuerzahleridentifikationsnummer (REGON)
org.apache.fineract.common.validation.pl.NIP.message=ung\u00fcltige Mehrwertsteueridentifikationsnummer (NIP)
org.apache.fineract.common.validation.pl.PESEL.message=ung\u00fcltige polnische nationale Identifikationsnummer (PESEL)
org.apache.fineract.common.validation.ru.INN.message=ung\u00fcltige russisch Steuerzahleridentifikationsnummer (INN)
org.apache.fineract.common.validation.time.DurationMax.message=muss k\u00fcrzer sein als${inclusive == true ? ' oder gleich' : ''}${days == 0 ? '' : days == 1 ? ' 1 Tag' : ' ' += days += ' Tage'}${hours == 0 ? '' : hours == 1 ? ' 1 Stunde' : ' ' += hours += ' Stunden'}${minutes == 0 ? '' : minutes == 1 ? ' 1 Minute' : ' ' += minutes += ' Minuten'}${seconds == 0 ? '' : seconds == 1 ? ' 1 Sekunde' : ' ' += seconds += ' Sekunden'}${millis == 0 ? '' : millis == 1 ? ' 1 Millisekunde' : ' ' += millis += ' Millisekunden'}${nanos == 0 ? '' : nanos == 1 ? ' 1 Nanosekunde' : ' ' += nanos += ' Nanosekunden'}
org.apache.fineract.common.validation.time.DurationMin.message=muss gr\u00f6\u00dfer sein als${inclusive == true ? ' oder gleich' : ''}${days == 0 ? '' : days == 1 ? ' 1 Tag' : ' ' += days += ' Tage'}${hours == 0 ? '' : hours == 1 ? ' 1 Stunde' : ' ' += hours += ' Stunden'}${minutes == 0 ? '' : minutes == 1 ? ' 1 Minute' : ' ' += minutes += ' Minuten'}${seconds == 0 ? '' : seconds == 1 ? ' 1 Sekunde' : ' ' += seconds += ' Sekunden'}${millis == 0 ? '' : millis == 1 ? ' 1 Millisekunde' : ' ' += millis += ' Millisekunden'}${nanos == 0 ? '' : nanos == 1 ? ' 1 Nanosekunde' : ' ' += nanos += ' Nanosekunden'}
# dummy
org.apache.fineract.dummy.request.content.not-empty=Dummy Request Attribut muss einen Wert enthalten
@@ -0,0 +1,71 @@
#
# 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.
#
# common
org.apache.fineract.common.validation.error=Validation Error
org.apache.fineract.common.validation.AssertFalse.message=must be false
org.apache.fineract.common.validation.AssertTrue.message=must be true
org.apache.fineract.common.validation.DecimalMax.message=must be less than ${inclusive == true ? 'or equal to ' : ''}{value}
org.apache.fineract.common.validation.DecimalMin.message=must be greater than ${inclusive == true ? 'or equal to ' : ''}{value}
org.apache.fineract.common.validation.Digits.message=numeric value out of bounds (<{integer} digits>.<{fraction} digits> expected)
org.apache.fineract.common.validation.Email.message=must be a well-formed email address
org.apache.fineract.common.validation.Future.message=must be a future date
org.apache.fineract.common.validation.FutureOrPresent.message=must be a date in the present or in the future
org.apache.fineract.common.validation.Max.message=must be less than or equal to {value}
org.apache.fineract.common.validation.Min.message=must be greater than or equal to {value}
org.apache.fineract.common.validation.Negative.message=must be less than 0
org.apache.fineract.common.validation.NegativeOrZero.message=must be less than or equal to 0
org.apache.fineract.common.validation.NotBlank.message=must not be blank
org.apache.fineract.common.validation.NotEmpty.message=must not be empty
org.apache.fineract.common.validation.NotNull.message=must not be null
org.apache.fineract.common.validation.Null.message=must be null
org.apache.fineract.common.validation.Past.message=must be a past date
org.apache.fineract.common.validation.PastOrPresent.message=must be a date in the past or in the present
org.apache.fineract.common.validation.Pattern.message=must match "{regexp}"
org.apache.fineract.common.validation.Positive.message=must be greater than 0
org.apache.fineract.common.validation.PositiveOrZero.message=must be greater than or equal to 0
org.apache.fineract.common.validation.Size.message=size must be between {min} and {max}
org.apache.fineract.common.validation.CreditCardNumber.message=invalid credit card number
org.apache.fineract.common.validation.Currency.message=invalid currency (must be one of {value})
org.apache.fineract.common.validation.EAN.message=invalid {type} barcode
org.apache.fineract.common.validation.ISBN.message=invalid ISBN
org.apache.fineract.common.validation.Length.message=length must be between {min} and {max}
org.apache.fineract.common.validation.CodePointLength.message=length must be between {min} and {max}
org.apache.fineract.common.validation.LuhnCheck.message=the check digit for ${validatedValue} is invalid, Luhn Modulo 10 checksum failed
org.apache.fineract.common.validation.Mod10Check.message=the check digit for ${validatedValue} is invalid, Modulo 10 checksum failed
org.apache.fineract.common.validation.Mod11Check.message=the check digit for ${validatedValue} is invalid, Modulo 11 checksum failed
org.apache.fineract.common.validation.ModCheck.message=the check digit for ${validatedValue} is invalid, {modType} checksum failed
org.apache.fineract.common.validation.Normalized.message=must be normalized
org.apache.fineract.common.validation.ParametersScriptAssert.message=script expression "{script}" didn't evaluate to true
org.apache.fineract.common.validation.Range.message=must be between {min} and {max}
org.apache.fineract.common.validation.ScriptAssert.message=script expression "{script}" didn't evaluate to true
org.apache.fineract.common.validation.UniqueElements.message=must only contain unique elements
org.apache.fineract.common.validation.URL.message=must be a valid URL
org.apache.fineract.common.validation.UUID.message=must be a valid UUID
org.apache.fineract.common.validation.br.CNPJ.message=invalid Brazilian corporate taxpayer registry number (CNPJ)
org.apache.fineract.common.validation.br.CPF.message=invalid Brazilian individual taxpayer registry number (CPF)
org.apache.fineract.common.validation.br.TituloEleitoral.message=invalid Brazilian Voter ID card number
org.apache.fineract.common.validation.pl.REGON.message=invalid Polish Taxpayer Identification Number (REGON)
org.apache.fineract.common.validation.pl.NIP.message=invalid VAT Identification Number (NIP)
org.apache.fineract.common.validation.pl.PESEL.message=invalid Polish National Identification Number (PESEL)
org.apache.fineract.common.validation.ru.INN.message=invalid Russian taxpayer identification number (INN)
org.apache.fineract.common.validation.time.DurationMax.message=must be shorter than${inclusive == true ? ' or equal to' : ''}${days == 0 ? '' : days == 1 ? ' 1 day' : ' ' += days += ' days'}${hours == 0 ? '' : hours == 1 ? ' 1 hour' : ' ' += hours += ' hours'}${minutes == 0 ? '' : minutes == 1 ? ' 1 minute' : ' ' += minutes += ' minutes'}${seconds == 0 ? '' : seconds == 1 ? ' 1 second' : ' ' += seconds += ' seconds'}${millis == 0 ? '' : millis == 1 ? ' 1 milli' : ' ' += millis += ' millis'}${nanos == 0 ? '' : nanos == 1 ? ' 1 nano' : ' ' += nanos += ' nanos'}
org.apache.fineract.common.validation.time.DurationMin.message=must be longer than${inclusive == true ? ' or equal to' : ''}${days == 0 ? '' : days == 1 ? ' 1 day' : ' ' += days += ' days'}${hours == 0 ? '' : hours == 1 ? ' 1 hour' : ' ' += hours += ' hours'}${minutes == 0 ? '' : minutes == 1 ? ' 1 minute' : ' ' += minutes += ' minutes'}${seconds == 0 ? '' : seconds == 1 ? ' 1 second' : ' ' += seconds += ' seconds'}${millis == 0 ? '' : millis == 1 ? ' 1 milli' : ' ' += millis += ' millis'}${nanos == 0 ? '' : nanos == 1 ? ' 1 nano' : ' ' += nanos += ' nanos'}
# dummy
org.apache.fineract.dummy.request.content.not-empty=Dummy request content must have a value
@@ -20,23 +20,17 @@ package org.apache.fineract.infrastructure.core.domain;
import java.util.Map;
import java.util.Optional;
import org.springframework.core.NamedThreadLocal;
import org.springframework.transaction.TransactionStatus;
public final class BatchRequestContextHolder {
private BatchRequestContextHolder() {}
private static final ThreadLocal<Map<String, Object>> batchAttributes = new NamedThreadLocal<>("batchAttributes");
private static final ThreadLocal<Map<String, Object>> batchAttributes = new ThreadLocal<>();
private static final ThreadLocal<Optional<TransactionStatus>> batchTransaction = new NamedThreadLocal<>("batchTransaction") {
private static final ThreadLocal<Optional<TransactionStatus>> batchTransaction = ThreadLocal.withInitial(Optional::empty);
@Override
protected Optional<TransactionStatus> initialValue() {
return Optional.empty();
}
};
private static final ThreadLocal<Boolean> isEnclosingTransaction = new NamedThreadLocal<>("isEnclosingTransaction");
private static final ThreadLocal<Boolean> isEnclosingTransaction = new ThreadLocal<>();
/**
* True if the batch attributes are set
+1 -1
View File
@@ -168,7 +168,7 @@ configurations {
dependencies {
driver 'org.mariadb.jdbc:mariadb-java-client'
driver 'org.postgresql:postgresql'
driver 'mysql:mysql-connector-java:8.0.33'
driver 'com.mysql:mysql-connector-j'
}
URLClassLoader loader = GroovyObject.class.classLoader
+1
View File
@@ -36,6 +36,7 @@ dependencies {
implementation(project(path: ':fineract-progressive-loan'))
implementation(project(path: ':fineract-report'))
implementation(project(path: ':fineract-tax'))
implementation(project(path: ':fineract-command'))
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
@@ -58,7 +58,8 @@ import org.springframework.transaction.support.TransactionTemplate;
@Configuration
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = { "org.apache.fineract.**.domain", "org.apache.fineract.**.repository" })
@EnableJpaRepositories(basePackages = { "org.apache.fineract.**.domain", "org.apache.fineract.**.repository",
"org.apache.fineract.command.persistence" })
@EnableConfigurationProperties(JpaProperties.class)
@Import(JpaAuditingHandlerRegistrar.class)
public class JPAConfig extends JpaBaseConfiguration {
@@ -460,3 +460,8 @@ resilience4j.retry.instances.postInterest.wait-duration=${FINERACT_PROCESS_POST_
resilience4j.retry.instances.postInterest.enable-exponential-backoff=${FINERACT_PROCESS_POST_INTEREST_RETRY_ENABLE_EXPONENTIAL_BACKOFF:true}
resilience4j.retry.instances.postInterest.exponential-backoff-multiplier=${FINERACT_PROCESS_POST_INTEREST_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER:2}
resilience4j.retry.instances.postInterest.retryExceptions=${FINERACT_PROCESS_POST_INTEREST_RETRY_EXCEPTIONS:org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,jakarta.persistence.OptimisticLockException,org.springframework.orm.jpa.JpaOptimisticLockingFailureException}
fineract.command.enabled=true
fineract.command.executor=sync
fineract.command.ring-buffer-size=1024
fineract.command.producer-type=single
@@ -65,6 +65,7 @@ import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -86,6 +87,7 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = ScheduledJobRunnerConfig.class) })
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@PropertySource("classpath:application-test.properties")
public class TestConfiguration {
@Bean
@@ -285,3 +285,8 @@ fineract.insecure-http-client=true
fineract.client-connect-timeout=30
fineract.client-read-timeout=30
fineract.client-write-timeout=30
fineract.command.enabled=true
fineract.command.executor=sync
fineract.command.ring-buffer-size=1024
fineract.command.producer-type=single
+1 -1
View File
@@ -34,7 +34,7 @@ configurations {
driver
}
dependencies {
driver 'mysql:mysql-connector-java:8.0.33'
driver 'com.mysql:mysql-connector-j'
}
cargo {
+1 -1
View File
@@ -34,7 +34,7 @@ configurations {
driver
}
dependencies {
driver 'mysql:mysql-connector-java:8.0.33'
driver 'com.mysql:mysql-connector-j'
}
cargo {
+1
View File
@@ -48,6 +48,7 @@ buildCache {
rootProject.name='fineract'
include ':fineract-core'
include ':fineract-command'
include ':fineract-accounting'
include ':fineract-provider'
include ':fineract-branch'
+1 -1
View File
@@ -34,7 +34,7 @@ configurations {
driver
}
dependencies {
driver 'mysql:mysql-connector-java:8.0.33'
driver 'com.mysql:mysql-connector-j'
}
cargo {