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
@@ -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