diff --git a/business-transaction-anti-fraud-v1/Dockerfile b/business-transaction-anti-fraud-v1/Dockerfile new file mode 100644 index 0000000..6b54c40 --- /dev/null +++ b/business-transaction-anti-fraud-v1/Dockerfile @@ -0,0 +1,21 @@ +# Use an official OpenJDK runtime as a parent image +FROM openjdk:17-jdk-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the Maven project file and the source code +# COPY pom.xml /app +# COPY src /app/src + +# Package the application +# RUN ./mvnw clean install -DskipTests + +# Copy the packaged JAR file to the working directory +COPY target/*.jar app.jar + +# Exponer el puerto en el que la aplicación se ejecutará (cambiar si es necesario) +EXPOSE 8080 + +# Run the application +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/business-transaction-anti-fraud-v1/README.md b/business-transaction-anti-fraud-v1/README.md new file mode 100644 index 0000000..5060acd --- /dev/null +++ b/business-transaction-anti-fraud-v1/README.md @@ -0,0 +1,42 @@ +# API business-transaction-anti-fraud + +## Input Payload + +```json +{ + "id": "1", + "amount": "100", + "currency": "USD" +} +``` + +## Output Payload + +```json +{ + "transactionId": "1", + "isFraudulent": false, + "reason": "" +} +``` + + +# ingress-nginx + +**ingress-nginx** es un controlador de Ingress para Kubernetes basado en NGINX. Se utiliza para gestionar el acceso externo a los servicios en un clúster de Kubernetes, proporcionando balanceo de carga, terminación SSL y enrutamiento basado en host y ruta. + +## Características principales + +- **Balanceo de carga**: Distribuye el tráfico entrante entre múltiples réplicas de un servicio. +- **Terminación SSL**: Maneja la terminación SSL/TLS para asegurar las conexiones. +- **Enrutamiento basado en host y ruta**: Permite definir reglas para enrutar el tráfico a diferentes servicios basados en el host y la ruta de la solicitud. + +## Instalación + +Para instalar **ingress-nginx** en un clúster de Kubernetes, puedes usar Helm: + +```bash +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx +helm repo update +helm install ingress-nginx ingress-nginx/ingress-nginx --namespace ingress-nginx --create-namespace + diff --git a/business-transaction-anti-fraud-v1/checkstyle-checks.xml b/business-transaction-anti-fraud-v1/checkstyle-checks.xml new file mode 100644 index 0000000..aef1686 --- /dev/null +++ b/business-transaction-anti-fraud-v1/checkstyle-checks.xml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/checkstyle-suppressions.xml b/business-transaction-anti-fraud-v1/checkstyle-suppressions.xml new file mode 100644 index 0000000..35cfde6 --- /dev/null +++ b/business-transaction-anti-fraud-v1/checkstyle-suppressions.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/copyright_header.txt b/business-transaction-anti-fraud-v1/copyright_header.txt new file mode 100644 index 0000000..e69de29 diff --git a/business-transaction-anti-fraud-v1/docker-compose.yml b/business-transaction-anti-fraud-v1/docker-compose.yml new file mode 100644 index 0000000..e3f7a98 --- /dev/null +++ b/business-transaction-anti-fraud-v1/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3' +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.0.1 + container_name: zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - "2181:2181" + + kafka: + image: confluentinc/cp-kafka:7.0.1 + container_name: kafka + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + volumes: + - ./kafka-data:/var/lib/kafka/data + + kafka-manager: + image: hlebalbau/kafka-manager:latest + container_name: kafka-manager + depends_on: + - kafka + ports: + - "9000:9000" + environment: + ZK_HOSTS: zookeeper:2181 \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/kubectl/deployment.yaml b/business-transaction-anti-fraud-v1/kubectl/deployment.yaml new file mode 100644 index 0000000..a104726 --- /dev/null +++ b/business-transaction-anti-fraud-v1/kubectl/deployment.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: business-transaction-anti-fraud-v1 +spec: + replicas: 1 # Número inicial de réplicas + selector: + matchLabels: + app: business-transaction-anti-fraud-v1 + template: + metadata: + labels: + app: business-transaction-anti-fraud-v1 + spec: + containers: + - name: business-transaction-anti-fraud-v1-container + image: vllave/business-transaction-anti-fraud-v1:latest + resources: + limits: + cpu: "2" # cpu_limits + memory: "1512Mi" # memory_limits + requests: + cpu: "40m" # cpu_requests + memory: "256Mi" # memory_requests + env: + - name: JAVA_OPTS + value: "-Xms32m -Xmx1024m" # jvm_xms y jvm_xmx \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/kubectl/hpa.yaml b/business-transaction-anti-fraud-v1/kubectl/hpa.yaml new file mode 100644 index 0000000..98fe5ee --- /dev/null +++ b/business-transaction-anti-fraud-v1/kubectl/hpa.yaml @@ -0,0 +1,24 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: business-transaction-anti-fraud-v1-hpa +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: business-transaction-anti-fraud-v1 + minReplicas: 1 + maxReplicas: 4 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 900 # target_average_cpu (representa el % de uso de CPU por pod) + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 350 # target_average_memory (representa el % de uso de memoria por pod) \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/kubectl/ingress.yaml b/business-transaction-anti-fraud-v1/kubectl/ingress.yaml new file mode 100644 index 0000000..887bf1f --- /dev/null +++ b/business-transaction-anti-fraud-v1/kubectl/ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: business-transaction-anti-fraud-v1-ingress + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / +spec: + ingressClassName: nginx + rules: + - host: localhost + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: business-transaction-anti-fraud-v1-service + port: + number: 80 \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/kubectl/service.yaml b/business-transaction-anti-fraud-v1/kubectl/service.yaml new file mode 100644 index 0000000..32ab383 --- /dev/null +++ b/business-transaction-anti-fraud-v1/kubectl/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: business-transaction-anti-fraud-v1-service +spec: + selector: + app: business-transaction-anti-fraud-v1 + ports: + - protocol: TCP + port: 80 # Puerto interno del servicio + targetPort: 8085 # Puerto en el contenedor del pod + nodePort: 30007 # Puerto expuesto en el nodo +# type: LoadBalancer + type: NodePort # Usar NodePort para exponer el servicio localmente diff --git a/business-transaction-anti-fraud-v1/pom.xml b/business-transaction-anti-fraud-v1/pom.xml new file mode 100644 index 0000000..fb1ccb5 --- /dev/null +++ b/business-transaction-anti-fraud-v1/pom.xml @@ -0,0 +1,185 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.2 + + + com.bcp.services.trxantifraud + business-transaction-anti-fraud + 1.0.0 + business-transaction-anti-fraud + business transaction anti fraud + + + 17 + 4.7.3.4 + 1.12.0 + 1.5.0 + false + + + + + + org.springframework.kafka + spring-kafka + + + + io.projectreactor.kafka + reactor-kafka + + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.openapitools + jackson-databind-nullable + 0.2.6 + + + + org.apache.commons + commons-lang3 + 3.16.0 + + + + org.mockito + mockito-core + 5.12.0 + test + + + + io.projectreactor + reactor-test + 3.6.9 + test + + + + io.projectreactor.addons + reactor-adapter + 3.5.2 + + + + com.github.spotbugs + spotbugs-annotations + 4.7.3 + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + com.bcp.services.trxantifraud.StartApplication + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + + com.github.spotbugs + spotbugs + 4.7.3 + + + com.github.spotbugs + spotbugs-annotations + 4.7.3 + + + + Max + Low + true + spotbugs-exclude.xml + + + com.h3xstream.findsecbugs + findsecbugs-plugin + ${findsecbugs-plugin.version} + + + jp.skypencil.findbugs.slf4j + bug-pattern + ${bug-pattern-plugin.version} + + + + true + + + spotbugs-check + verify + + check + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.0 + + checkstyle-checks.xml + copyright_header.txt + checkstyle-suppressions.xml + true + true + false + + + + validate + validate + + check + + + + + + + + diff --git a/business-transaction-anti-fraud-v1/spotbugs-exclude.xml b/business-transaction-anti-fraud-v1/spotbugs-exclude.xml new file mode 100644 index 0000000..1600e28 --- /dev/null +++ b/business-transaction-anti-fraud-v1/spotbugs-exclude.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/StartApplication.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/StartApplication.java new file mode 100644 index 0000000..c0eb5d2 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/StartApplication.java @@ -0,0 +1,16 @@ +package com.bcp.services.trxantifraud; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * StartApplication. + * This class is used to start the application. + */ +@SpringBootApplication +public class StartApplication { + + public static void main(final String[] args) { + SpringApplication.run(StartApplication.class, args); + } +} diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/ReactiveKafkaConsumer.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/ReactiveKafkaConsumer.java new file mode 100644 index 0000000..792ee02 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/ReactiveKafkaConsumer.java @@ -0,0 +1,96 @@ +package com.bcp.services.trxantifraud.business; + +import com.bcp.services.trxantifraud.model.AnalysisResult; +import com.bcp.services.trxantifraud.model.RawTransaction; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.kafka.receiver.KafkaReceiver; +import reactor.kafka.receiver.ReceiverRecord; + +import java.util.UUID; + +/** + * Reactive Kafka Consumer.
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ReactiveKafkaConsumer { + + @Value("${application.kafka.producer.topic}") + private String topic; + + private final KafkaReceiver kafkaReceiver; + private final TransactionService transactionService; + private final ReactiveKafkaProducer reactiveKafkaProducer; + private final ObjectMapper objectMapper; + + @PostConstruct + public void initialize() { + startConsuming(); + } + + private void startConsuming() { + + kafkaReceiver.receive() + .doOnNext(this::processMessage) + .flatMap(this::processRecord) + .subscribe(); + } + + private void processMessage(final ConsumerRecord record) { + log.info("Message received: key = {}, value = {}", record.key(), record.value()); + } + + private Mono processRecord(final ReceiverRecord record) { + return Mono.just(record) + .map(this::getRawTransaction) + .flatMap(this::analyzeRawTransaction) + .flatMap(this::sendAnalysisResult) + .doOnError(error -> log.error("Error processing record: ", error)) + .then(acknowledgeRecord(record)); + } + + private RawTransaction getRawTransaction(final ReceiverRecord record) { + try { + return objectMapper.readValue(record.value(), RawTransaction.class); + } catch (JsonProcessingException ex) { + log.error("Error parsing message: ", ex); + throw new RuntimeException(ex); + } + } + + private Mono analyzeRawTransaction(final RawTransaction transaction) { + return transactionService.analyzeTrx(Mono.just(transaction)) + .doOnError(error -> log.error("Error analyzing transaction: ", error)); + } + + private Mono sendAnalysisResult(final AnalysisResult analysisResult) { + String analysisResultJson = getResultJson(analysisResult); + return reactiveKafkaProducer.sendMessage(topic, UUID.randomUUID().toString(), analysisResultJson) + .doOnError(error -> log.error("Error sending message: ", error)); + } + + private Mono acknowledgeRecord(final ReceiverRecord record) { + return Mono.fromRunnable(record.receiverOffset()::acknowledge) + .doOnError(error -> log.error("Error acknowledging message: ", error)) + .then(); + } + + private String getResultJson(final AnalysisResult analysisResult) { + try { + return objectMapper.writeValueAsString(analysisResult); + } catch (JsonProcessingException ex) { + log.error("Error parsing message: ", ex); + throw new RuntimeException(ex); + } + } + +} diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/ReactiveKafkaProducer.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/ReactiveKafkaProducer.java new file mode 100644 index 0000000..20fe921 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/ReactiveKafkaProducer.java @@ -0,0 +1,29 @@ +package com.bcp.services.trxantifraud.business; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderRecord; + +/** + * Reactive Kafka Producer.
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ReactiveKafkaProducer { + + private final KafkaSender kafkaSender; + + public Mono sendMessage(final String topic, final String key, final String value) { + ProducerRecord record = new ProducerRecord<>(topic, key, value); + SenderRecord senderRecord = SenderRecord.create(record, key); + + return kafkaSender.send(Mono.just(senderRecord)) + .doOnError(throwable -> log.error("Error sending message: {}", throwable.getMessage())) + .then(); + } +} diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/TransactionService.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/TransactionService.java new file mode 100644 index 0000000..10b786d --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/TransactionService.java @@ -0,0 +1,15 @@ +package com.bcp.services.trxantifraud.business; + +import com.bcp.services.trxantifraud.model.AnalysisResult; +import com.bcp.services.trxantifraud.model.RawTransaction; +import reactor.core.publisher.Mono; + +/** + * Transaction Service. + * This interface is used to represent a TransactionService. + */ +public interface TransactionService { + + Mono analyzeTrx(Mono transaction); + +} diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/impl/TransactionServiceImpl.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/impl/TransactionServiceImpl.java new file mode 100644 index 0000000..cf047d5 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/business/impl/TransactionServiceImpl.java @@ -0,0 +1,45 @@ +package com.bcp.services.trxantifraud.business.impl; + +import com.bcp.services.trxantifraud.business.TransactionService; +import com.bcp.services.trxantifraud.config.ApplicationProperties; +import com.bcp.services.trxantifraud.model.AnalysisResult; +import com.bcp.services.trxantifraud.model.RawTransaction; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +/** + * Transaction Service Implementation.
+ */ +@Service +@RequiredArgsConstructor +public class TransactionServiceImpl implements TransactionService { + + public static final String REASON = "Transaction amount exceeds the maximum allowed amount"; + private final ApplicationProperties applicationProperties; + + @Override + public Mono analyzeTrx(final Mono transaction) { + + return transaction.filter(trx -> trx.getAmount() + .compareTo(applicationProperties.getMaximumTransactionAmount()) <= 0) + .map(this::createLegitimateResult) + .switchIfEmpty(transaction.map(this::createFraudulentResult)); + } + + private AnalysisResult createLegitimateResult(final RawTransaction trx) { + return AnalysisResult.builder() + .isFraudulent(false) + .transactionId(trx.getId()) + .build(); + } + + private AnalysisResult createFraudulentResult(final RawTransaction trx) { + return AnalysisResult.builder() + .isFraudulent(true) + .transactionId(trx.getId()) + .reason(REASON) + .build(); + } + +} diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ApplicationProperties.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ApplicationProperties.java new file mode 100644 index 0000000..2c1a72f --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ApplicationProperties.java @@ -0,0 +1,20 @@ +package com.bcp.services.trxantifraud.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.math.BigDecimal; + +/** + * Application Properties.
+ */ +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "application") +public class ApplicationProperties { + + private BigDecimal maximumTransactionAmount; +} \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ObjectMapperConfig.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ObjectMapperConfig.java new file mode 100644 index 0000000..b170c02 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ObjectMapperConfig.java @@ -0,0 +1,18 @@ +package com.bcp.services.trxantifraud.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration class for Jackson ObjectMapper. + */ +@Configuration +public class ObjectMapperConfig { + + @Bean(name = "objectMapper2") + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} + diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ReactiveKafkaConsumerConfig.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ReactiveKafkaConsumerConfig.java new file mode 100644 index 0000000..f3dd3fb --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ReactiveKafkaConsumerConfig.java @@ -0,0 +1,54 @@ +package com.bcp.services.trxantifraud.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.kafka.receiver.KafkaReceiver; +import reactor.kafka.receiver.ReceiverOptions; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Reactive Kafka Consumer Configuration.
+ */ +@Configuration +public class ReactiveKafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + @Value("${spring.kafka.consumer.auto-offset-reset}") + private String autoOffsetReset; + + @Value("${spring.kafka.consumer.enable-auto-commit}") + private boolean enableAutoCommit; + + @Value("${spring.kafka.consumer.key-deserializer}") + private String keyDeserializer; + + @Value("${spring.kafka.consumer.value-deserializer}") + private String valueDeserializer; + + @Value("${application.kafka.consumer.topic}") + private String topic; + + @Bean + public KafkaReceiver kafkaReceiver() { + Map consumerProps = new HashMap<>(); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset); + consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, enableAutoCommit); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, keyDeserializer); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, valueDeserializer); + + ReceiverOptions receiverOptions = ReceiverOptions.create(consumerProps); + return KafkaReceiver.create(receiverOptions.subscription(Collections.singleton(topic))); + } +} diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ReactiveKafkaProducerConfig.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ReactiveKafkaProducerConfig.java new file mode 100644 index 0000000..9877635 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/config/ReactiveKafkaProducerConfig.java @@ -0,0 +1,38 @@ +package com.bcp.services.trxantifraud.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderOptions; + +import java.util.HashMap; +import java.util.Map; + +/** + * Reactive Kafka Producer Configuration.
+ */ +@Configuration +public class ReactiveKafkaProducerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.producer.key-serializer}") + private String keySerializer; + + @Value("${spring.kafka.producer.value-serializer}") + private String valueSerializer; + + @Bean + public KafkaSender kafkaSender() { + Map producerProps = new HashMap<>(); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, keySerializer); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, valueSerializer); + + SenderOptions senderOptions = SenderOptions.create(producerProps); + return KafkaSender.create(senderOptions); + } +} diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/model/AnalysisResult.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/model/AnalysisResult.java new file mode 100644 index 0000000..d3b1bdd --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/model/AnalysisResult.java @@ -0,0 +1,20 @@ +package com.bcp.services.trxantifraud.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * AnalysisResult. + * This class is used to represent an AnalysisResult. + */ +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class AnalysisResult { + private String transactionId; + private Boolean isFraudulent; + private String reason; +} diff --git a/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/model/RawTransaction.java b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/model/RawTransaction.java new file mode 100644 index 0000000..4da8b9b --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/java/com/bcp/services/trxantifraud/model/RawTransaction.java @@ -0,0 +1,22 @@ +package com.bcp.services.trxantifraud.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * RawTransaction. + * This class is used to represent a RawTransaction. + */ +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class RawTransaction { + private String id; + private BigDecimal amount; + private String currency; +} \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/src/main/resources/application-local.yml b/business-transaction-anti-fraud-v1/src/main/resources/application-local.yml new file mode 100644 index 0000000..408daa1 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/resources/application-local.yml @@ -0,0 +1,13 @@ +application: + maximum-transaction-amount: 1000 + kafka: + producer: + topic: transaction-processing + consumer: + group-id: reactive-group + topic: transaction-anti-fraud-validation +logging: + level: + root: INFO + com.bcp.services.trxantifraud: INFO + diff --git a/business-transaction-anti-fraud-v1/src/main/resources/application.yml b/business-transaction-anti-fraud-v1/src/main/resources/application.yml new file mode 100644 index 0000000..f4f1760 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/main/resources/application.yml @@ -0,0 +1,20 @@ +spring: + application: + name: business-transaction-anti-fraud + webflux: + base-path: /transaction-anti-fraud/v1 + kafka: + bootstrap-servers: localhost:9092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + consumer: + group-id: reactive-group + auto-offset-reset: earliest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + profiles: + active: local +server: + port: 8080 \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/src/test/java/com/bcp/services/trxantifraud/business/ReactiveKafkaConsumerTest.java b/business-transaction-anti-fraud-v1/src/test/java/com/bcp/services/trxantifraud/business/ReactiveKafkaConsumerTest.java new file mode 100644 index 0000000..f2669b9 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/test/java/com/bcp/services/trxantifraud/business/ReactiveKafkaConsumerTest.java @@ -0,0 +1,118 @@ +package com.bcp.services.trxantifraud.business; + +import com.bcp.services.trxantifraud.model.AnalysisResult; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.kafka.receiver.KafkaReceiver; +import reactor.kafka.receiver.ReceiverOffset; +import reactor.kafka.receiver.ReceiverRecord; +import reactor.test.StepVerifier; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReactiveKafkaConsumerTest { + + @Mock + private KafkaReceiver kafkaReceiver; + + @Mock + private TransactionService transactionService; + + @Mock + private ReactiveKafkaProducer reactiveKafkaProducer; + + @Spy + private ObjectMapper objectMapper; + + @InjectMocks + private ReactiveKafkaConsumer reactiveKafkaConsumer; + + @Test + @DisplayName("Initialize successfully") + void initializeSuccessfully() { + + ConsumerRecord consumerRecord = new ConsumerRecord<>("test-topic", 0, 0L, + "9295f66a-6db4-4a39-9af2-2c524f49fb39", + "{\"id\":\"cc34b401-ba74-41e6-9079-5e5b9567d938\",\"amount\":\"200.00\",\"currency\":\"USD\"}"); + + ReceiverRecord mockRecord = getStringStringReceiverRecord(consumerRecord); + doReturn(Flux.just(mockRecord)).when(kafkaReceiver).receive(); + + // Arrange + var analysisResult = AnalysisResult.builder().build(); + doReturn(Mono.empty()).when(reactiveKafkaProducer).sendMessage(any(), any(), any()); + doReturn(Mono.just(analysisResult)).when(transactionService).analyzeTrx(any()); + + // Act + reactiveKafkaConsumer.initialize(); + + // Assert + StepVerifier.create(transactionService.analyzeTrx(any())) + .expectNext(analysisResult) + .expectComplete() + .verify(); + + StepVerifier.create(reactiveKafkaProducer.sendMessage(any(), any(), any())) + .expectComplete() + .verify(); + } + + @Test + @DisplayName("Fail to initialize due to Kafka error") + void failToInitializeDueToKafkaError() { + // Arrange + ConsumerRecord consumerRecord = new ConsumerRecord<>("test-topic", 0, 0L, + "9295f66a-6db4-4a39-9af2-2c524f49fb39", + "{\"id\":\"cc34b401-ba74-41e6-9079-5e5b9567d938\",\"amount\":\"ee.00\",\"currency\":\"USD\"}"); + + ReceiverRecord mockRecord = getStringStringReceiverRecord(consumerRecord); + doReturn(Flux.just(mockRecord)).when(kafkaReceiver).receive(); + + reactiveKafkaConsumer.initialize(); + + // Assert + verify(transactionService, never()).analyzeTrx(any()); + } + + private static ReceiverRecord getStringStringReceiverRecord( + ConsumerRecord consumerRecord) { + ReceiverOffset receiverOffset = new ReceiverOffset() { + + @Override + public TopicPartition topicPartition() { + return new TopicPartition("test-topic", 0); + } + + @Override + public long offset() { + return 0; + } + + @Override + public void acknowledge() { + + } + + @Override + public Mono commit() { + return null; + } + }; + + return new ReceiverRecord<>(consumerRecord, receiverOffset); + } +} \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/src/test/java/com/bcp/services/trxantifraud/business/ReactiveKafkaProducerTest.java b/business-transaction-anti-fraud-v1/src/test/java/com/bcp/services/trxantifraud/business/ReactiveKafkaProducerTest.java new file mode 100644 index 0000000..c97acc0 --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/test/java/com/bcp/services/trxantifraud/business/ReactiveKafkaProducerTest.java @@ -0,0 +1,56 @@ +package com.bcp.services.trxantifraud.business; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.kafka.sender.KafkaSender; +import reactor.test.StepVerifier; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; + +@ExtendWith(MockitoExtension.class) +class ReactiveKafkaProducerTest { + + @Mock + private KafkaSender kafkaSender; + + @InjectMocks + private ReactiveKafkaProducer reactiveKafkaProducer; + + @Test + @DisplayName("Send message successfully") + void sendMessageSuccessfully() { + // Arrange + doReturn(Flux.empty()).when(kafkaSender).send(any(Mono.class)); + + // Act + Mono result = reactiveKafkaProducer.sendMessage("test-topic", "test-key", "test-value"); + + // Assert + StepVerifier.create(result) + .expectComplete() + .verify(); + } + + @Test + @DisplayName("Fail to send message") + void failToSendMessage() { + + // Arrange + doReturn(Flux.error(new RuntimeException("Kafka send error"))).when(kafkaSender).send(any(Mono.class)); + + // Act + Mono result = reactiveKafkaProducer.sendMessage("test-topic", "test-key", "test-value"); + + // Assert + StepVerifier.create(result) + .expectErrorMessage("Kafka send error") + .verify(); + } +} \ No newline at end of file diff --git a/business-transaction-anti-fraud-v1/src/test/java/com/bcp/services/trxantifraud/business/impl/RawTransactionServiceImplTest.java b/business-transaction-anti-fraud-v1/src/test/java/com/bcp/services/trxantifraud/business/impl/RawTransactionServiceImplTest.java new file mode 100644 index 0000000..4078e6f --- /dev/null +++ b/business-transaction-anti-fraud-v1/src/test/java/com/bcp/services/trxantifraud/business/impl/RawTransactionServiceImplTest.java @@ -0,0 +1,110 @@ +package com.bcp.services.trxantifraud.business.impl; + +import com.bcp.services.trxantifraud.config.ApplicationProperties; +import com.bcp.services.trxantifraud.model.RawTransaction; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RawTransactionServiceImplTest { + + @Mock + private ApplicationProperties applicationProperties; + + @InjectMocks + private TransactionServiceImpl transactionService; + + @Test + @DisplayName("Return a legitimate transaction when the transaction amount is less than the maximum allowed amount") + void returnALegitimateTransactionWhenTheTransactionAmountIsLessThanTheMaximumAllowedAmount() { + + // Arrange + when(applicationProperties.getMaximumTransactionAmount()).thenReturn(BigDecimal.valueOf(1000)); + + var uuid = UUID.randomUUID().toString(); + var rawTransaction = Mono.just(RawTransaction.builder() + .id(uuid) + .amount(BigDecimal.valueOf(500)) + .build()); + + // Act + var result = transactionService.analyzeTrx(rawTransaction); + + // Assert + StepVerifier.create(result) + .assertNext(result1 -> { + Assertions.assertNotNull(result1); + Assertions.assertEquals(uuid, result1.getTransactionId()); + Assertions.assertEquals(false, result1.getIsFraudulent()); + }) + .expectComplete() + .verify(); + } + + @Test + @DisplayName("Return a legitimate transaction when the transaction amount is equal to the maximum allowed amount") + void returnALegitimateTransactionWhenTheTransactionAmountIsEqualToTheMaximumAllowedAmount() { + + // Arrange + when(applicationProperties.getMaximumTransactionAmount()).thenReturn(BigDecimal.valueOf(1000)); + + var uuid = UUID.randomUUID().toString(); + var rawTransaction = Mono.just(RawTransaction.builder() + .id(uuid) + .amount(BigDecimal.valueOf(1000)) + .build()); + + // Act + var result = transactionService.analyzeTrx(rawTransaction); + + // Assert + StepVerifier.create(result) + .assertNext(result1 -> { + Assertions.assertNotNull(result1); + Assertions.assertEquals(uuid, result1.getTransactionId()); + Assertions.assertEquals(false, result1.getIsFraudulent()); + }) + .expectComplete() + .verify(); + } + + @Test + @DisplayName("Return a fraudulent transaction when the transaction amount exceeds the maximum allowed amount") + void returnAFraudulentTransactionWhenTheTransactionAmountExceedsTheMaximumAllowedAmount() { + + // Arrange + when(applicationProperties.getMaximumTransactionAmount()).thenReturn(BigDecimal.valueOf(1000)); + + var uuid = UUID.randomUUID().toString(); + var rawTransaction = Mono.just(RawTransaction.builder() + .id(uuid) + .amount(BigDecimal.valueOf(1500)) + .build()); + + // Act + var result = transactionService.analyzeTrx(rawTransaction); + + // Assert + StepVerifier.create(result) + .assertNext(result1 -> { + Assertions.assertNotNull(result1); + Assertions.assertEquals(uuid, result1.getTransactionId()); + Assertions.assertEquals(true, result1.getIsFraudulent()); + Assertions.assertEquals("Transaction amount exceeds the maximum allowed amount", result1.getReason()); + }) + .expectComplete() + .verify(); + } +} \ No newline at end of file diff --git a/business-transaction-v1/.openapi-generator-ignore b/business-transaction-v1/.openapi-generator-ignore new file mode 100644 index 0000000..2ca2093 --- /dev/null +++ b/business-transaction-v1/.openapi-generator-ignore @@ -0,0 +1,27 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +**/invoker/** +**/ApiExceptionDetail.java +**/ModelApiException.java +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md +# Prevent generator from creating these files: \ No newline at end of file diff --git a/business-transaction-v1/Dockerfile b/business-transaction-v1/Dockerfile new file mode 100644 index 0000000..5f21733 --- /dev/null +++ b/business-transaction-v1/Dockerfile @@ -0,0 +1,21 @@ +# Use an official OpenJDK runtime as a parent image +FROM openjdk:17-jdk-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the Maven project file and the source code +# COPY pom.xml /app +# COPY src /app/src + +# Package the application +# RUN ./mvnw clean install -DskipTests + +# Copy the packaged JAR file to the working directory +COPY target/business-transaction-1.0.0.jar app.jar + +# Exponer el puerto en el que la aplicación se ejecutará (cambiar si es necesario) +EXPOSE 8085 + +# Run the application +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/business-transaction-v1/README.md b/business-transaction-v1/README.md new file mode 100644 index 0000000..ce5dab7 --- /dev/null +++ b/business-transaction-v1/README.md @@ -0,0 +1,194 @@ +# API Business Transaction V1 + +# Pasos para instalar postgresql en docker + +### 1. Ejecutar el siguiente comando para descargar la imagen de postgresql +```bash +docker-compose up -d +``` +# Pasos para instalar kafka en docker + +### 1. Ejecutar el siguiente comando para descargar la imagen de kafka +```bash +docker-compose up -d +``` +### 2. Limpia los volúmenes de datos persistentes: +Elimina los datos persistidos de Kafka. Dependiendo de cómo configuraste tus volúmenes en docker-compose.yml, esto puede implicar borrar la carpeta que contiene los datos de Kafka. + +```bash +docker-compose down +``` +### 3. Esto eliminará los datos persistidos por Kafka, incluidos los archivos de meta.properties. + +```bash +sudo rm -rf ./kafka-data/* +``` + +# Pasos para Crear base datos en postgresql + +### 1. Crear la base de datos +```sql +CREATE SCHEMA IF NOT EXISTS trx; + + +DROP TABLE IF EXISTS trx.transaction; + + +CREATE TABLE trx.transaction(id varchar(36) PRIMARY KEY, + accountExternalIdDebit varchar(40), + accountExternalIdCredit varchar(40), + transactionTypeCode varchar(20), + amount numeric, + status varchar(10), + createdAt timestamp, + updatedAt timestamp); + +GRANT ALL PRIVILEGES ON SCHEMA trx TO postgresuser; + +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA trx TO postgresuser; +``` + +# Pasos para Crear y Subir una Imagen a Docker Hub + +### 1. Crear un `Dockerfile` +Define cómo se debe construir tu imagen. Ejemplo básico para una aplicación Node.js: + +```dockerfile +# Usar la imagen base oficial de OpenJDK con Java 17 +FROM openjdk:17-jdk-slim + +# Establecer el directorio de trabajo dentro del contenedor +WORKDIR /app + +# Copiar el archivo JAR de la aplicación al directorio de trabajo +COPY target/myapp.jar /app/myapp.jar + +# Exponer el puerto en el que la aplicación se ejecutará (cambiar si es necesario) +EXPOSE 8080 + +# Comando para ejecutar la aplicación Java +ENTRYPOINT ["java", "-jar", "myapp.jar"] + +``` + +### 2. Construir la Imagen +Usa el siguiente comando para construir la imagen: + +```bash +docker build -t /: . +``` + +### 3. Iniciar Sesión en Docker Hub +Inicia sesión en Docker Hub desde la terminal usando el siguiente comando: + +```bash +docker login +``` +### 4. Subir la Imagen a Docker Hub +Una vez que hayas iniciado sesión, sube la imagen a Docker Hub usando el siguiente comando: + +```bash +docker push /: +``` +### 5. Verificar la Imagen +Verifica que la imagen se haya subido correctamente iniciando sesión en [Docker Hub](https://hub.docker.com/). + +1. Inicia sesión en tu cuenta de Docker Hub. +2. Dirígete a tu perfil o al repositorio donde subiste la imagen. +3. La imagen debería aparecer con el nombre y la etiqueta que especificaste. + +### 6. Descargar y Ejecutar la Imagen + +Para descargar y ejecutar la imagen en otro entorno, usa los siguientes comandos: + +```bash +docker pull /: +docker run -p 4000:8080 -d /: +``` + +# Pasos para Ejecutar los Planes de `resources_plan` y `replicas_plan` en Kubernetes + +### 1. Crear el Archivo `deployment.yaml` +Define el archivo `deployment.yaml` con los recursos de CPU, memoria y configuraciones de JVM: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-application +spec: + replicas: 1 # Número inicial de réplicas + selector: + matchLabels: + app: my-application + template: + metadata: + labels: + app: my-application + spec: + containers: + - name: my-container + image: my-image:latest + resources: + limits: + cpu: "2" # cpu_limits + memory: "1512Mi" # memory_limits + requests: + cpu: "40m" # cpu_requests + memory: "256Mi" # memory_requests + env: + - name: JAVA_OPTS + value: "-Xms32m -Xmx1024m" # jvm_xms y jvm_xmx +``` + +### 2. Crear el Archivo `hpa.yaml` +Define el escalado automático en un archivo `hpa.yaml`: + +```yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: my-application-hpa +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: my-application + minReplicas: 1 + maxReplicas: 4 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 900 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 350 +``` + +### 3. Aplicar los Manifiestos YAML +Aplica los archivos `deployment.yaml` y `hpa.yaml` al clúster de Kubernetes con los siguientes comandos: + +```bash +kubectl apply -f deployment.yaml +kubectl apply -f hpa.yaml +``` +### 4. Verificar el Estado de los Recursos +Comprueba el estado del Deployment y del Horizontal Pod Autoscaler (HPA) para asegurarte de que todo está funcionando correctamente: + +```bash +kubectl get deployment my-application +kubectl get hpa my-application-hpa +``` + +### 5. Monitorear el Escalado +Monitorea cómo el Horizontal Pod Autoscaler (HPA) ajusta las réplicas automáticamente basándose en las métricas de CPU y memoria: + +```bash +kubectl describe hpa my-application-hpa +``` \ No newline at end of file diff --git a/business-transaction-v1/checkstyle-checks.xml b/business-transaction-v1/checkstyle-checks.xml new file mode 100644 index 0000000..aef1686 --- /dev/null +++ b/business-transaction-v1/checkstyle-checks.xml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/business-transaction-v1/checkstyle-suppressions.xml b/business-transaction-v1/checkstyle-suppressions.xml new file mode 100644 index 0000000..e6ddc75 --- /dev/null +++ b/business-transaction-v1/checkstyle-suppressions.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/business-transaction-v1/copyright_header.txt b/business-transaction-v1/copyright_header.txt new file mode 100644 index 0000000..e69de29 diff --git a/business-transaction-v1/docker-compose.yml b/business-transaction-v1/docker-compose.yml new file mode 100644 index 0000000..2abf693 --- /dev/null +++ b/business-transaction-v1/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + postgres: + image: postgres:latest + environment: + POSTGRES_DB: mypostgresdb + POSTGRES_USER: postgresuser + POSTGRES_PASSWORD: postgrespassword + volumes: + - postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" + +volumes: + postgres-data: \ No newline at end of file diff --git a/business-transaction-v1/kubectl/deployment.yaml b/business-transaction-v1/kubectl/deployment.yaml new file mode 100644 index 0000000..04bcbef --- /dev/null +++ b/business-transaction-v1/kubectl/deployment.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: business-transaction-v1 +spec: + replicas: 1 # Número inicial de réplicas + selector: + matchLabels: + app: business-transaction-v1 + template: + metadata: + labels: + app: business-transaction-v1 + spec: + containers: + - name: business-transaction-v1-container + image: vllave/business-transaction-v1:latest + resources: + limits: + cpu: "2" # cpu_limits + memory: "1512Mi" # memory_limits + requests: + cpu: "40m" # cpu_requests + memory: "256Mi" # memory_requests + env: + - name: JAVA_OPTS + value: "-Xms32m -Xmx1024m" # jvm_xms y jvm_xmx \ No newline at end of file diff --git a/business-transaction-v1/kubectl/hpa.yaml b/business-transaction-v1/kubectl/hpa.yaml new file mode 100644 index 0000000..8c1ef21 --- /dev/null +++ b/business-transaction-v1/kubectl/hpa.yaml @@ -0,0 +1,24 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: business-transaction-v1-hpa +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: business-transaction-v1 + minReplicas: 1 + maxReplicas: 4 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 900 # target_average_cpu (representa el % de uso de CPU por pod) + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 350 # target_average_memory (representa el % de uso de memoria por pod) \ No newline at end of file diff --git a/business-transaction-v1/kubectl/ingress.yaml b/business-transaction-v1/kubectl/ingress.yaml new file mode 100644 index 0000000..e61f30f --- /dev/null +++ b/business-transaction-v1/kubectl/ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: business-transaction-v1-ingress + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / +spec: + ingressClassName: nginx + rules: + - host: localhost + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: business-transaction-v1-service + port: + number: 80 \ No newline at end of file diff --git a/business-transaction-v1/kubectl/service.yaml b/business-transaction-v1/kubectl/service.yaml new file mode 100644 index 0000000..2e24e42 --- /dev/null +++ b/business-transaction-v1/kubectl/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: business-transaction-v1-service +spec: + selector: + app: business-transaction-v1 + ports: + - protocol: TCP + port: 80 + targetPort: 8085 + type: LoadBalancer \ No newline at end of file diff --git a/business-transaction-v1/pom.xml b/business-transaction-v1/pom.xml new file mode 100644 index 0000000..95d6bc0 --- /dev/null +++ b/business-transaction-v1/pom.xml @@ -0,0 +1,360 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.2 + + + com.bcp.services.transaction + business-transaction + 1.0.0 + business-transaction + business transaction + + + + 17 + + 4.7.3.4 + 1.12.0 + 1.5.0 + + false + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.kafka + spring-kafka + + + + io.projectreactor.kafka + reactor-kafka + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.springframework.boot + spring-boot-starter-validation + + + + jakarta.validation + jakarta.validation-api + 3.0.2 + + + + jakarta.servlet + jakarta.servlet-api + 6.0.0 + provided + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.openapitools + jackson-databind-nullable + 0.2.6 + + + + com.google.guava + guava + 33.1.0-jre + + + + io.swagger.core.v3 + swagger-annotations + 2.2.22 + + + + org.apache.commons + commons-lang3 + 3.16.0 + + + + commons-io + commons-io + 2.16.1 + + + + io.gsonfire + gson-fire + 1.8.5 + + + + org.mockito + mockito-core + 5.3.1 + test + + + + io.projectreactor + reactor-test + 3.6.9 + test + + + + io.projectreactor.addons + reactor-adapter + 3.5.2 + + + + com.github.spotbugs + spotbugs-annotations + 4.7.3 + + + + org.springframework.boot + spring-boot-starter-data-r2dbc + + + + org.postgresql + postgresql + runtime + + + org.postgresql + r2dbc-postgresql + runtime + + + + io.vavr + vavr + 0.10.4 + + + + org.skyscreamer + jsonassert + 1.5.3 + test + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + org.springdoc + springdoc-openapi-webflux-ui + 1.8.0 + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + com.bcp.services.transaction.StartApplication + + + + + + org.openapitools + openapi-generator-maven-plugin + 7.7.0 + + + transaction + + generate + + + ${project.basedir}/src/main/resources/openapi.yaml + spring + spring-boot + ${project.groupId}.trx.api + ${project.groupId}.trx.model + + ApiUtil.java + + + ./.openapi-generator-ignore + + + true + true + true + false + false + + true + true + true + true + true + true + false + true + true + + + + + OffsetDateTime=LocalDateTime + ApiException=ApiException + ApiExceptionDetail=ApiExceptionDetail + + + java.time.OffsetDateTime=java.time.LocalDateTime + LocalDateTime=java.time.LocalDateTime + ApiException=com.bcp.services.transaction.config.core.utils.exception.ApiException + ApiExceptionDetail=com.bcp.services.transaction.config.core.utils.exception.ApiExceptionDetail + + + + + + + com.google.code.maven-replacer-plugin + replacer + 1.5.3 + + + generate-sources + + replace + + + + + ${project.basedir}/target/ + + **/TransactionApi.java + **/TransactionApiDelegate.java + + + + import com.bcp.services.transaction.invoker.CollectionFormats.*; + + + + LocalDateTime requestDate + java.time.LocalDateTime requestDate + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + + com.github.spotbugs + spotbugs + 4.7.3 + + + com.github.spotbugs + spotbugs-annotations + 4.7.3 + + + + Max + Low + true + spotbugs-exclude.xml + + + com.h3xstream.findsecbugs + findsecbugs-plugin + ${findsecbugs-plugin.version} + + + jp.skypencil.findbugs.slf4j + bug-pattern + ${bug-pattern-plugin.version} + + + + true + + + spotbugs-check + verify + + check + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.0 + + checkstyle-checks.xml + copyright_header.txt + checkstyle-suppressions.xml + true + true + false + + + + validate + validate + + check + + + + + + + + diff --git a/business-transaction-v1/spotbugs-exclude.xml b/business-transaction-v1/spotbugs-exclude.xml new file mode 100644 index 0000000..ca798da --- /dev/null +++ b/business-transaction-v1/spotbugs-exclude.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/StartApplication.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/StartApplication.java new file mode 100644 index 0000000..0e1faf9 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/StartApplication.java @@ -0,0 +1,16 @@ +package com.bcp.services.transaction; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * StartApplication. + * This class is used to start the application. + */ +@SpringBootApplication +public class StartApplication { + + public static void main(final String[] args) { + SpringApplication.run(StartApplication.class, args); + } +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/ReactiveKafkaConsumer.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/ReactiveKafkaConsumer.java new file mode 100644 index 0000000..ffc459a --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/ReactiveKafkaConsumer.java @@ -0,0 +1,50 @@ +package com.bcp.services.transaction.business; + +import com.bcp.services.transaction.model.AnalysisResult; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.kafka.receiver.KafkaReceiver; + +/** + * Reactive Kafka Consumer.
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ReactiveKafkaConsumer { + + private final KafkaReceiver kafkaReceiver; + private final TransactionService transactionService; + private final ObjectMapper objectMapper; + + @PostConstruct + public void initialize() { + startConsuming(); + } + + private void startConsuming() { + + kafkaReceiver.receive() + .doOnNext(this::processMessage) + .flatMap(record -> Mono.fromCallable(() -> objectMapper.readValue(record.value(), AnalysisResult.class)) + .flatMap(transactionService::updateTrx) + .doOnError(error -> log.error("[0] Error processing analysis result: ", error)) + .then(Mono.fromRunnable(record.receiverOffset()::acknowledge)) + .onErrorResume(ex -> { + log.error("[1] Error processing analysis result: ", ex); + return Mono.empty(); + })) + .subscribe(); + + } + + private void processMessage(final ConsumerRecord record) { + log.info("Message received: key = {}, value = {}", record.key(), record.value()); + } + +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/ReactiveKafkaProducer.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/ReactiveKafkaProducer.java new file mode 100644 index 0000000..1875564 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/ReactiveKafkaProducer.java @@ -0,0 +1,33 @@ +package com.bcp.services.transaction.business; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderRecord; + +/** + * Reactive Kafka Producer.
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ReactiveKafkaProducer { + + private final KafkaSender kafkaSender; + + public Mono sendMessage(final String topic, final String key, final String value) { + ProducerRecord record = new ProducerRecord<>(topic, key, value); + SenderRecord senderRecord = SenderRecord.create(record, key); + + return kafkaSender.send(Mono.just(senderRecord)) + .flatMap(result -> Mono.empty()) + .onErrorResume(throwable -> { + log.error("Error sending message: {}", throwable.getMessage()); + return Mono.empty(); + }) + .then(); + } +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/TransactionService.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/TransactionService.java new file mode 100644 index 0000000..f40acc6 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/TransactionService.java @@ -0,0 +1,19 @@ +package com.bcp.services.transaction.business; + +import com.bcp.services.transaction.model.AnalysisResult; +import com.bcp.services.transaction.trx.model.GetTransactionResponse; +import com.bcp.services.transaction.trx.model.TransactionRequest; +import com.bcp.services.transaction.trx.model.TransactionResponse; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +/** + * Transaction Service.
+ */ +public interface TransactionService { + + Mono createTrx(Mono transaction); + Mono getTrx(UUID transactionExternalId); + Mono updateTrx(AnalysisResult transaction); +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/impl/TransactionServiceImpl.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/impl/TransactionServiceImpl.java new file mode 100644 index 0000000..e7c6f45 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/business/impl/TransactionServiceImpl.java @@ -0,0 +1,158 @@ +package com.bcp.services.transaction.business.impl; + +import com.bcp.services.transaction.business.ReactiveKafkaProducer; +import com.bcp.services.transaction.business.TransactionService; +import com.bcp.services.transaction.config.ApplicationProperties; +import com.bcp.services.transaction.model.AnalysisResult; +import com.bcp.services.transaction.model.RawTransaction; +import com.bcp.services.transaction.model.TransactionEntity; +import com.bcp.services.transaction.repository.TransactionRepository; +import com.bcp.services.transaction.trx.model.GetTransactionResponse; +import com.bcp.services.transaction.trx.model.TransactionRequest; +import com.bcp.services.transaction.trx.model.TransactionResponse; +import com.bcp.services.transaction.trx.model.TransactionStatusResponse; +import com.bcp.services.transaction.trx.model.TransactionTypeResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static com.bcp.services.transaction.util.exception.CustomApiException.C4091; +import static com.bcp.services.transaction.util.exception.CustomApiException.C5001; +import static com.bcp.services.transaction.util.exception.CustomApiException.C5003; +import static com.bcp.services.transaction.util.exception.ExceptionUtils.buildApiExceptionFromPostgresqlThrowable; + +/** + * Transaction Service Implementation.
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TransactionServiceImpl implements TransactionService { + + @Value("${application.kafka.producer.topic}") + private String topic; + + private final TransactionRepository transactionRepository; + private final R2dbcEntityTemplate entityTemplate; + private final ReactiveKafkaProducer reactiveKafkaProducer; + private final ApplicationProperties applicationProperties; + private final ObjectMapper objectMapper; + + @Override + public Mono createTrx(final Mono requestMono) { + + return requestMono + .map(this::buildTransactionEntity) + .flatMap(this::saveTransaction) + .flatMap(transactionEntity -> sendMessage(transactionEntity) + .then(Mono.just(transactionEntity))) + .map(this::buildTransactionResponse); + } + + private TransactionEntity buildTransactionEntity(final TransactionRequest request) { + return TransactionEntity.builder() + .id(UUID.randomUUID().toString()) + .accountExternalIdDebit(request.getAccountExternalIdDebit().toString()) + .accountExternalIdCredit(request.getAccountExternalIdCredit().toString()) + .transactionTypeCode(request.getTransactionTypeCode()) + .amount(request.getAmount()) + .status(applicationProperties.getTrxStatuses().getPending()) + .createdAt(LocalDateTime.now()) + .build(); + } + + private Mono saveTransaction(final TransactionEntity transactionEntity) { + return entityTemplate + .insert(TransactionEntity.class) + .using(transactionEntity) + .doOnError((Throwable ex) -> log.error("Error saving transaction", ex)) + .onErrorResume((Throwable ex) -> Mono.error(buildApiExceptionFromPostgresqlThrowable(C5001, ex))); + } + + private Mono sendMessage(final TransactionEntity entity) { + return convertTransactionToJson(entity) + .flatMap(trxJson -> reactiveKafkaProducer.sendMessage(topic, UUID.randomUUID().toString(), trxJson) + .doOnError(ex -> log.error("Error sending message to Kafka", ex))) + .onErrorResume(ex -> Mono.empty()); + } + + private Mono convertTransactionToJson(final TransactionEntity entity) { + var rawTransaction = RawTransaction.builder() + .id(entity.getId()) + .amount(entity.getAmount()) + .build(); + + try { + return Mono.just(objectMapper.writeValueAsString(rawTransaction)); + } catch (JsonProcessingException ex) { + log.error("Error processing transaction", ex); + return Mono.error(C5003.getException(ex)); + } + } + + private TransactionResponse buildTransactionResponse(final TransactionEntity entity) { + return new TransactionResponse() + .transactionExternalId(UUID.fromString(entity.getId())); + } + + @Override + public Mono getTrx(final UUID transactionExternalId) { + return transactionRepository + .findById(transactionExternalId.toString()) + .map(this::buildGetTransactionResponse) + .doOnError((Throwable ex) -> log.error("Error obtaining transaction", ex)) + .onErrorResume((Throwable ex) -> + Mono.error(buildApiExceptionFromPostgresqlThrowable(C5001, ex))); + } + + private GetTransactionResponse buildGetTransactionResponse(final TransactionEntity entity) { + return new GetTransactionResponse() + .transactionExternalId(UUID.fromString(entity.getId())) + .transactionType(new TransactionTypeResponse() + .code(entity.getTransactionTypeCode()) + .name(applicationProperties.getTrxTypes().get(entity.getTransactionTypeCode()))) + .amount(entity.getAmount()) + .transactionStatus(new TransactionStatusResponse() + .name(entity.getStatus())) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()); + } + + @Override + public Mono updateTrx(final AnalysisResult analysisResult) { + return transactionRepository + .findById(analysisResult.getTransactionId()) + .switchIfEmpty(Mono.defer(() -> Mono.error(C4091.getException()))) + .flatMap(transactionEntity -> updateTransactionEntity(transactionEntity, analysisResult)) + .onErrorMap(this::handleError) + .then(); + } + + private Mono updateTransactionEntity(final TransactionEntity transactionEntity, + final AnalysisResult analysisResult) { + + String status = analysisResult.getIsFraudulent() ? applicationProperties + .getTrxStatuses().getRejected() : applicationProperties.getTrxStatuses().getApproved(); + + var trx = transactionEntity.toBuilder() + .status(status) + .updatedAt(LocalDateTime.now()) + .build(); + return transactionRepository.save(trx) + .doOnError(ex -> log.error("Error updating transaction in PostgresSQL: {}", ex.getMessage())); + } + + private Throwable handleError(final Throwable ex) { + log.error("Error processing transaction update: {}", ex.getMessage()); + return buildApiExceptionFromPostgresqlThrowable(C5001, ex); + } + +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/ApplicationProperties.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/ApplicationProperties.java new file mode 100644 index 0000000..7e24031 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/ApplicationProperties.java @@ -0,0 +1,41 @@ +package com.bcp.services.transaction.config; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +/** + * Application Properties.
+ */ +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "application") +public class ApplicationProperties { + + private Map trxTypes; + private TrxStatuses trxStatuses; + + /** + * Class that contains Counterparty Type. + * + * @author Vito + * @version 1.0 + */ + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TrxStatuses { + private String pending; + private String approved; + private String rejected; + } +} \ No newline at end of file diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/CustomWebFluxConfigurer.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/CustomWebFluxConfigurer.java new file mode 100644 index 0000000..165c32f --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/CustomWebFluxConfigurer.java @@ -0,0 +1,34 @@ +package com.bcp.services.transaction.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.springframework.http.codec.ServerCodecConfigurer; + +/** + * Custom WebFlux Configurer. + */ +@Configuration +public class CustomWebFluxConfigurer implements WebFluxConfigurer { + + @Override + public void configureHttpMessageCodecs(final ServerCodecConfigurer configurer) { + configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper(), MediaType.APPLICATION_JSON)); + configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper(), MediaType.APPLICATION_JSON)); + } + + @Bean + public ObjectMapper objectMapper() { + return Jackson2ObjectMapperBuilder.json() + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // ISO-8601 format + .modules(new JavaTimeModule()) // Register the JavaTimeModule + .build(); + } +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/ReactiveKafkaConsumerConfig.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/ReactiveKafkaConsumerConfig.java new file mode 100644 index 0000000..af8c6f8 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/ReactiveKafkaConsumerConfig.java @@ -0,0 +1,54 @@ +package com.bcp.services.transaction.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.kafka.receiver.KafkaReceiver; +import reactor.kafka.receiver.ReceiverOptions; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Reactive Kafka Consumer Configuration.
+ */ +@Configuration +public class ReactiveKafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + @Value("${spring.kafka.consumer.auto-offset-reset}") + private String autoOffsetReset; + + @Value("${spring.kafka.consumer.enable-auto-commit}") + private boolean enableAutoCommit; + + @Value("${spring.kafka.consumer.key-deserializer}") + private String keyDeserializer; + + @Value("${spring.kafka.consumer.value-deserializer}") + private String valueDeserializer; + + @Value("${application.kafka.consumer.topic}") + private String topic; + + @Bean + public KafkaReceiver kafkaReceiver() { + Map consumerProps = new HashMap<>(); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset); + consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, enableAutoCommit); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, keyDeserializer); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, valueDeserializer); + + ReceiverOptions receiverOptions = ReceiverOptions.create(consumerProps); + return KafkaReceiver.create(receiverOptions.subscription(Collections.singleton(topic))); + } +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/ReactiveKafkaProducerConfig.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/ReactiveKafkaProducerConfig.java new file mode 100644 index 0000000..f1abcdf --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/ReactiveKafkaProducerConfig.java @@ -0,0 +1,38 @@ +package com.bcp.services.transaction.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderOptions; + +import java.util.HashMap; +import java.util.Map; + +/** + * Reactive Kafka Producer Configuration.
+ */ +@Configuration +public class ReactiveKafkaProducerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.producer.key-serializer}") + private String keySerializer; + + @Value("${spring.kafka.producer.value-serializer}") + private String valueSerializer; + + @Bean + public KafkaSender kafkaSender() { + Map producerProps = new HashMap<>(); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, keySerializer); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, valueSerializer); + + SenderOptions senderOptions = SenderOptions.create(producerProps); + return KafkaSender.create(senderOptions); + } +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/config/ApplicationReadyListener.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/config/ApplicationReadyListener.java new file mode 100644 index 0000000..9897a54 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/config/ApplicationReadyListener.java @@ -0,0 +1,25 @@ +package com.bcp.services.transaction.config.core.utils.config; + +import com.bcp.services.transaction.config.core.utils.utils.PropertyUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +/** + * ApplicationReadyListener. + * This class is used to listen to the application ready event. + */ +@Component +@Slf4j +public class ApplicationReadyListener implements ApplicationListener { + + @Override + public void onApplicationEvent(final ApplicationReadyEvent event) { + log.trace("Configuring properties resolver..."); + final Environment environment = event.getApplicationContext().getEnvironment(); + PropertyUtils.setResolver(environment); + } + +} \ No newline at end of file diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/constants/Constants.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/constants/Constants.java new file mode 100644 index 0000000..e0b8eca --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/constants/Constants.java @@ -0,0 +1,15 @@ +package com.bcp.services.transaction.config.core.utils.constants; + +/** + * Class for applications constants. + * + * @author vito.ivan + */ +public final class Constants { + + private Constants() { + } + + public static final String TECHNICAL_ERROR = "Technical"; + +} \ No newline at end of file diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/constants/ErrorCategory.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/constants/ErrorCategory.java new file mode 100644 index 0000000..af97261 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/constants/ErrorCategory.java @@ -0,0 +1,65 @@ +package com.bcp.services.transaction.config.core.utils.constants; + +import com.bcp.services.transaction.config.core.utils.utils.PropertyUtils; +import lombok.Getter; + +import static com.bcp.services.transaction.config.core.utils.constants.Constants.TECHNICAL_ERROR; + +/** + * Dummy
+ * Class: {@link ErrorCategory}
+ * + * @author vito.ivan
+ * @version 1.0 + */ + +public enum ErrorCategory { + + INVALID_REQUEST("invalid-request", 400), + ARGUMENT_MISMATCH("argument-mismatch", 400), + UNAUTHORIZED("unauthorized", 401), + FORBIDDEN("forbidden", 403), + RESOURCE_NOT_FOUND("resource-not-found", 404), + CONFLICT("conflict", 409), + PRECONDITION_FAILED("precondition-failed", 412), + EXTERNAL_ERROR("external-error", 500), + HOST_NOT_FOUND("host-not-found", 500), + UNEXPECTED("unexpected", 500), + NOT_IMPLEMENTED("not-implemented", 501), + SERVICE_UNAVAILABLE("service-unavailable", 503), + EXTERNAL_TIMEOUT("external-timeout", 503); + + private static final String PROPERTY_PREFIX = "application.api.error-code."; + private final String property; + @Getter + private final int httpStatus; + + ErrorCategory(final String property000, final int httpStatus000) { + this.property = PROPERTY_PREFIX.concat(property000); + this.httpStatus = httpStatus000; + } + + private String codeProperty() { + return this.property + ".code"; + } + + private String descriptionProperty() { + return this.property + ".description"; + } + + private String errorTypeProperty() { + return this.property + ".error-type"; + } + + public String getErrorType() { + return PropertyUtils.getOptionalValue(this.errorTypeProperty()).orElse(TECHNICAL_ERROR); + } + + public String getCode() { + return PropertyUtils.getOptionalValue(this.codeProperty()).orElse("TL9999"); + } + + public String getDescription() { + return PropertyUtils.getOptionalValue(this.descriptionProperty()).orElse("Sin descripcion configurada."); + } +} \ No newline at end of file diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/constants/HttpHeadersKey.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/constants/HttpHeadersKey.java new file mode 100644 index 0000000..b28fcb4 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/constants/HttpHeadersKey.java @@ -0,0 +1,20 @@ +package com.bcp.services.transaction.config.core.utils.constants; + +/** + * Dummy
+ * Class: {@link HttpHeadersKey}
+ * + * @author vito.ivan
+ * @version 1.0 + */ +public final class HttpHeadersKey { + + private HttpHeadersKey() { + } + + public static final String REQUEST_ID = "request-id"; + public static final String REQUEST_DATE = "request-date"; + public static final String APP_CODE = "app-code"; + public static final String CALLER_NAME = "caller-name"; + +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/exception/ApiException.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/exception/ApiException.java new file mode 100644 index 0000000..a1b09c1 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/exception/ApiException.java @@ -0,0 +1,267 @@ +package com.bcp.services.transaction.config.core.utils.exception; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.bcp.services.transaction.config.core.utils.constants.ErrorCategory; +import com.bcp.services.transaction.config.core.utils.constants.HttpHeadersKey; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.http.HttpHeaders; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Dummy
+ * Class: {@link ApiException}
+ * + * @author vito.ivan
+ * @version 1.0 + */ +@SuppressFBWarnings(value = "SE_TRANSIENT_FIELD_NOT_RESTORED", + justification = "Transient field that isn't set by deserialization.") +@Getter +@JsonAutoDetect(creatorVisibility = Visibility.NONE, + fieldVisibility = Visibility.NONE, getterVisibility = Visibility.NONE, + isGetterVisibility = Visibility.NONE, setterVisibility = Visibility.NONE) +@JsonInclude(Include.NON_NULL) +@Schema(description = "Datos del error de sistema.") +@Slf4j +public class ApiException extends RuntimeException { + + @JsonProperty + @Schema(title = "Codigo de error de Sistema", example = "TL0001") + private final String code; + + @JsonProperty + @Schema(title = "Descripcion del error de Sistema", example = "Error al llamar al servicio") + private final String description; + + @JsonProperty + @Schema(title = "Tipo de Error de Sistema", example = "TECHNICAL") + private final String errorType; + + @JsonProperty + @Schema(title = "Lista de detalles del error") + private final List exceptionDetails; + + @JsonProperty + @JsonIgnore + private final Map properties; + + @JsonIgnore + private final HttpHeaders headers; + + @Schema(title = "Categoria del error", example = "INVALID_REQUEST") + @JsonProperty + private final ErrorCategory category; + + @JsonIgnore + private final boolean isResolved; + + @SuppressFBWarnings( + justification = "generated code" + ) + @JsonCreator + private ApiException( + final @JsonProperty(value = "code", required = true) String code01, + final @JsonProperty(value = "description", required = true) String description01, + final @JsonProperty("errorType") String errorType01, + final @JsonProperty("exceptionDetails") List exceptionDetails01, + final @JsonProperty("properties") Map properties01) { + + this.code = code01; + this.description = description01; + this.errorType = errorType01; + this.headers = null; + this.category = null; + this.exceptionDetails = Optional.ofNullable(exceptionDetails01).map(Collections::unmodifiableList).orElseGet( + Collections::emptyList); + this.properties = properties01; + this.isResolved = true; + } + + @SuppressFBWarnings( + justification = "generated code" + ) + ApiException( + final String code01, + final String description01, + final String errorType01, + final ErrorCategory category01, + final List exceptionDetails01, + final Map properties01, + final HttpHeaders headers01, + final Throwable cause01, + final boolean resolved01) { + + super(description01, cause01); + + this.code = code01; + this.description = description01; + this.errorType = errorType01; + this.category = category01; + this.exceptionDetails = Optional.ofNullable(exceptionDetails01).map(Collections::unmodifiableList) + .orElseGet(Collections::emptyList); + this.properties = properties01; + this.headers = headers01; + this.isResolved = resolved01; + if (headers != null) { + this.putMdc(headers); + } + MDC.put("code", code); + MDC.put("description", description); + MDC.put("errorType", errorType); + exceptionDetails.forEach(this::setMdcProperties); + if (properties != null) { + properties.forEach((key, value) -> MDC.put(key, value.toString())); + } + } + + private void putMdc(final HttpHeaders headers0) { + List validHeaders = new ArrayList<>(); + validHeaders.add(HttpHeadersKey.REQUEST_ID); + validHeaders.add(HttpHeadersKey.APP_CODE); + validHeaders.add(HttpHeadersKey.REQUEST_DATE); + validHeaders.add(HttpHeadersKey.CALLER_NAME); + + if (headers0 != null) { + headers0.forEach((key, value) -> { + MDC.clear(); + if (validHeaders.contains(key)) { + MDC.put(key, value.get(value.size() - 1)); + } else { + log.debug("HttpHeadersKey not match"); + } + }); + } + + } + + private void setMdcProperties(final ApiExceptionDetail value) { + if (value.getCode() != null) { + MDC.put("exceptionDetails.code", value.getCode()); + } else if (value.getComponent() != null) { + MDC.put("exceptionDetails.component", value.getComponent()); + } else if (value.getDescription() != null) { + MDC.put("exceptionDetails.description", value.getDescription()); + } else if (value.getEndpoint() != null) { + MDC.put("exceptionDetails.endpoint", value.getEndpoint()); + } + } + + /** + * If the parent {@code cause} is an {@link ApiException}, joins with the + * current list. + * + * @return A list of {@link ApiExceptionDetail} + */ + @JsonProperty("exceptionDetails") + public List getExceptionDetails() { + if (getCause() instanceof ApiException apiException) { + List details = apiException.getExceptionDetails(); + List newDetails = new ArrayList<>(); + newDetails.addAll(exceptionDetails); + newDetails.addAll(details); + return Collections.unmodifiableList(newDetails); + } + return new ArrayList<>(exceptionDetails); + } + + /** + * Get the original list of {@link ApiExceptionDetail} not including the + * {@link Throwable} cause. + * + * @return A list of {@link ApiExceptionDetail} + */ + public List getUnresolvedExceptionDetails() { + return new ArrayList<>(exceptionDetails); + } + + /** + * Create an ApiExceptionBuilder instance. + * + * @return An ApiExceptionBuilder instance. + */ + public static ApiExceptionBuilder builder() { + return new ApiExceptionBuilder(); + } + + /** + * Create an ApiExceptionBuilder instance. + * + * @return An ApiExceptionBuilder instance. + */ + public ApiExceptionBuilder mutate() { + + ApiExceptionBuilder builder = builder() + .cause(isResolved() ? this : getCause()) + .category(getCategory()) + .errorType(getErrorType()) + .systemCode(getCode()) + .description(getDescription()); + + if (properties != null) { + properties.forEach(builder::addProperty); + } + + if (headers != null) { + headers.forEach((key, value1) -> { + for (String value : value1) { + builder.addHeader(key, value); + } + }); + } + + builder.setMutated(true); + + if (isResolved()) { + builder.markAsResolved(); + } else { + if (null != exceptionDetails) { + exceptionDetails.forEach(detail -> builder.addDetail(detail.isResolved()) + .withComponent(detail.getComponent()) + .withEndpoint(detail.getEndpoint()) + .withCode(detail.getCode()) + .withDescription(detail.getDescription()) + .push()); + } + } + return builder; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class ApiException {\n"); + sb.append(" code: ").append(toIndentedString(code)).append("\n"); + sb.append(" description: ").append(toIndentedString(description)).append("\n"); + sb.append(" errorType: ").append(toIndentedString(errorType)).append("\n"); + sb.append(" exceptionDetails: ").append(toIndentedString(exceptionDetails)).append("\n"); + sb.append(" category: ").append(toIndentedString(category)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(final Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/exception/ApiExceptionBuilder.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/exception/ApiExceptionBuilder.java new file mode 100644 index 0000000..724135a --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/exception/ApiExceptionBuilder.java @@ -0,0 +1,378 @@ +package com.bcp.services.transaction.config.core.utils.exception; + +import com.bcp.services.transaction.config.core.utils.constants.ErrorCategory; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.ClassUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.apache.commons.lang3.ClassUtils.isAssignable; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +/** + * Dummy
+ * Class: {@link ApiExceptionBuilder}
+ * + * @author vito.ivan
+ * @version 1.0 + */ +@NoArgsConstructor(access = AccessLevel.PACKAGE) +public final class ApiExceptionBuilder { + + private String componentName; + private String endpoint; + + private ErrorCategory category; + private String systemCode; + private String description; + private String errorType; + private Throwable cause; + + private final List apiExceptionDetails = new ArrayList<>(); + private final Map properties = new ConcurrentHashMap<>(); + private final HttpHeaders headers = new HttpHeaders(); + + private boolean hasNewCategory = false; + private boolean hasNewErrorType = false; + private boolean hasNewSystemCode = false; + private boolean hasNewDescription = false; + private boolean hasNewProperty = false; + private boolean hasNewHeader = false; + + private boolean isMutated = false; + private boolean isResolved = false; + + public ApiExceptionBuilder setComponentName(final String componentName0) { + this.componentName = componentName0; + return this; + } + + public ApiExceptionBuilder setEndpoint(final String endpoint0) { + this.endpoint = endpoint0; + return this; + } + + /** + * Sets whether this builder is coming from an existing {@link ApiException}. + */ + void setMutated(final boolean mutated0) { + isMutated = mutated0; + } + + /** + * Checks whether this builder is coming from an existing + * {@link ApiException}. + * + * @return True if it is mutated. + */ + public boolean isMutated() { + return isMutated; + } + + /** + * Set error category for new ApiException. + * + * @param category0 Error category to configure. + * @return An ApiExceptionBuilder instance. + */ + public ApiExceptionBuilder category(final ErrorCategory category0) { + if (category0 != null) { + this.hasNewCategory = true; + this.category = category0; + } + return this; + } + + /** + * Set error type for new ApiException. + * + * @param errorType0 Error type to configure. + * @return An ApiExceptionBuilder instance. + */ + public ApiExceptionBuilder errorType(final String errorType0) { + if (isNotBlank(errorType0)) { + this.hasNewErrorType = true; + this.errorType = errorType0; + } + return this; + } + + /** + * Set description for new ApiException. + * + * @param description0 Description to configure. + * @return An ApiExceptionBuilder instance. + */ + public ApiExceptionBuilder description(final String description0) { + if (isNotBlank(description0)) { + this.hasNewDescription = true; + this.description = description0; + } + return this; + } + + /** + * Set System code for new ApiException. + * + * @param systemCode0 System code to configure. + * @return An ApiExceptionBuilder instance. + */ + public ApiExceptionBuilder systemCode(final String systemCode0) { + if (isNotBlank(systemCode0)) { + this.hasNewSystemCode = true; + this.systemCode = systemCode0; + } + return this; + } + + /** + * Insert a pair key-value as a HTTP Header in the exception. + * + * @param key0 The entry key associated. + * @param value0 The value associated to key. + */ + public void addHeader(final String key0, final String value0) { + if (isNotBlank(key0) && StringUtils.isAsciiPrintable(key0)) { + this.hasNewHeader = true; + headers.add(key0, value0); + } + } + + /** + * Insert a pair key-value as a custom field in the exception. + * + * @param key The entry key associated. + * @param value The value associated to key. + */ + public void addProperty(final String key, final Object value) { + if (isNotBlank(key) && StringUtils.isAsciiPrintable(key) && isValidForProperty(value)) { + this.hasNewProperty = true; + properties.put(key, value); + } + } + + private static boolean isValidForProperty(final Object value) { + return value != null && (ClassUtils.isPrimitiveOrWrapper(value.getClass()) + || isAssignable(value.getClass(), CharSequence.class) + || isAssignable(value.getClass(), Date.class) + || isAssignable(value.getClass(), Calendar.class) + || isAssignable(value.getClass(), Instant.class) + || isAssignable(value.getClass(), Timestamp.class)); + } + + /** + * Set base exception for new ApiException. + * + * @param cause0 Base Exception to configure. + * @return An ApiExceptionBuilder instance. + */ + public ApiExceptionBuilder cause(final Throwable cause0) { + this.cause = cause0; + return this; + } + + private void addExceptionDetail(final ApiExceptionDetail detail) { + this.apiExceptionDetails.add(detail); + this.isResolved = false; + } + + /** + * Mark new ApiException as resolved. + */ + public void markAsResolved() { + this.isResolved = true; + } + + /** + * Returns the builder for create a new {@link ApiExceptionDetail}. + * + * @return The {@link ApiExceptionDetailBuilder} instance. + */ + public ApiExceptionDetailBuilder addDetail() { + return this.addDetail(false); + } + + /** + * Returns the builder for create a new {@link ApiExceptionDetail}. + * + * @param resolved Whether the detail is marked as resolved or not. + * @return The {@link ApiExceptionDetailBuilder} instance. + */ + public ApiExceptionDetailBuilder addDetail(final boolean resolved) { + return new ApiExceptionDetailBuilder(this, resolved); + } + + /** + * dummy. + * + * @author willianmarchan + */ + public static final class ApiExceptionDetailBuilder { + + private String code; + private String component; + private String description; + private Boolean resolved; + private String endpoint; + + private final ApiExceptionBuilder builder; + + private ApiExceptionDetailBuilder(final ApiExceptionBuilder builder0, final boolean resolved0) { + this.builder = builder0; + this.resolved = resolved0; + } + + /** + * Set the {@code code} for {@link ApiExceptionDetail}. + * + * @param code0 The component to be set. + * @return The {@link ApiExceptionDetailBuilder} instance. + */ + public ApiExceptionDetailBuilder withCode(final String code0) { + if (isNotBlank(code0)) { + this.code = code0; + } + return this; + } + + /** + * Set the {@code component} for {@link ApiExceptionDetail}. + * + * @param component0 The component to be set. + * @return The {@link ApiExceptionDetailBuilder} instance. + */ + public ApiExceptionDetailBuilder withComponent(final String component0) { + if (isNotBlank(component0)) { + this.component = component0; + } + return this; + } + + /** + * Set the {@code endpoint} for {@link ApiExceptionDetail}. + * + * @param endpoint0 The component to be set. + * @return The {@link ApiExceptionDetailBuilder} instance. + */ + public ApiExceptionDetailBuilder withEndpoint(final String endpoint0) { + if (isNotBlank(endpoint0)) { + this.endpoint = endpoint0; + } + return this; + } + + /** + * Set the {@code description} for {@link ApiExceptionDetail}. + * + * @param description0 The description to be set. + * @return The {@link ApiExceptionDetailBuilder} instance. + */ + public ApiExceptionDetailBuilder withDescription(final String description0) { + if (isNotBlank(description0)) { + this.description = description0; + } + return this; + } + + /** + * Creates and Adds a new {@link ApiExceptionDetail} with the parameters + * previosly setted. + * + * @return The {@link ApiExceptionBuilder} instance. + */ + public ApiExceptionBuilder push() { + component = component == null ? this.builder.componentName : component; + endpoint = endpoint == null ? this.builder.endpoint : endpoint; + if (isNotBlank(code) || isNotBlank(component) || isNotBlank(description) + || isNotBlank(endpoint)) { + builder.addExceptionDetail( + new ApiExceptionDetail(code, component, description, resolved, endpoint)); + this.code = null; + this.component = null; + this.description = null; + this.resolved = null; + } + return this.builder; + } + } + + /** + * Create a new instance of ApiException. + * + * @return An ApiException instance. + */ + public ApiException build() { + + Boolean isResolvedFlag = isResolved; + + if (cause != null) { + if (cause instanceof ApiException apiException) { + apiException.getUnresolvedExceptionDetails() + .forEach(detail -> addDetail(detail.isResolved()) + .withCode(detail.getCode()) + .withComponent(detail.getComponent()) + .withDescription(detail.getDescription()) + .withEndpoint(detail.getEndpoint()) + .push()); + cause(null); + } else if (!isMutated()) { + addDetail(true) + .withComponent(componentName) + .withEndpoint(endpoint) + .withDescription(cause.getClass().getName() + .concat(Optional.ofNullable(cause.getMessage()).map(" : "::concat).orElse(""))) + .push(); + cause(null); + } + } + + return this.createApiExceptionInstance(isResolvedFlag); + } + + private ApiException createApiExceptionInstance(final Boolean isResolvedFlag) { + return new ApiException(hasNewSystemCode ? systemCode : null, + // Avoid to call error catalog / with a previous code + hasNewDescription ? description : null, + hasNewErrorType ? errorType : null, + hasNewCategory ? category : ErrorCategory.UNEXPECTED, + apiExceptionDetails, + hasNewProperty ? properties : null, + hasNewHeader ? headers : null, + cause, + isResolvedFlag); + } + + public Mono buildAsCompletable() { + return Mono.error(this::build); + } + + public Mono buildAsSingle() { + return Mono.error(this::build); + } + + public Flux buildAsObservable() { + return Flux.error(this::build); + } + + public Flux buildAsMaybe() { + return Flux.error(this::build); + } + + public Flux buildAsFlowable() { + return Flux.error(this::build); + } +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/exception/ApiExceptionDetail.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/exception/ApiExceptionDetail.java new file mode 100644 index 0000000..30e934c --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/exception/ApiExceptionDetail.java @@ -0,0 +1,67 @@ +package com.bcp.services.transaction.config.core.utils.exception; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * Dummy
+ * Class: {@link ApiExceptionDetail}
+ * + * @author vito.ivan
+ * @version 1.0 + */ +@ToString +@Getter +@Setter +@Builder +@JsonInclude(Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Datos del error técnico.") +public class ApiExceptionDetail { + + @Schema(title = "Codigo de error del Detalle/Proveedor", example = "TL0008") + private final String code; + + @Schema(title = "Nombre del componente de falla", example = "biz-sinister-query-compensationclaim-lpg-v1") + private final String component; + + @Schema(title = "Descripcion del Detalle", example = "Codigo invalido para el canal") + private final String description; + + @JsonIgnore + private final boolean resolved; + + @Schema(title = "Endpoint que ejecuta el servicio", example = "(GET) /biz/sinister/query/compensation-claim/lpg/v1") + private final String endpoint; + + /** + * Create an ApiExceptionDetail instance. + * + * @param code0 Provider error code. + * @param component0 Service provider code. + * @param description0 Provider error description. + * @param resolved0 Whether the detail will be resolved or not. + */ + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + ApiExceptionDetail( + @JsonProperty("code") final String code0, + @JsonProperty("component") final String component0, + @JsonProperty("description") final String description0, + @JsonProperty("resolved") final boolean resolved0, + @JsonProperty("endpoint") final String endpoint0) { + this.code = code0; + this.component = component0; + this.description = description0; + this.resolved = resolved0; + this.endpoint = endpoint0; + } +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/utils/PropertyUtils.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/utils/PropertyUtils.java new file mode 100644 index 0000000..d86df5a --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/utils/utils/PropertyUtils.java @@ -0,0 +1,47 @@ +package com.bcp.services.transaction.config.core.utils.utils; + +import org.springframework.core.env.PropertyResolver; + +import java.util.Optional; + +/** + * Dummy
+ * Class: {@link PropertyUtils}
+ * + * @author vito.ivan
+ * @version 1.0 + */ +public final class PropertyUtils { + private PropertyUtils() { + } + + private static final String APPLICATION_CODE_PROPERTY = "spring.application.name"; + private static final String DEFAULT_APPLICATION_CODE = "unknown"; + + private static PropertyResolver resolver; + + public static void setResolver(final PropertyResolver resolver0) { + PropertyUtils.resolver = resolver0; + } + + public static String getValue(final String property) { + + return resolver.getProperty(property); + } + + public static T getValue(final String property, final Class resolvedClazz) { + return resolver.getProperty(property, resolvedClazz); + } + + public static Optional getOptionalValue(final String property, final Class resolvedClazz) { + return Optional.ofNullable(resolver.getProperty(property, resolvedClazz)); + } + + public static Optional getOptionalValue(final String property) { + return Optional.ofNullable(resolver.getProperty(property)); + } + + public static String getApplicationCode() { + return getOptionalValue(APPLICATION_CODE_PROPERTY).orElse(DEFAULT_APPLICATION_CODE); + } +} \ No newline at end of file diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/web/ErrorCategoryFromThrowable.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/web/ErrorCategoryFromThrowable.java new file mode 100644 index 0000000..f8e5a61 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/web/ErrorCategoryFromThrowable.java @@ -0,0 +1,41 @@ +package com.bcp.services.transaction.config.core.web; + +import com.bcp.services.transaction.config.core.utils.constants.ErrorCategory; + +import java.net.ConnectException; +import java.net.MalformedURLException; +import java.net.NoRouteToHostException; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.concurrent.TimeoutException; + +import static org.springframework.util.ClassUtils.isAssignable; + +/** + * ErrorCategoryFromThrowable. + * This class is used to map exceptions to error categories. + */ +public final class ErrorCategoryFromThrowable { + + private ErrorCategoryFromThrowable() { + } + + public static ErrorCategory mapExceptionToCategory(final Exception ex) { + Class exClass = ex.getClass(); + if (isAssignable(exClass, UnknownHostException.class) + || isAssignable(exClass, NoRouteToHostException.class) + || isAssignable(exClass, MalformedURLException.class) + || isAssignable(exClass, URISyntaxException.class)) { + return ErrorCategory.HOST_NOT_FOUND; + } else if (isAssignable(exClass, SocketTimeoutException.class) + || isAssignable(exClass, SocketException.class) + || isAssignable(exClass, TimeoutException.class)) { + return ErrorCategory.EXTERNAL_TIMEOUT; + } else if (isAssignable(exClass, ConnectException.class)) { + return ErrorCategory.SERVICE_UNAVAILABLE; + } + return ErrorCategory.UNEXPECTED; + } +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/web/ErrorControllerAdvice.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/web/ErrorControllerAdvice.java new file mode 100644 index 0000000..37d5634 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/config/core/web/ErrorControllerAdvice.java @@ -0,0 +1,145 @@ +package com.bcp.services.transaction.config.core.web; + +import com.bcp.services.transaction.config.core.utils.constants.ErrorCategory; +import com.bcp.services.transaction.config.core.utils.exception.ApiException; +import com.bcp.services.transaction.config.core.utils.utils.PropertyUtils; +import jakarta.validation.ConstraintViolationException; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import static com.bcp.services.transaction.config.core.utils.constants.ErrorCategory.INVALID_REQUEST; + +/** + * ErrorControllerAdvice. + * This class is used to handle exceptions. + */ +@RestControllerAdvice +public class ErrorControllerAdvice { + + @ExceptionHandler(ApiException.class) + protected Mono> handleCustomFrameworkException( + final ApiException exception, + final ServerWebExchange serverWebExchange) { + if (StringUtils.isBlank(exception.getCode()) + && ((!exception.getExceptionDetails().isEmpty() + && StringUtils.isBlank(exception.getExceptionDetails().get(0).getCode())) + || exception.getExceptionDetails().isEmpty())) { + var endpoint = serverWebExchange.getRequest().getMethod().name() + .concat(StringUtils.SPACE).concat(serverWebExchange.getRequest().getPath().value()); + + var apiException = ApiException + .builder() + .category(exception.getCategory()) + .systemCode(exception.getCategory().getCode()) + .description(exception.getCategory().getDescription()) + .errorType(exception.getCategory().getErrorType()) + .cause(exception) + .addDetail(true) + .withEndpoint(endpoint) + .withComponent(PropertyUtils.getApplicationCode()) + .push() + .build(); + return Mono.just(ResponseEntity.status(this.fromCategoryToHttpStatus(apiException.getCategory())) + .body(apiException)); + } + return Mono.just(ResponseEntity + .status(this.fromCategoryToHttpStatus(exception.getCategory())) + .body(exception.mutate().build())); + } + + private HttpStatus fromCategoryToHttpStatus(final ErrorCategory errorCategory) { + if (errorCategory == null) { + return HttpStatus.INTERNAL_SERVER_ERROR; + } else { + return HttpStatus.valueOf(errorCategory.getHttpStatus()); + } + } + + @ExceptionHandler(ConstraintViolationException.class) + protected Mono> constraintViolationExceptionHandler( + final ConstraintViolationException violationException, + final ServerWebExchange serverWebExchange) { + + var endpoint = serverWebExchange.getRequest().getMethod().name() + .concat(StringUtils.SPACE).concat(serverWebExchange.getRequest().getPath().value()); + + var finalBuilder = ApiException.builder() + .category(INVALID_REQUEST) + .systemCode(INVALID_REQUEST.getCode()) + .description(INVALID_REQUEST.getDescription()) + .errorType(INVALID_REQUEST.getErrorType()) + .setComponentName(PropertyUtils.getApplicationCode()) + .setEndpoint(endpoint); + violationException.getConstraintViolations() + .forEach(constraintViolation -> finalBuilder.addDetail() + .withDescription(constraintViolation.getMessage()).push()); + + var apiException = finalBuilder.build(); + return Mono.just(ResponseEntity + .status(this.getHttpStatusFromCategory(apiException.getCategory())) + .body(apiException)); + } + + @ExceptionHandler(WebExchangeBindException.class) + protected Mono> webExchangeBindExceptionHandler( + final WebExchangeBindException webExchangeBindException, + final ServerWebExchange serverWebExchange) { + + var endpoint = serverWebExchange.getRequest().getMethod().name() + .concat(StringUtils.SPACE).concat(serverWebExchange.getRequest().getPath().value()); + + var finalBuilder = ApiException.builder() + .setComponentName(PropertyUtils.getApplicationCode()) + .errorType(INVALID_REQUEST.getErrorType()) + .setEndpoint(endpoint) + .systemCode(INVALID_REQUEST.getCode()) + .description(INVALID_REQUEST.getDescription()) + .category(INVALID_REQUEST); + webExchangeBindException.getAllErrors() + .forEach(objectError -> finalBuilder.addDetail() + .withDescription(objectError.getDefaultMessage()).push()); + + var apiException = finalBuilder.build(); + return Mono.just(ResponseEntity + .status(this.getHttpStatusFromCategory(apiException.getCategory())) + .body(apiException)); + } + + private HttpStatus getHttpStatusFromCategory(final ErrorCategory errorCategory) { + if (errorCategory == null) { + return HttpStatus.INTERNAL_SERVER_ERROR; + } else { + return HttpStatus.valueOf(errorCategory.getHttpStatus()); + } + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleCustomException(final Exception exception, + final ServerWebExchange serverWebExchange) { + + var endpoint = serverWebExchange.getRequest().getMethod().name() + .concat(StringUtils.SPACE).concat(serverWebExchange.getRequest().getPath().value()); + + var errorCategory = ErrorCategoryFromThrowable.mapExceptionToCategory(exception); + + var apiException = ApiException.builder() + .setComponentName(PropertyUtils.getApplicationCode()) + .errorType(errorCategory.getErrorType()) + .setEndpoint(endpoint) + .systemCode(errorCategory.getCode()) + .description(errorCategory.getDescription()) + .category(errorCategory) + .cause(exception) + .build(); + + return ResponseEntity.status(this.fromCategoryToHttpStatus(apiException.getCategory())) + .body(apiException); + } + +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/expose/web/TransactionApiImpl.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/expose/web/TransactionApiImpl.java new file mode 100644 index 0000000..8750c25 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/expose/web/TransactionApiImpl.java @@ -0,0 +1,53 @@ +package com.bcp.services.transaction.expose.web; + +import com.bcp.services.transaction.business.TransactionService; +import com.bcp.services.transaction.trx.api.TransactionApiDelegate; +import com.bcp.services.transaction.trx.model.GetTransactionResponse; +import com.bcp.services.transaction.trx.model.TransactionRequest; +import com.bcp.services.transaction.trx.model.TransactionResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +/** + * TransactionApiImpl. + * This class implements the TransactionApiDelegate interface. + */ +@RequiredArgsConstructor +@Component +public class TransactionApiImpl implements TransactionApiDelegate { + + private final TransactionService transactionService; + + @Override + public Mono> createTransaction(final UUID requestID, + final java.time.LocalDateTime requestDate, + final String callerName, + final Mono transactionRequest, + final ServerWebExchange exchange) { + + return transactionService.createTrx(transactionRequest) + .map(response -> new ResponseEntity<>(response, org.springframework.http.HttpStatus.CREATED)); + + } + + @Override + public Mono> getTransaction(final UUID requestID, + final java.time.LocalDateTime requestDate, + final String callerName, + final UUID transactionExternalId, + final ServerWebExchange exchange) { + + return transactionService.getTrx(transactionExternalId) + .map(ResponseEntity::ok) + .defaultIfEmpty(ResponseEntity.noContent().build()); + } + +} + + + diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/AnalysisResult.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/AnalysisResult.java new file mode 100644 index 0000000..dc86c76 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/AnalysisResult.java @@ -0,0 +1,20 @@ +package com.bcp.services.transaction.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * AnalysisResult. + * This class is used to represent an AnalysisResult. + */ +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class AnalysisResult { + private String transactionId; + private Boolean isFraudulent; + private String reason; +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/RawTransaction.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/RawTransaction.java new file mode 100644 index 0000000..baa5985 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/RawTransaction.java @@ -0,0 +1,22 @@ +package com.bcp.services.transaction.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * RawTransaction. + * This class is used to represent a RawTransaction. + */ +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class RawTransaction { + private String id; + private BigDecimal amount; + private String currency; +} \ No newline at end of file diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/TransactionDto.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/TransactionDto.java new file mode 100644 index 0000000..edddbe3 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/TransactionDto.java @@ -0,0 +1,26 @@ +package com.bcp.services.transaction.model; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.AllArgsConstructor; + +import java.math.BigDecimal; +import java.util.UUID; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TransactionDto { + private UUID id; + private UUID accountExternalIdDebit; + + private UUID accountExternalIdCredit; + + private String transferTypeId; + + private BigDecimal value; +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/TransactionEntity.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/TransactionEntity.java new file mode 100644 index 0000000..0b17e90 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/model/TransactionEntity.java @@ -0,0 +1,44 @@ +package com.bcp.services.transaction.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Transaction Entity. + * This class is used to represent a Transaction. + */ +@Table("trx.transaction") +@Builder(toBuilder = true) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TransactionEntity { + + @Id + private String id; + @Column("accountExternalIdDebit") + private String accountExternalIdDebit; + @Column("accountExternalIdCredit") + private String accountExternalIdCredit; + @Column("transactionTypeCode") + private String transactionTypeCode; + @Column("amount") + private BigDecimal amount; + @Column("status") + private String status; + @Column("createdAt") + private LocalDateTime createdAt; + @Column("updatedAt") + private LocalDateTime updatedAt; + +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/repository/TransactionRepository.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/repository/TransactionRepository.java new file mode 100644 index 0000000..3eab004 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/repository/TransactionRepository.java @@ -0,0 +1,17 @@ +package com.bcp.services.transaction.repository; + +import com.bcp.services.transaction.model.TransactionEntity; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +/** + * Transaction Postgresql Repository. + * This interface is used to interact with the Postgresql database. + * It extends the ReactiveCrudRepository interface. + * + * @see ReactiveCrudRepository + * @see TransactionEntity + * @see TransactionRepository + */ +public interface TransactionRepository extends ReactiveCrudRepository { + +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/util/constants/Constants.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/util/constants/Constants.java new file mode 100644 index 0000000..eef9709 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/util/constants/Constants.java @@ -0,0 +1,20 @@ +package com.bcp.services.transaction.util.constants; + +/** + * Class containing constants. + * + * @author vito + */ + +public final class Constants { + private Constants() { + } + + /** + * The component name for the PostgresSQL database. + */ + public static final String BD_COMPONENT_NAME = "PostgresSQL"; + + public static final String DOT_AND_SPACE = ". "; + +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/util/exception/CustomApiException.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/util/exception/CustomApiException.java new file mode 100644 index 0000000..99dfad2 --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/util/exception/CustomApiException.java @@ -0,0 +1,117 @@ +package com.bcp.services.transaction.util.exception; + +import com.bcp.services.transaction.config.core.utils.constants.ErrorCategory; +import com.bcp.services.transaction.config.core.utils.exception.ApiException; +import com.bcp.services.transaction.config.core.utils.utils.PropertyUtils; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Optional; + +import static com.bcp.services.transaction.config.core.utils.constants.ErrorCategory.CONFLICT; +import static com.bcp.services.transaction.config.core.utils.constants.ErrorCategory.EXTERNAL_ERROR; +import static com.bcp.services.transaction.config.core.utils.constants.ErrorCategory.UNEXPECTED; +import static com.bcp.services.transaction.util.constants.Constants.BD_COMPONENT_NAME; +import static com.bcp.services.transaction.util.constants.Constants.DOT_AND_SPACE; +import static org.apache.logging.log4j.util.Strings.EMPTY; + + +/** + * Builder component that contains methods to build exception object. + * + * @author vito.ivan + */ +@SuppressFBWarnings(value = "NM_CLASS_NOT_EXCEPTION", justification = "This class is derived from another exception") +@Getter +@AllArgsConstructor +public enum CustomApiException { + + C5001(EXTERNAL_ERROR, + "C5001", BD_COMPONENT_NAME, + "Error de base de datos. "), + + C4091(CONFLICT, + "C4091", PropertyUtils.getApplicationCode(), + "No se encontró ningúna transacción para el ID proporcionado."), + + C5003(UNEXPECTED, + "C5003", PropertyUtils.getApplicationCode(), + "Error al convertir un objeto a json. ");; + + private final ErrorCategory category; + + private final String code; + private final String componentName; + private final String description; + + /** + * Return exception. + * + * @return ApiException + */ + public ApiException getException() { + + return ApiException + .builder() + .category(this.getCategory()) + .systemCode(this.getCategory().getCode()) + .description(this.getCategory().getDescription()) + .errorType(this.getCategory().getErrorType()) + .addDetail(true) + .withCode(this.getCode()) + .withComponent(this.getComponentName()) + .withDescription(this.getDescription()) + .push() + .build(); + } + + /** + * Build and return a custom exception with description. + * + * @param throwable throwable + * @return ApiException + */ + public ApiException getException(final Throwable throwable) { + + return ApiException + .builder() + .category(this.getCategory()) + .systemCode(this.getCategory().getCode()) + .description(this.getCategory().getDescription()) + .errorType(this.getCategory().getErrorType()) + .addDetail(true) + .withCode(this.getCode()) + .withComponent(this.getComponentName()) + .withDescription(this.getDescription().concat(throwable.getClass().getName()) + .concat(Optional.ofNullable(throwable.getMessage()) + .map(DOT_AND_SPACE::concat).orElse(EMPTY)) + .concat(Optional.ofNullable(throwable.getCause()) + .map(th -> DOT_AND_SPACE.concat(th.getMessage())).orElse(EMPTY))) + .push() + .build(); + } + + /** + * Build and return a custom exception with description. + * + * @param throwable throwable + * @return ApiException + */ + public ApiException getException(final String throwable) { + + return ApiException + .builder() + .category(this.getCategory()) + .systemCode(this.getCategory().getCode()) + .description(this.getCategory().getDescription()) + .errorType(this.getCategory().getErrorType()) + .addDetail(true) + .withCode(this.getCode()) + .withComponent(this.getComponentName()) + .withDescription(this.getDescription().concat(throwable)) + .push() + .build(); + } + +} diff --git a/business-transaction-v1/src/main/java/com/bcp/services/transaction/util/exception/ExceptionUtils.java b/business-transaction-v1/src/main/java/com/bcp/services/transaction/util/exception/ExceptionUtils.java new file mode 100644 index 0000000..007588f --- /dev/null +++ b/business-transaction-v1/src/main/java/com/bcp/services/transaction/util/exception/ExceptionUtils.java @@ -0,0 +1,99 @@ +package com.bcp.services.transaction.util.exception; + +import com.bcp.services.transaction.config.core.utils.exception.ApiException; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.BadSqlGrammarException; + +import java.util.Map; +import java.util.Optional; + +import static com.bcp.services.transaction.util.constants.Constants.DOT_AND_SPACE; +import static org.apache.logging.log4j.util.Strings.EMPTY; + +/** + * Utility class for exceptions. + */ +public final class ExceptionUtils { + private ExceptionUtils() { + } + + + /** + * Build an API exception from a PostgreSQL throwable. + * + * @param customApiException the custom API exception. + * @param ex the throwable. + * @return the API exception. + */ + public static ApiException buildApiExceptionFromPostgresqlThrowable( + final CustomApiException customApiException, + final Throwable ex) { + + DbExceptionHandler handler = HANDLERS.get(ex.getClass()); + + if (handler != null) { + return customApiException.getException(handler.description(ex)); + } + + Throwable cause = ex.getCause(); + + if (cause != null) { + handler = HANDLERS.get(cause.getClass()); + + if (handler != null) { + return customApiException.getException(handler.description(cause)); + } + } + + return customApiException.getException(ex); + } + + private static final Map, DbExceptionHandler> HANDLERS = + Map.of(DataAccessResourceFailureException.class, new DataAccessResourceFailureExceptionHandler(), + BadSqlGrammarException.class, new BadSqlGrammarExceptionHandler()); + + /** + * Interface that contains methods to build custom exception object. + * + * @author VI + */ + public interface DbExceptionHandler { + + String description(Throwable throwable); + + } + + private static class BadSqlGrammarExceptionHandler implements DbExceptionHandler { + + @Override + public String description(final Throwable ex) { + if (ex instanceof BadSqlGrammarException exception) { + + return exception.getClass().getName() + .concat(Optional.ofNullable(exception.getMessage()) + .map(DOT_AND_SPACE::concat).orElse(EMPTY)) + .concat(Optional.ofNullable(exception.getCause()) + .map(th -> DOT_AND_SPACE.concat(th.getMessage())).orElse(EMPTY)); + } + return EMPTY; + } + + } + + private static class DataAccessResourceFailureExceptionHandler implements DbExceptionHandler { + + @Override + public String description(final Throwable ex) { + if (ex instanceof DataAccessResourceFailureException exception) { + return exception.getClass().getName() + .concat(Optional.ofNullable(exception.getMessage()) + .map(DOT_AND_SPACE::concat).orElse(EMPTY)) + .concat(Optional.ofNullable(exception.getCause()) + .map(th -> DOT_AND_SPACE.concat(th.getMessage())).orElse(EMPTY)); + } + return EMPTY; + } + + } + +} \ No newline at end of file diff --git a/business-transaction-v1/src/main/resources/ValidationMessages.properties b/business-transaction-v1/src/main/resources/ValidationMessages.properties new file mode 100644 index 0000000..66386e7 --- /dev/null +++ b/business-transaction-v1/src/main/resources/ValidationMessages.properties @@ -0,0 +1,2 @@ +transferTypeId=El campo transferTypeId debe coincidir con los valores permitidos: 220, 225, 320 o 325. +transactionRequestMono.transferTypeId=El campo transferTypeId debe coincidir con los valores permitidos: 220, 225, 320 o 325. \ No newline at end of file diff --git a/business-transaction-v1/src/main/resources/application-local.yml b/business-transaction-v1/src/main/resources/application-local.yml new file mode 100644 index 0000000..40c6151 --- /dev/null +++ b/business-transaction-v1/src/main/resources/application-local.yml @@ -0,0 +1,20 @@ +application: + kafka: + producer: + topic: transaction-anti-fraud-validation + consumer: + group-id: reactive-group + topic: transaction-processing + trx-types: + 220: Transferencias ordinaria + 225: Pago a cta tarjeta + 320: ORDINARIAS - INMEDIATAS + 325: PAGO DE TARJETA DE CREDITO - INMEDIATAS + trx-statuses: + pending: PENDING + approved: APPROVED + rejected: REJECTED +logging: + level: + root: ERROR + com.bcp.services.transaction: TRACE \ No newline at end of file diff --git a/business-transaction-v1/src/main/resources/application.yml b/business-transaction-v1/src/main/resources/application.yml new file mode 100644 index 0000000..7b398b6 --- /dev/null +++ b/business-transaction-v1/src/main/resources/application.yml @@ -0,0 +1,50 @@ +springdoc: + swagger-ui: + path: /swagger-ui.html + api-docs: + path: /openapi + enabled: true + show-actuator: true +management: + endpoints: + web: + exposure: + include: health,info,openapi,swagger-ui,mappings +spring: + application: + name: business-transaction + webflux: + base-path: /transaction/v1 + kafka: + bootstrap-servers: localhost:9092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + consumer: + group-id: reactive-group + auto-offset-reset: earliest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + r2dbc: + url: r2dbc:postgresql://localhost:5432/mypostgresdb + username: postgresuser + password: postgrespassword + sql: + init: + mode: always + schema-locations: classpath:/data.sql + continue-on-error: true + jpa: + defer-datasource-initialization: true + main: + allow-bean-definition-overriding: true + messages: + basename: ValidationMessages + profiles: + active: local +openapi: + aPIBusinessTransactionV1: + base-path: '' +server: + port: 8085 \ No newline at end of file diff --git a/business-transaction-v1/src/main/resources/data.sql b/business-transaction-v1/src/main/resources/data.sql new file mode 100644 index 0000000..e39e676 --- /dev/null +++ b/business-transaction-v1/src/main/resources/data.sql @@ -0,0 +1,18 @@ +CREATE SCHEMA IF NOT EXISTS trx; + + +DROP TABLE IF EXISTS trx.transaction; + + +CREATE TABLE trx.transaction(id varchar(36) PRIMARY KEY, + accountExternalIdDebit varchar(40), + accountExternalIdCredit varchar(40), + transactionTypeCode varchar(20), + amount numeric, + status varchar(10), + createdAt timestamp, + updatedAt timestamp); + +GRANT ALL PRIVILEGES ON SCHEMA trx TO postgresuser; + +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA trx TO postgresuser; \ No newline at end of file diff --git a/business-transaction-v1/src/main/resources/openapi.yaml b/business-transaction-v1/src/main/resources/openapi.yaml new file mode 100644 index 0000000..ef4236c --- /dev/null +++ b/business-transaction-v1/src/main/resources/openapi.yaml @@ -0,0 +1,277 @@ +openapi: 3.0.3 +info: + title: API Business Transaction V1 + description: API que permite crear y recuperar transacciones financieras. + version: 1.0.0 + +servers: + - url: http://localhost:8085 + description: Servidor local + +tags: + - name: Transaction + description: Operaciones relacionadas con transacciones financieras + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + Request-ID: + name: Request-ID + in: header + description: ID único para identificar la solicitud. + required: true + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + + request-date: + name: request-date + in: header + description: Fecha y hora de la solicitud. + required: true + schema: + type: string + format: date-time + example: '2020-10-09T14:02:03.987-0700' + + caller-name: + name: caller-name + in: header + description: Nombre de la API que realiza la invocación al servicio. + required: true + schema: + type: string + pattern: '^[0-9a-zA-Z.-]*$' + maxLength: 100 + minLength: 5 + example: business-product-directory-v1 + + schemas: + TransactionRequest: + type: object + properties: + accountExternalIdDebit: + type: string + format: uuid + description: ID externo de la cuenta desde la cual se debitarán los fondos. + accountExternalIdCredit: + type: string + format: uuid + description: ID externo de la cuenta a la cual se acreditarán los fondos. + transactionTypeCode: + type: string + pattern: ^(220|225|320|325)$ + description: Codigo del tipo de transferencia (220, 225, 320, 325). + amount: + type: string + format: decimal + description: Valor monetario de la transacción. + required: + - accountExternalIdDebit + - accountExternalIdCredit + - transactionTypeCode + - amount + + TransactionResponse: + type: object + properties: + transactionExternalId: + type: string + format: uuid + description: ID externo asignado a la transacción. + + GetTransactionResponse: + type: object + properties: + transactionExternalId: + type: string + format: uuid + description: ID externo asignado a la transacción. + transactionType: + $ref: '#/components/schemas/TransactionTypeResponse' + transactionStatus: + $ref: '#/components/schemas/TransactionStatusResponse' + amount: + type: string + format: decimal + description: Valor monetario de la transacción. + createdAt: + type: string + format: date-time + description: Fecha y hora en que se creó la transacción. + updatedAt: + type: string + format: date-time + description: Fecha y hora en que se actualizó la transacción. + + TransactionTypeResponse: + type: object + properties: + code: + type: string + example: 220 + description: Codigo del tipo de transferencia. + name: + type: string + example: Transferencia ordinaria + description: Nombre del tipo de transacción. + TransactionStatusResponse: + type: object + properties: + name: + type: string + example: pending + description: Estado de la transacción. + + + ApiException: + type: object + required: + - code + - description + - errorType + properties: + code: + type: string + description: Código de error del sistema. + example: TL0001 + description: + type: string + description: Descripción del error del sistema. + example: Error al llamar al servicio + errorType: + type: string + description: Tipo de error (ej. TECHNICAL, BUSINESS). + example: TECHNICAL + exceptionDetails: + type: array + description: Lista de detalles del error. + items: + $ref: '#/components/schemas/ApiExceptionDetail' + properties: + type: object + description: Lista de propiedades adicionales específicas del error. + additionalProperties: true + + ApiExceptionDetail: + type: object + properties: + code: + type: string + description: Código de error específico del detalle/proveedor. + example: MB0008 + component: + type: string + description: Nombre del componente donde ocurrió el error. + example: MB.CardInqV2 + description: + type: string + description: Descripción detallada del error. + example: Código inválido para el canal + +paths: + /transactions: + post: + tags: + - Transaction + summary: Crea una nueva transacción + operationId: createTransaction + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Request-ID' + - $ref: '#/components/parameters/request-date' + - $ref: '#/components/parameters/caller-name' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TransactionRequest' + responses: + '201': + description: Transacción creada exitosamente. + content: + application/json: + schema: + $ref: '#/components/schemas/TransactionResponse' + '400': + description: Solicitud inválida. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiException' + '401': + description: No autorizado. + '403': + description: Prohibido. + '404': + description: No encontrado. + '429': + description: Demasiadas solicitudes. + '500': + description: Error interno del servidor. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiException' + '503': + description: Servicio no disponible. + + /transactions/{transactionExternalId}: + get: + tags: + - Transaction + summary: Recupera una transacción por su ID externo + operationId: getTransaction + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Request-ID' + - $ref: '#/components/parameters/request-date' + - $ref: '#/components/parameters/caller-name' + - name: transactionExternalId + in: path + required: true + schema: + type: string + format: uuid + description: ID externo de la transacción. + responses: + '200': + description: Transacción recuperada exitosamente. + content: + application/json: + schema: + $ref: '#/components/schemas/GetTransactionResponse' + '400': + description: Solicitud inválida. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiException' + '401': + description: No autorizado. + '403': + description: Prohibido. + '404': + description: No encontrado. + '429': + description: Demasiadas solicitudes. + '500': + description: Error interno del servidor. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiException' + '503': + description: Servicio no disponible. + +security: + - bearerAuth: [] diff --git a/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/ReactiveKafkaConsumerTest.java b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/ReactiveKafkaConsumerTest.java new file mode 100644 index 0000000..d7462b0 --- /dev/null +++ b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/ReactiveKafkaConsumerTest.java @@ -0,0 +1,127 @@ +package com.bcp.services.transaction.business; + +import com.bcp.services.transaction.model.AnalysisResult; +import com.bcp.services.transaction.trx.model.TransactionResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.kafka.receiver.KafkaReceiver; +import reactor.kafka.receiver.ReceiverOffset; +import reactor.kafka.receiver.ReceiverRecord; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReactiveKafkaConsumerTest { + + @Mock + private KafkaReceiver kafkaReceiver; + + @Mock + private TransactionService transactionService; + + @InjectMocks + private ReactiveKafkaConsumer reactiveKafkaConsumer; + + @Mock + private ObjectMapper objectMapper; + + @Captor + private ArgumentCaptor> classCaptor; + + @Test + @DisplayName("Initialize successfully") + void initializeSuccessfully() throws JsonProcessingException { + ConsumerRecord consumerRecord = new ConsumerRecord<>( + "test-topic", + 0, + 0L, + "9295f66a-6db4-4a39-9af2-2c524f49fb39", + "{\"transactionId\":\"4e375479-048e-43d5-ae02-6612a03e9037\",\"isFraudulent\":false}"); + + ReceiverRecord mockRecord = getStringStringReceiverRecord(consumerRecord); + doReturn(Flux.just(mockRecord)).when(kafkaReceiver).receive(); + + when(objectMapper.readValue(anyString(), classCaptor.capture())) + .thenReturn(AnalysisResult.builder() + .transactionId("4e375479-048e-43d5-ae02-6612a03e9037") + .isFraudulent(false) + .build()); + + var transactionResponse = new TransactionResponse() + .transactionExternalId(UUID.fromString("9295f66a-6db4-4a39-9af2-2c524f49fb39")); + doReturn(Mono.just(transactionResponse)).when(transactionService).updateTrx(any()); + + reactiveKafkaConsumer.initialize(); + + verify(transactionService, times(1)).updateTrx(any()); + } + + @Test + @DisplayName("Fail to initialize due to Kafka error") + void failToInitializeDueToKafkaError() throws JsonProcessingException { + ConsumerRecord consumerRecord = new ConsumerRecord<>( + "test-topic", + 0, + 0L, + "9295f66a-6db4-4a39-9af2-2c524f49fb39", + "{\"transactionId0\":\"4e375479-048e-43d5-ae02-6612a03e9037\",\"isFraudulent0\":false}"); + + ReceiverRecord mockRecord = getStringStringReceiverRecord(consumerRecord); + doReturn(Flux.just(mockRecord)).when(kafkaReceiver).receive(); + + when(objectMapper.readValue(anyString(), classCaptor.capture())) + .thenThrow(new RuntimeException("Error parsing JSON")); + + reactiveKafkaConsumer.initialize(); + + verify(transactionService, never()).updateTrx(any()); + } + + private static ReceiverRecord getStringStringReceiverRecord( + ConsumerRecord consumerRecord) { + ReceiverOffset receiverOffset = new ReceiverOffset() { + + @Override + public TopicPartition topicPartition() { + return new TopicPartition("test-topic", 0); + } + + @Override + public long offset() { + return 0; + } + + @Override + public void acknowledge() { + // Acknowledge logic + } + + @Override + public Mono commit() { + return Mono.empty(); + } + }; + + return new ReceiverRecord<>(consumerRecord, receiverOffset); + } +} \ No newline at end of file diff --git a/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/ReactiveKafkaProducerTest.java b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/ReactiveKafkaProducerTest.java new file mode 100644 index 0000000..06a70bb --- /dev/null +++ b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/ReactiveKafkaProducerTest.java @@ -0,0 +1,56 @@ +package com.bcp.services.transaction.business; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.annotation.DirtiesContext; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import reactor.kafka.sender.KafkaSender; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; + +@ExtendWith(MockitoExtension.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +class ReactiveKafkaProducerTest { + + @Mock + private KafkaSender kafkaSender; + + @InjectMocks + private ReactiveKafkaProducer reactiveKafkaProducer; + + @Test + @DisplayName("Send message successfully") + void sendMessageSuccessfully() { + // Arrange + doReturn(Flux.empty()).when(kafkaSender).send(any(Mono.class)); + + // Act + Mono result = reactiveKafkaProducer.sendMessage("test-topic", "test-key", "test-value"); + + // Assert + StepVerifier.create(result) + .expectComplete() + .verify(); + } + + @Test + @DisplayName("Fail to send message") + void failToSendMessage() { + doReturn(Flux.error(new RuntimeException("Kafka send error"))).when(kafkaSender).send(any(Mono.class)); + + Mono result = reactiveKafkaProducer.sendMessage("test-topic", "test-key", "test-value"); + + StepVerifier.create(result) + .expectComplete() + .verify(); + } +} \ No newline at end of file diff --git a/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/impl/GetTransactionServiceImplTest.java b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/impl/GetTransactionServiceImplTest.java new file mode 100644 index 0000000..bef7928 --- /dev/null +++ b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/impl/GetTransactionServiceImplTest.java @@ -0,0 +1,116 @@ +package com.bcp.services.transaction.business.impl; + +import com.bcp.services.transaction.config.ApplicationProperties; +import com.bcp.services.transaction.config.core.utils.utils.PropertyUtils; +import com.bcp.services.transaction.model.TransactionEntity; +import com.bcp.services.transaction.repository.TransactionRepository; +import com.bcp.services.transaction.trx.model.GetTransactionResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; +import org.springframework.jdbc.BadSqlGrammarException; +import org.springframework.test.annotation.DirtiesContext; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.sql.SQLDataException; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +class GetTransactionServiceImplTest { + + @Mock + private Environment environment; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private ApplicationProperties applicationProperties; + + @InjectMocks + private TransactionServiceImpl transactionService; + + private UUID transactionExternalId; + private TransactionEntity transactionEntity; + + @BeforeEach + void setUp() { + transactionExternalId = UUID.randomUUID(); + transactionEntity = TransactionEntity.builder() + .id(transactionExternalId.toString()) + .status("APPROVED") + .transactionTypeCode("220") + .amount(new BigDecimal("100.00")) + .createdAt(LocalDateTime.parse("2021-08-01T00:00:00")) + .updatedAt(LocalDateTime.parse("2021-08-01T00:00:00")) + .build(); + } + + @Test + @DisplayName("Return transaction when transaction is found") + void returnTransactionWhenTransactionIsFound() { + + when(applicationProperties.getTrxTypes()).thenReturn(Map.of( + "220", "Transferencias ordinaria", + "225", "Pago a cta tarjeta", + "320", "ORDINARIAS - INMEDIATAS", + "325", "PAGO DE TARJETA DE CREDITO - INMEDIATAS")); + when(transactionRepository.findById(anyString())).thenReturn(Mono.just(transactionEntity)); + + Mono result = transactionService.getTrx(transactionExternalId); + + StepVerifier.create(result) + .expectNextMatches(response -> response.getTransactionExternalId().equals(transactionExternalId)) + .verifyComplete(); + } + + @Test + @DisplayName("Throw ApiException when error occurs while obtaining transaction") + void throwApiExceptionWhenErrorOccursWhileObtainingTransaction() { + + PropertyUtils.setResolver(environment); + + given(environment.getProperty("spring.application.name")).willReturn("business-transaction"); + given(environment.getProperty("application.api.error-code.external-error.code")).willReturn("T0099"); + given(environment.getProperty("application.api.error-code.external-error.description")).willReturn( + "Internal server error"); + given(environment.getProperty("application.api.error-code.external-error.error-type")).willReturn("Technical"); + + var exception = new BadSqlGrammarException("Database error", "SQL", new SQLDataException("Database error")); + when(transactionRepository.findById(anyString())).thenReturn(Mono.error(exception)); + + Mono result = transactionService.getTrx(transactionExternalId); + + StepVerifier.create(result) + .expectErrorMatches(throwable -> throwable.getMessage().contains("Internal server error")) + .verify(); + } + + @Test + @DisplayName("Return empty when transaction is not found") + void returnEmptyWhenTransactionIsNotFound() { + when(transactionRepository.findById(anyString())).thenReturn(Mono.empty()); + + Mono result = transactionService.getTrx(transactionExternalId); + + StepVerifier.create(result) + .expectNextCount(0) + .verifyComplete(); + } +} diff --git a/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/impl/SaveTransactionServiceImplTest.java b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/impl/SaveTransactionServiceImplTest.java new file mode 100644 index 0000000..5d8b5cc --- /dev/null +++ b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/impl/SaveTransactionServiceImplTest.java @@ -0,0 +1,238 @@ +package com.bcp.services.transaction.business.impl; + +import com.bcp.services.transaction.business.ReactiveKafkaProducer; +import com.bcp.services.transaction.config.ApplicationProperties; +import com.bcp.services.transaction.config.core.utils.exception.ApiException; +import com.bcp.services.transaction.config.core.utils.utils.PropertyUtils; +import com.bcp.services.transaction.model.TransactionEntity; +import com.bcp.services.transaction.trx.model.TransactionRequest; +import com.bcp.services.transaction.trx.model.TransactionResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; +import org.springframework.core.env.Environment; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.r2dbc.core.ReactiveInsertOperation; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SaveTransactionServiceImplTest { + + public static final ApplicationProperties.TrxStatuses TRX_STATUSES = ApplicationProperties.TrxStatuses.builder() + .pending("PENDING") + .approved("APPROVED") + .rejected("REJECTED") + .build(); + @Mock + private Environment environment; + + @Mock + private R2dbcEntityTemplate entityTemplate; + + @Mock + private ReactiveKafkaProducer reactiveKafkaProducer; + + @Mock + private ApplicationProperties applicationProperties; + + @InjectMocks + private TransactionServiceImpl transactionService; + + private TransactionRequest transactionRequest; + private TransactionEntity transactionEntity; + + @Mock + private ReactiveInsertOperation.ReactiveInsert insertMock; + + @Captor + private ArgumentCaptor topicCaptor; + + @Captor + private ArgumentCaptor keyCaptor; + + @Captor + private ArgumentCaptor messageCaptor; + + @Mock + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + + transactionRequest = new TransactionRequest() + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .transactionTypeCode("220") + .amount(new BigDecimal("100.00")); + + transactionEntity = TransactionEntity.builder() + .id(UUID.randomUUID().toString()) + .accountExternalIdDebit(transactionRequest.getAccountExternalIdDebit().toString()) + .accountExternalIdCredit(transactionRequest.getAccountExternalIdCredit().toString()) + .transactionTypeCode(transactionRequest.getTransactionTypeCode()) + .amount(transactionRequest.getAmount()) + .status("PENDING") + .createdAt(LocalDateTime.now()) + .build(); + + ReflectionTestUtils.setField(transactionService, "topic", "transaction-anti-fraud-validation"); + + when(applicationProperties.getTrxStatuses()).thenReturn(TRX_STATUSES); + + + } + + @Test + @DisplayName("Return transaction when it is saved and sent for anti-fraud validation") + void returnTransactionWhenItIsSavedAndSentForAntiFraudValidation() throws JSONException, JsonProcessingException { + + when(entityTemplate.insert(TransactionEntity.class)).thenReturn(insertMock); + when(insertMock.using(any(TransactionEntity.class))).thenReturn(Mono.just(transactionEntity)); + when(objectMapper.writeValueAsString(any())).thenReturn("{\"id\":null,\"amount\":100.00,\"currency\":null}"); + when(reactiveKafkaProducer.sendMessage(any(), any(), any())).thenReturn(Mono.empty()); + + Mono result = transactionService.createTrx(Mono.just(transactionRequest)); + + StepVerifier.create(result) + .expectNextMatches(response -> response.getTransactionExternalId() + .equals(UUID.fromString(transactionEntity.getId()))) + .verifyComplete(); + + verify(reactiveKafkaProducer).sendMessage(topicCaptor.capture(), keyCaptor.capture(), messageCaptor.capture()); + + assertEquals("transaction-anti-fraud-validation", topicCaptor.getValue()); + assertEquals(36, keyCaptor.getValue().length()); + + JSONObject expectedJson = new JSONObject("{\"id\":null,\"amount\":100.00,\"currency\":null}"); + JSONObject actualJson = new JSONObject(messageCaptor.getValue()); + + expectedJson.remove("id"); + actualJson.remove("id"); + + JSONAssert.assertEquals(expectedJson, actualJson, JSONCompareMode.LENIENT); + + verify(reactiveKafkaProducer, times(1)).sendMessage(any(), any(), any()); + } + + @Test + @DisplayName("Return transaction when it is saved and an error occurs while sending it for anti-fraud validation") + void returnTransactionWhenItIsSavedAndAnErrorOccursWhileSendingItForAntiFraudValidation() throws JsonProcessingException { + + when(entityTemplate.insert(TransactionEntity.class)).thenReturn(insertMock); + when(insertMock.using(any(TransactionEntity.class))).thenReturn(Mono.just(transactionEntity)); + when(objectMapper.writeValueAsString(any())).thenReturn("{\"id\":null,\"amount\":100.00,\"currency\":null}"); + doReturn(Mono.error(new RuntimeException("Kafka error"))) + .when(reactiveKafkaProducer).sendMessage(any(), any(), any()); + + Mono result = transactionService.createTrx(Mono.just(transactionRequest)); + + StepVerifier.create(result) + .expectNextMatches(response -> response.getTransactionExternalId() + .equals(UUID.fromString(transactionEntity.getId()))) + .verifyComplete(); + + verify(reactiveKafkaProducer).sendMessage(topicCaptor.capture(), keyCaptor.capture(), messageCaptor.capture()); + + assertEquals("transaction-anti-fraud-validation", topicCaptor.getValue()); + assertEquals(36, keyCaptor.getValue().length()); + + verify(reactiveKafkaProducer, times(1)).sendMessage(any(), any(), any()); + + } + + @Test + @DisplayName("Return transaction when it is saved and a JsonProcessingException occurs") + void returnTransactionWhenItIsSavedAndAJsonProcessingExceptionOccurs() throws JsonProcessingException { + + PropertyUtils.setResolver(environment); + + //given(environment.getProperty("spring.application.name")).willReturn("business-transaction"); + given(environment.getProperty("application.api.error-code.unexpected.code")).willReturn("T0099"); + given(environment.getProperty("application.api.error-code.unexpected.description")).willReturn( + "Internal server error"); + given(environment.getProperty("application.api.error-code.unexpected.error-type")).willReturn("Technical"); + + when(applicationProperties.getTrxStatuses()).thenReturn(ApplicationProperties.TrxStatuses.builder() + .pending("PENDING") + .approved("APPROVED") + .rejected("REJECTED") + .build()); + + when(entityTemplate.insert(TransactionEntity.class)).thenReturn(insertMock); + when(insertMock.using(any(TransactionEntity.class))).thenReturn(Mono.just(transactionEntity)); + when(objectMapper.writeValueAsString(any())).thenThrow(new JsonProcessingException("Error processing JSON") { + }); + + Mono result = transactionService.createTrx(Mono.just(transactionRequest)); + + StepVerifier.create(result) + .expectNextMatches(response -> response.getTransactionExternalId() + .equals(UUID.fromString(transactionEntity.getId()))) + .verifyComplete(); + + verify(entityTemplate, times(1)).insert(TransactionEntity.class); + verify(insertMock, times(1)).using(any(TransactionEntity.class)); + verify(objectMapper, times(1)).writeValueAsString(any()); + verify(reactiveKafkaProducer, times(0)).sendMessage(any(), any(), any()); + } + + @Test + @DisplayName("Throw ApiException when error occurs while saving transaction") + void throwApiExceptionWhenErrorOccursWhileSavingTransaction() throws JsonProcessingException { + + PropertyUtils.setResolver(environment); + + //given(environment.getProperty("spring.application.name")).willReturn("business-transaction"); + given(environment.getProperty("application.api.error-code.external-error.code")).willReturn("T0099"); + given(environment.getProperty("application.api.error-code.external-error.description")).willReturn( + "Internal server error"); + given(environment.getProperty("application.api.error-code.external-error.error-type")).willReturn("Technical"); + + when(entityTemplate.insert(TransactionEntity.class)).thenReturn(insertMock); + when(insertMock.using(any(TransactionEntity.class))) + .thenReturn(Mono.error(new RuntimeException("Database error"))); + + Mono result = transactionService.createTrx(Mono.just(transactionRequest)); + + StepVerifier.create(result) + .expectErrorMatches(throwable -> { + if (throwable instanceof ApiException apiException) { + return apiException.getExceptionDetails().get(0).getDescription().contains("Database error"); + } + return false; + }) + .verify(); + + verify(entityTemplate, times(1)).insert(TransactionEntity.class); + verify(insertMock, times(1)).using(any(TransactionEntity.class)); + verify(objectMapper, times(0)).writeValueAsString(any()); + verify(reactiveKafkaProducer, times(0)).sendMessage(any(), any(), any()); + } + +} diff --git a/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/impl/UpdateTransactionServiceImplTest.java b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/impl/UpdateTransactionServiceImplTest.java new file mode 100644 index 0000000..e360f34 --- /dev/null +++ b/business-transaction-v1/src/test/java/com/bcp/services/transaction/business/impl/UpdateTransactionServiceImplTest.java @@ -0,0 +1,192 @@ +package com.bcp.services.transaction.business.impl; + +import com.bcp.services.transaction.config.ApplicationProperties; +import com.bcp.services.transaction.config.core.utils.exception.ApiException; +import com.bcp.services.transaction.config.core.utils.utils.PropertyUtils; +import com.bcp.services.transaction.model.AnalysisResult; +import com.bcp.services.transaction.model.TransactionEntity; +import com.bcp.services.transaction.repository.TransactionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UpdateTransactionServiceImplTest { + + public static final ApplicationProperties.TrxStatuses TRX_STATUSES = ApplicationProperties.TrxStatuses.builder() + .pending("PENDING") + .approved("APPROVED") + .rejected("REJECTED") + .build(); + + @Mock + private Environment environment; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private ApplicationProperties applicationProperties; + + @InjectMocks + private TransactionServiceImpl transactionService; + + private AnalysisResult analysisResult; + private TransactionEntity transactionEntity; + + @BeforeEach + void setUp() { + analysisResult = AnalysisResult.builder() + .transactionId(UUID.randomUUID().toString()) + .isFraudulent(false) + .build(); + + transactionEntity = TransactionEntity.builder() + .id(analysisResult.getTransactionId()) + .accountExternalIdDebit(UUID.randomUUID().toString()) + .accountExternalIdCredit(UUID.randomUUID().toString()) + .transactionTypeCode("220") + .amount(new BigDecimal("100.00")) + .status("PENDING") + .createdAt(LocalDateTime.now()) + .build(); + } + + @Nested + class SuccessScenarios { + + @Test + @DisplayName("Successfully update transaction when it is not fraudulent, found, and updated") + void successfullyUpdateTransactionWhenItIsNotFraudulentFoundAndUpdated() { + when(transactionRepository.findById(anyString())).thenReturn(Mono.just(transactionEntity)); + when(applicationProperties.getTrxStatuses()).thenReturn(TRX_STATUSES); + when(transactionRepository.save(any(TransactionEntity.class))).thenReturn(Mono.just(transactionEntity)); + + Mono result = transactionService.updateTrx(analysisResult); + + StepVerifier.create(result) + .verifyComplete(); + + verify(transactionRepository, times(1)).findById(anyString()); + verify(transactionRepository, times(1)).save(any(TransactionEntity.class)); + } + + @Test + @DisplayName("Successfully update transaction when it is fraudulent, found, and updated") + void successfullyUpdateTransactionWhenItIsFraudulentFoundAndUpdated() { + analysisResult = AnalysisResult.builder() + .transactionId(UUID.randomUUID().toString()) + .isFraudulent(true) + .build(); + + when(transactionRepository.findById(anyString())).thenReturn(Mono.just(transactionEntity)); + when(applicationProperties.getTrxStatuses()).thenReturn(TRX_STATUSES); + when(transactionRepository.save(any(TransactionEntity.class))).thenReturn(Mono.just(transactionEntity)); + + Mono result = transactionService.updateTrx(analysisResult); + + StepVerifier.create(result) + .verifyComplete(); + + verify(transactionRepository, times(1)).findById(anyString()); + verify(transactionRepository, times(1)).save(any(TransactionEntity.class)); + } + } + + @Nested + class FailureScenarios { + + @Test + @DisplayName("Throw ApiException when transaction not found") + void throwApiExceptionWhenTransactionNotFound() { + + PropertyUtils.setResolver(environment); + + //doReturn("business-transaction").when(environment).getProperty("spring.application.name"); + doReturn("T0099").when(environment).getProperty("application.api.error-code.conflict.code"); + doReturn("conflict").when(environment).getProperty("application.api.error-code.conflict.description"); + doReturn("Functional").when(environment).getProperty("application.api.error-code.conflict.error-type"); + + when(transactionRepository.findById(anyString())).thenReturn(Mono.empty()); + + Mono result = transactionService.updateTrx(analysisResult); + + StepVerifier.create(result) + .expectErrorMatches(throwable -> throwable instanceof ApiException) + .verify(); + + verify(transactionRepository, times(1)).findById(anyString()); + verify(transactionRepository, times(0)).save(any(TransactionEntity.class)); + } + + @Test + @DisplayName("Throw ApiException when error occurs while finding transaction") + void throwApiExceptionWhenErrorOccursWhileFindingTransaction() { + + PropertyUtils.setResolver(environment); + + //doReturn("business-transaction").when(environment).getProperty("spring.application.name"); + doReturn("T0099").when(environment).getProperty("application.api.error-code.external-error.code"); + doReturn("Internal server error").when(environment).getProperty("application.api.error-code.external-error.description"); + doReturn("Technical").when(environment).getProperty("application.api.error-code.external-error.error-type"); + + when(transactionRepository.findById(anyString())) + .thenReturn(Mono.error(new RuntimeException("Database error"))); + + Mono result = transactionService.updateTrx(analysisResult); + + StepVerifier.create(result) + .expectErrorMatches(throwable -> throwable instanceof ApiException) + .verify(); + + verify(transactionRepository, times(1)).findById(anyString()); + verify(transactionRepository, times(0)).save(any(TransactionEntity.class)); + } + + @Test + @DisplayName("Throw ApiException when error occurs while updating transaction") + void throwApiExceptionWhenErrorOccursWhileUpdatingTransaction() { + + PropertyUtils.setResolver(environment); + + //doReturn("business-transaction").when(environment).getProperty("spring.application.name"); + doReturn("T0099").when(environment).getProperty("application.api.error-code.external-error.code"); + doReturn("Internal server error").when(environment).getProperty("application.api.error-code.external-error.description"); + doReturn("Technical").when(environment).getProperty("application.api.error-code.external-error.error-type"); + + when(transactionRepository.findById(anyString())).thenReturn(Mono.just(transactionEntity)); + when(applicationProperties.getTrxStatuses()).thenReturn(TRX_STATUSES); + when(transactionRepository.save(any(TransactionEntity.class))) + .thenReturn(Mono.error(new RuntimeException("Database error"))); + + Mono result = transactionService.updateTrx(analysisResult); + + StepVerifier.create(result) + .expectErrorMatches(throwable -> throwable instanceof ApiException) + .verify(); + + verify(transactionRepository, times(1)).findById(anyString()); + verify(transactionRepository, times(1)).save(any(TransactionEntity.class)); + } + + } +} \ No newline at end of file diff --git a/business-transaction-v1/src/test/java/com/bcp/services/transaction/expose/web/TransactionApiImplTest.java b/business-transaction-v1/src/test/java/com/bcp/services/transaction/expose/web/TransactionApiImplTest.java new file mode 100644 index 0000000..38c7605 --- /dev/null +++ b/business-transaction-v1/src/test/java/com/bcp/services/transaction/expose/web/TransactionApiImplTest.java @@ -0,0 +1,98 @@ +package com.bcp.services.transaction.expose.web; + +import com.bcp.services.transaction.business.TransactionService; +import com.bcp.services.transaction.trx.model.GetTransactionResponse; +import com.bcp.services.transaction.trx.model.TransactionRequest; +import com.bcp.services.transaction.trx.model.TransactionResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionApiImplTest { + + public static final UUID REQUEST_ID = UUID.randomUUID(); + public static final LocalDateTime REQUEST_DATE = LocalDateTime.now(); + public static final String CALLER_NAME = "testCaller"; + @Mock + private TransactionService transactionService; + + @InjectMocks + private TransactionApiImpl transactionApiImpl; + + @Test + @DisplayName("Return http status 201 when transaction is saved") + void returnHttpStatus201WhenTransactionIsSaved() { + Mono transactionRequest = Mono.just(new TransactionRequest()); + TransactionResponse transactionResponse = new TransactionResponse(); + + when(transactionService.createTrx(any(Mono.class))).thenReturn(Mono.just(transactionResponse)); + + Mono> result = transactionApiImpl + .createTransaction(REQUEST_ID, REQUEST_DATE, CALLER_NAME, transactionRequest, null); + + StepVerifier.create(result) + .assertNext(responseEntity -> { + Assertions.assertNotNull(responseEntity); + Assertions.assertEquals(HttpStatus.CREATED, responseEntity.getStatusCode()); + Assertions.assertEquals(transactionResponse, responseEntity.getBody()); + }) + .expectComplete() + .verify(); + } + + @Test + @DisplayName("Return http status 200 when transaction is found") + void returnHttpStatus200WhenTransactionIsFound() { + UUID transactionExternalId = UUID.randomUUID(); + GetTransactionResponse getTransactionResponse = new GetTransactionResponse(); + + when(transactionService.getTrx(any(UUID.class))).thenReturn(Mono.just(getTransactionResponse)); + + Mono> result = transactionApiImpl + .getTransaction(REQUEST_ID, REQUEST_DATE, CALLER_NAME, transactionExternalId, null); + + StepVerifier.create(result) + .assertNext(responseEntity -> { + Assertions.assertNotNull(responseEntity); + Assertions.assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + Assertions.assertEquals(getTransactionResponse, responseEntity.getBody()); + }) + .expectComplete() + .verify(); + } + + @Test + @DisplayName("Return http status 204 when transaction is not found") + void returnHttpStatus204WhenTransactionIsNotFound() { + UUID transactionExternalId = UUID.randomUUID(); + + when(transactionService.getTrx(any(UUID.class))).thenReturn(Mono.empty()); + + Mono> result = transactionApiImpl + .getTransaction(REQUEST_ID, REQUEST_DATE, CALLER_NAME, transactionExternalId, null); + + StepVerifier.create(result) + .assertNext(responseEntity -> { + Assertions.assertNotNull(responseEntity); + Assertions.assertEquals(HttpStatus.NO_CONTENT, responseEntity.getStatusCode()); + }) + .expectComplete() + .verify(); + } + +} \ No newline at end of file