This commit is contained in:
Yolando
2026-02-28 05:11:25 +07:00
commit 84c508d378
18 changed files with 1065 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package com.ando.dynamic_soa_mapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DynamicSoaMapperApplication {
public static void main(String[] args) {
SpringApplication.run(DynamicSoaMapperApplication.class, args);
}
}

View File

@@ -0,0 +1,116 @@
package com.ando.dynamic_soa_mapper.config;
import com.ando.dynamic_soa_mapper.entity.Payment;
import com.ando.dynamic_soa_mapper.entity.SoaMappingConfig;
import com.ando.dynamic_soa_mapper.entity.Transaction;
import com.ando.dynamic_soa_mapper.repository.PaymentRepository;
import com.ando.dynamic_soa_mapper.repository.SoaMappingConfigRepository;
import com.ando.dynamic_soa_mapper.repository.TransactionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
@Slf4j
@Component
@RequiredArgsConstructor
public class DataSeeder implements CommandLineRunner {
private final PaymentRepository paymentRepo;
private final TransactionRepository transactionRepo;
private final SoaMappingConfigRepository configRepo;
@Override
@Transactional
public void run(String... args) {
if (paymentRepo.count() == 0) {
seedData();
}
}
private void seedData() {
// 1. Setup Data Utama
Payment payment = new Payment();
payment.setId("PAY-999");
payment.setDebitAccountNo("1234567890");
payment.setCurrency("USD"); // Kita set USD untuk test logic
payment.setCreatedDate(new Date());
paymentRepo.save(payment);
Transaction trx = new Transaction();
trx.setId("TRX-555");
trx.setPaymentId("PAY-999");
trx.setAmount(new BigDecimal("50.00"));
trx.setEffectiveDate(new Date());
trx.setStatus("SUCCESS");
transactionRepo.save(trx);
List<SoaMappingConfig> configs = new ArrayList<>();
// ==========================================
// KOTRAN 1001: Standar Mapping & Simple Math
// ==========================================
configs.add(createConfig("1001", "TLBF_ACC", "String", "#payment.debitAccountNo", null, 1));
configs.add(createConfig("1001", "TLBF_AMT", "Number", "#transaction.amount * 100", null, 1)); // Kali 100
configs.add(createConfig("1001", "TLBF_CCY", "String", "#payment.currency", "IDR", 1));
// ==========================================
// KOTRAN 1002: Formatting & Conditional Logic
// ==========================================
// Format Date menjadi yyyyMMdd menggunakan java.text.SimpleDateFormat di dalam SpEL
configs.add(createConfig("1002", "TLBF_DATE", "String", "new java.text.SimpleDateFormat('yyyyMMdd').format(#transaction.effectiveDate)", null, 1));
// Logic kurs: Jika USD kali 15000, selain itu kali 1
configs.add(createConfig("1002", "TLBF_EQV_AMT", "Number", "#payment.currency == 'USD' ? (#transaction.amount * 15000) : #transaction.amount", null, 1));
// Concatenation string
configs.add(createConfig("1002", "TLBF_REF", "String", "'REF-' + #transaction.id", null, 1));
// ==========================================
// KOTRAN 1003: NPE Handling & Default Values
// ==========================================
// Misal kita coba ambil data 'promoCode' yang tidak ada di object, pakai safe navigation '?.'
// Jika null, akan jatuh ke default value 'NO_PROMO'
configs.add(createConfig("1003", "TLBF_PROMO", "String", "#transaction?.promoCode", "NO_PROMO", 0));
// Mapping status dengan ternary
configs.add(createConfig("1003", "TLBF_STS", "String", "#transaction.status == 'SUCCESS' ? '00' : '99'", null, 1));
// ==========================================
// KOTRAN 1004: NESTED JSON DENGAN DOT NOTATION
// ==========================================
// Header level
configs.add(createConfig("1004", "RequestHeader.MessageId", "String", "'MSG-' + #transaction.id", null, 1));
configs.add(createConfig("1004", "RequestHeader.Kotran", "String", "'1004'", null, 1));
configs.add(createConfig("1004", "RequestHeader.Timestamp", "String", "new java.text.SimpleDateFormat('yyyy-MM-dd HH:mm:ss').format(#transaction.effectiveDate)", null, 1));
// Body level - Account Info
configs.add(createConfig("1004", "RequestBody.AccountInfo.DebitAccount", "String", "#payment.debitAccountNo", null, 1));
configs.add(createConfig("1004", "RequestBody.AccountInfo.Currency", "String", "#payment.currency", null, 1));
// Body level - Transaction Details
configs.add(createConfig("1004", "RequestBody.TransactionDetail.Amount", "Number", "#transaction.amount", null, 1));
configs.add(createConfig("1004", "RequestBody.TransactionDetail.ConvertedAmount", "Number", "#transaction.amount * 15000", null, 0)); // Opsional
configRepo.saveAll(configs);
log.info("Data Seeder berhasil dieksekusi untuk Kotran 1001, 1002, 1003!");
}
private SoaMappingConfig createConfig(String kotran, String targetFieldNm, String targetFieldType,
String expressionRule, String defaultValue, Integer isMandatory) {
SoaMappingConfig config = new SoaMappingConfig();
config.setId(UUID.randomUUID().toString());
config.setKotran(kotran);
config.setTargetFieldNm(targetFieldNm);
config.setTargetFieldType(targetFieldType);
config.setExpressionRule(expressionRule);
config.setDefaultValue(defaultValue);
config.setIsMandatory(isMandatory);
config.setStatus("ACTIVE");
return config;
}
}

View File

@@ -0,0 +1,55 @@
package com.ando.dynamic_soa_mapper.controller;
import com.ando.dynamic_soa_mapper.service.MappingEngineService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/v1/soa")
@RequiredArgsConstructor
public class SoaMappingController {
private final MappingEngineService mappingEngineService;
/**
* Endpoint untuk men-generate JSON payload SOA secara dinamis
* Contoh URL: GET /api/v1/soa/generate-payload?transactionId=TRX-555&kotran=1001
*/
@GetMapping("/generate-payload")
public ResponseEntity<?> generatePayload(
@RequestParam String transactionId,
@RequestParam String kotran) {
log.info("Menerima request API untuk generate payload - TRX: {}, KOTRAN: {}", transactionId, kotran);
try {
// Memanggil otak utama (Engine) kita
Map<String, Object> payload = mappingEngineService.constructPayload(transactionId, kotran);
// Return HTTP 200 OK dengan body berupa JSON hasil mapping
return ResponseEntity.ok(payload);
} catch (IllegalArgumentException e) {
// Best practice: Bedakan error validasi (Client Error 400)
log.warn("Validasi gagal: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of(
"status", "FAILED",
"error", e.getMessage()
));
} catch (Exception e) {
// Best practice: Tangkap error sistem (Server Error 500)
log.error("Terjadi kesalahan sistem saat generate payload: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
"status", "ERROR",
"error", "Terjadi kesalahan internal saat memproses mapping SOA."
));
}
}
}

View File

@@ -0,0 +1,23 @@
package com.ando.dynamic_soa_mapper.entity;
import lombok.Getter;
import lombok.Setter;
import jakarta.persistence.*;
import java.util.Date;
@Entity
@Table(name = "payment")
@Getter
@Setter
public class Payment {
@Id
private String id;
@Column(name = "debit_account_no")
private String debitAccountNo;
private String currency;
@Column(name = "created_date")
private Date createdDate;
}

View File

@@ -0,0 +1,33 @@
package com.ando.dynamic_soa_mapper.entity;
import lombok.Getter;
import lombok.Setter;
import jakarta.persistence.*;
@Entity
@Table(name = "soa_mapping_config")
@Getter
@Setter
public class SoaMappingConfig {
@Id
private String id;
private String kotran;
@Column(name = "target_field_nm")
private String targetFieldNm;
@Column(name = "target_field_type")
private String targetFieldType;
@Column(name = "expression_rule")
private String expressionRule;
@Column(name = "default_value")
private String defaultValue;
@Column(name = "is_mandatory")
private Integer isMandatory;
private String status;
}

View File

@@ -0,0 +1,26 @@
package com.ando.dynamic_soa_mapper.entity;
import lombok.Getter;
import lombok.Setter;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.Date;
@Entity
@Table(name = "transaction")
@Getter
@Setter
public class Transaction {
@Id
private String id;
@Column(name = "payment_id")
private String paymentId;
private BigDecimal amount; // Menggunakan BigDecimal untuk nominal uang (best practice)
@Column(name = "effective_date")
private Date effectiveDate;
private String status;
}

View File

@@ -0,0 +1,9 @@
package com.ando.dynamic_soa_mapper.repository;
import com.ando.dynamic_soa_mapper.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface PaymentRepository extends JpaRepository<Payment, String> {
}

View File

@@ -0,0 +1,14 @@
package com.ando.dynamic_soa_mapper.repository;
import com.ando.dynamic_soa_mapper.entity.SoaMappingConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface SoaMappingConfigRepository extends JpaRepository<SoaMappingConfig, String> {
// Ini adalah custom method. Spring otomatis men-generate query-nya:
// SELECT * FROM soa_mapping_config WHERE kotran = ? AND status = ? ORDER BY target_field_nm ASC
List<SoaMappingConfig> findByKotranAndStatusOrderByTargetFieldNmAsc(String kotran, String status);
}

View File

@@ -0,0 +1,9 @@
package com.ando.dynamic_soa_mapper.repository;
import com.ando.dynamic_soa_mapper.entity.Transaction;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TransactionRepository extends JpaRepository<Transaction, String> {
}

View File

@@ -0,0 +1,130 @@
package com.ando.dynamic_soa_mapper.service;
import com.ando.dynamic_soa_mapper.entity.Payment;
import com.ando.dynamic_soa_mapper.entity.SoaMappingConfig;
import com.ando.dynamic_soa_mapper.entity.Transaction;
import com.ando.dynamic_soa_mapper.repository.PaymentRepository;
import com.ando.dynamic_soa_mapper.repository.SoaMappingConfigRepository;
import com.ando.dynamic_soa_mapper.repository.TransactionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
@RequiredArgsConstructor
public class MappingEngineService {
private final TransactionRepository transactionRepo;
private final PaymentRepository paymentRepo;
private final SoaMappingConfigRepository configRepo;
// 1. SpEL Parser & Cache (Best Practice untuk Performance)
private final SpelExpressionParser parser = new SpelExpressionParser();
private final Map<String, Expression> expressionCache = new ConcurrentHashMap<>();
public Map<String, Object> constructPayload(String transactionId, String kotran) {
log.info("Memulai mapping payload untuk Transaction ID: {} dengan Kotran: {}", transactionId, kotran);
// 2. Fetch Data (Build The Context Foundation)
Transaction transaction = transactionRepo.findById(transactionId)
.orElseThrow(() -> new RuntimeException("Transaction tidak ditemukan: " + transactionId));
Payment payment = paymentRepo.findById(transaction.getPaymentId())
.orElseThrow(() -> new RuntimeException("Payment tidak ditemukan untuk ID: " + transaction.getPaymentId()));
// 3. Setup SpEL Context
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("transaction", transaction);
context.setVariable("payment", payment);
// Jika ke depan ada object baru (misal Customer), cukup tambahkan: context.setVariable("customer", customerData);
// 4. Fetch Mapping Rules
List<SoaMappingConfig> configs = configRepo.findByKotranAndStatusOrderByTargetFieldNmAsc(kotran, "ACTIVE");
if (configs.isEmpty()) {
throw new RuntimeException("Konfigurasi tidak ditemukan untuk Kotran: " + kotran);
}
// Menggunakan LinkedHashMap agar urutan field JSON sesuai urutan konfigurasi di Database
Map<String, Object> payload = new LinkedHashMap<>();
// 5. Evaluation Loop
for (SoaMappingConfig config : configs) {
Object evaluatedValue = null;
try {
if (config.getExpressionRule() != null && !config.getExpressionRule().trim().isEmpty()) {
Expression expression = expressionCache.computeIfAbsent(
config.getExpressionRule(),
rule -> parser.parseExpression(rule)
);
evaluatedValue = expression.getValue(context);
}
} catch (Exception e) {
log.warn("Gagal mengevaluasi rule '{}' untuk field {}. Error: {}",
config.getExpressionRule(), config.getTargetFieldNm(), e.getMessage());
}
// 6. Fallback Mechanism
if (evaluatedValue == null && config.getDefaultValue() != null) {
evaluatedValue = config.getDefaultValue();
}
// 7. Mandatory Validation
if (evaluatedValue == null && config.getIsMandatory() != null && config.getIsMandatory() == 1) {
throw new RuntimeException(
String.format("Field Mandatory gagal di-mapping! Kotran: %s, Field: %s",
kotran, config.getTargetFieldNm())
);
}
// 8. [PERUBAHAN DISINI] Build Nested JSON based on Dot Notation
if (evaluatedValue != null || (config.getIsMandatory() != null && config.getIsMandatory() == 0)) {
// Tetap masukkan ke map meski null jika bukan mandatory (atau sesuai rule SOA-mu)
putNestedValue(payload, config.getTargetFieldNm(), evaluatedValue);
}
}
log.info("Mapping selesai. Total config field yang diproses: {}", configs.size());
return payload;
}
/**
* Helper method untuk membangun nested Map berdasarkan Notasi Titik (Dot Notation).
* Contoh: path "req.header.msgId" akan membuat map { "req": { "header": { "msgId": value } } }
*/
@SuppressWarnings("unchecked")
private void putNestedValue(Map<String, Object> map, String path, Object value) {
// Jika tidak ada titik, langsung put ke root map (Flat JSON)
if (!path.contains(".")) {
map.put(path, value);
return;
}
String[] keys = path.split("\\.");
Map<String, Object> currentMap = map;
// Loop sampai elemen sebelum terakhir untuk membangun hierarki parent
for (int i = 0; i < keys.length - 1; i++) {
String key = keys[i];
// Jika parent belum ada, atau parent ada tapi bukan Map, buat LinkedHashMap baru
currentMap.putIfAbsent(key, new LinkedHashMap<>());
// Pindah fokus ke map anak (child)
currentMap = (Map<String, Object>) currentMap.get(key);
}
// Put value asli di kunci paling ujung (leaf node)
currentMap.put(keys[keys.length - 1], value);
}
}

View File

@@ -0,0 +1,14 @@
spring:
application:
name: dynamic-soa-mapper
datasource:
url: jdbc:oracle:thin:@//localhost:1521/FREEPDB1
username: appuser
password: appuser
driver-class-name: oracle.jdbc.OracleDriver
jpa:
database-platform: org.hibernate.dialect.OracleDialect
hibernate:
ddl-auto: update
show-sql: true

View File

@@ -0,0 +1,13 @@
package com.ando.dynamic_soa_mapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class DynamicSoaMapperApplicationTests {
@Test
void contextLoads() {
}
}