diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3f5e1d --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# 🚀 Dynamic SOA Mapper (Configuration-Driven Payload Engine) + +## 📖 Apa itu Dynamic SOA Mapper? +Dalam sistem integrasi perbankan/Enterprise, memetakan data internal ke format JSON yang diminta oleh *Service Oriented Architecture* (SOA) seringkali memakan waktu. Jika ada 100+ jenis transaksi (Kotran) dan setiap Kotran memiliki 30-40 field, melakukan *hardcode* menggunakan `if-else` di Java akan menghasilkan *spaghetti code* yang sulit di-*maintain*. + +**Dynamic SOA Mapper** memecahkan masalah ini dengan pendekatan **Metadata-Driven Architecture**. +Alih-alih menulis aturan *mapping* di dalam kode Java, seluruh aturan transformasi, operasi matematika, hingga pembentukan JSON berjenjang (*nested JSON*) **disimpan di dalam tabel Database**. + +Jika ada perubahan format dari SOA, tim operasional cukup mengubah data di tabel konfigurasi tanpa perlu melakukan kompilasi kode atau *deployment* ulang (*Zero Downtime*). + +--- + +## ✨ Fitur Utama +1. **100% Dynamic Rules:** Menggunakan **Spring Expression Language (SpEL)** untuk memanipulasi data (*string concatenation*, matematika, *ternary operator*, format tanggal) langsung dari teks di database. +2. **Auto Nested-JSON (Dot Notation):** Mendukung pembuatan JSON bertingkat tanpa mengubah struktur tabel. Cukup gunakan notasi titik pada konfigurasi (contoh: `header.message.id`), *engine* akan otomatis merakit objek JSON-nya. +3. **High Performance (SpEL Caching):** Aturan SpEL di-*parse* dan disimpan dalam *ConcurrentHashMap In-Memory Cache*. Eksekusi *mapping* berjalan dalam hitungan mikrosekon (O(1)). +4. **Resilient & Fail-Safe:** Dilengkapi dengan fitur *Safe Navigation* (mencegah *NullPointerException*), kolom *Default Value* (*fallback* jika data kosong), dan validasi *Mandatory Field*. + +--- + +## 🏗️ Arsitektur & Cara Kerja + +Saat *endpoint* API dipanggil dengan membawa `transactionId` dan `kotran`: +1. **Data Aggregation:** Sistem menarik data sumber (`Transaction` dan `Payment`) dari database dan membungkusnya ke dalam `StandardEvaluationContext`. +2. **Fetch Configuration:** Sistem mengambil daftar aturan *mapping* khusus untuk `kotran` tersebut dari tabel `SOA_MAPPING_CONFIG`. +3. **SpEL Evaluation:** *Engine* melakukan iterasi pada setiap aturan, mengeksekusi `expression_rule` terhadap *Context* data sumber. +4. **JSON Assembly:** Hasil evaluasi dirakit menjadi *Map* (mendukung *nested* berkat notasi titik) dan dikembalikan sebagai representasi JSON siap kirim. + +--- + +## 💻 Panduan Penggunaan & Konfigurasi + +### 1. Struktur Tabel Konfigurasi (`SOA_MAPPING_CONFIG`) +Ini adalah jantung dari *project* ini. Memahami cara mengisi tabel ini berarti menguasai seluruh *mapping*. + +| Kolom | Penjelasan | Contoh Pengisian | +| :--- | :--- | :--- | +| `KOTRAN` | Kode transaksi sebagai pengelompokan *rules*. | `1001`, `1002`, `POS_PAYMENT` | +| `TARGET_FIELD_NM` | Nama *key* JSON tujuan. Gunakan titik (`.`) untuk *nested object*. | `TLBF01` atau `header.msgId` | +| `TARGET_FIELD_TYPE` | Tipe data target (saat ini sebagai referensi dokumentasi). | `String`, `Number` | +| `EXPRESSION_RULE` | **Aturan SpEL** untuk mengambil/mengubah data dari objek sumber. | `#transaction.amount * 100` | +| `DEFAULT_VALUE` | Nilai *fallback* jika hasil evaluasi *rule* menghasilkan `null`. | `0`, `NO_DATA` | +| `IS_MANDATORY` | `1` (Wajib). Jika evaluasi dan default *null*, sistem akan *error*. `0` (Opsional). | `1`, `0` | + +### 2. Cheatsheet Penulisan Expression Rule (SpEL) +Dalam *project* ini, objek yang didaftarkan ke dalam *Context* adalah `#transaction` dan `#payment`. + +* **Mapping Langsung:** `#payment.debitAccountNo` +* **Hardcode Value:** `'PT BUMI MAKMUR'` *(Gunakan kutip satu)* +* **Operasi Matematika:** `#transaction.amount * 15000` +* **Konkatenasi String:** `'REF-' + #transaction.id` +* **Format Tanggal:** `new java.text.SimpleDateFormat('yyyyMMdd').format(#transaction.effectiveDate)` +* **Ternary Operator (If-Else):** `#transaction.status == 'SUCCESS' ? '00' : '99'` +* **Safe Navigation (Anti-NPE):** `#transaction?.promoCode` *(Gunakan `?.` jika curiga field tersebut bisa null)* + +--- + +## 🚀 Cara Menjalankan Aplikasi Lokal + +### Prasyarat: +* Java 17 atau lebih baru +* Maven 3.x +* Database Oracle (atau ubah URL di `application.yaml` jika menggunakan database lain) + +### Langkah-langkah: +1. Pastikan database Oracle menyala dan konfigurasi di `src/main/resources/application.yaml` sudah sesuai dengan *username* dan *password* lokalmu. +2. Jalankan perintah Maven: + ```bash + ./mvnw spring-boot:run \ No newline at end of file diff --git a/src/test/java/com/ando/dynamic_soa_mapper/MappingEngineServiceTest.java b/src/test/java/com/ando/dynamic_soa_mapper/MappingEngineServiceTest.java new file mode 100644 index 0000000..7369f83 --- /dev/null +++ b/src/test/java/com/ando/dynamic_soa_mapper/MappingEngineServiceTest.java @@ -0,0 +1,151 @@ +package com.ando.dynamic_soa_mapper; + +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 com.ando.dynamic_soa_mapper.service.MappingEngineService; +import org.junit.jupiter.api.BeforeEach; +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 java.math.BigDecimal; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) // Mengaktifkan Mockito +class MappingEngineServiceTest { + + @Mock + private TransactionRepository transactionRepo; + + @Mock + private PaymentRepository paymentRepo; + + @Mock + private SoaMappingConfigRepository configRepo; + + @InjectMocks + private MappingEngineService mappingEngineService; + + private Transaction dummyTransaction; + private Payment dummyPayment; + + @BeforeEach + void setUp() { + // Setup data dummy yang akan digunakan di setiap test + dummyPayment = new Payment(); + dummyPayment.setId("PAY-123"); + dummyPayment.setDebitAccountNo("1122334455"); + dummyPayment.setCurrency("IDR"); + + dummyTransaction = new Transaction(); + dummyTransaction.setId("TRX-123"); + dummyTransaction.setPaymentId("PAY-123"); + dummyTransaction.setAmount(new BigDecimal("1000.00")); + dummyTransaction.setStatus("SUCCESS"); + } + + @Test + void testConstructPayload_SuccessFlatMapping() { + // 1. Persiapkan rule mapping (Mock Database) + SoaMappingConfig config1 = createConfig("TLBF_ACC", "#payment.debitAccountNo", null, 1); + SoaMappingConfig config2 = createConfig("TLBF_AMT", "#transaction.amount * 5", null, 1); + + // 2. Ajari Mockito cara membalas request + when(transactionRepo.findById("TRX-123")).thenReturn(Optional.of(dummyTransaction)); + when(paymentRepo.findById("PAY-123")).thenReturn(Optional.of(dummyPayment)); + when(configRepo.findByKotranAndStatusOrderByTargetFieldNmAsc(eq("1001"), eq("ACTIVE"))) + .thenReturn(Arrays.asList(config1, config2)); + + // 3. Eksekusi Engine + Map payload = mappingEngineService.constructPayload("TRX-123", "1001"); + + // 4. Verifikasi Hasil + assertNotNull(payload); + assertEquals(2, payload.size()); + assertEquals("1122334455", payload.get("TLBF_ACC")); + assertEquals(new BigDecimal("5000.00"), payload.get("TLBF_AMT")); // 1000 * 5 + } + + @Test + @SuppressWarnings("unchecked") + void testConstructPayload_SuccessNestedMappingDotNotation() { + // 1. Setup rule mapping nested (Notasi Titik) + SoaMappingConfig config1 = createConfig("header.msgId", "'MSG-' + #transaction.id", null, 1); + SoaMappingConfig config2 = createConfig("body.account.currency", "#payment.currency", null, 1); + + when(transactionRepo.findById("TRX-123")).thenReturn(Optional.of(dummyTransaction)); + when(paymentRepo.findById("PAY-123")).thenReturn(Optional.of(dummyPayment)); + when(configRepo.findByKotranAndStatusOrderByTargetFieldNmAsc(eq("1002"), eq("ACTIVE"))) + .thenReturn(Arrays.asList(config1, config2)); + + // 2. Eksekusi + Map payload = mappingEngineService.constructPayload("TRX-123", "1002"); + + // 3. Verifikasi hierarki JSON (Nested Map) + assertTrue(payload.containsKey("header")); + Map headerMap = (Map) payload.get("header"); + assertEquals("MSG-TRX-123", headerMap.get("msgId")); + + assertTrue(payload.containsKey("body")); + Map bodyMap = (Map) payload.get("body"); + Map accountMap = (Map) bodyMap.get("account"); + assertEquals("IDR", accountMap.get("currency")); + } + + @Test + void testConstructPayload_MandatoryFieldThrowsException() { + // Setup rule di mana data aslinya kosong, tapi isMandatory = 1 + dummyTransaction.setStatus(null); // Kita buat status jadi null + SoaMappingConfig config1 = createConfig("TLBF_STS", "#transaction.status", null, 1); + + when(transactionRepo.findById("TRX-123")).thenReturn(Optional.of(dummyTransaction)); + when(paymentRepo.findById("PAY-123")).thenReturn(Optional.of(dummyPayment)); + when(configRepo.findByKotranAndStatusOrderByTargetFieldNmAsc(eq("1003"), eq("ACTIVE"))) + .thenReturn(Arrays.asList(config1)); + + // Harus melempar RuntimeException karena mandatory field null + Exception exception = assertThrows(RuntimeException.class, () -> { + mappingEngineService.constructPayload("TRX-123", "1003"); + }); + + assertTrue(exception.getMessage().contains("Field Mandatory gagal di-mapping")); + } + + @Test + void testConstructPayload_FallbackToDefaultValue() { + // Setup rule dengan SpEL error/null, tapi punya default value + SoaMappingConfig config1 = createConfig("TLBF_PROMO", "#transaction?.promoCode", "NO_PROMO", 1); + + when(transactionRepo.findById("TRX-123")).thenReturn(Optional.of(dummyTransaction)); + when(paymentRepo.findById("PAY-123")).thenReturn(Optional.of(dummyPayment)); + when(configRepo.findByKotranAndStatusOrderByTargetFieldNmAsc(eq("1004"), eq("ACTIVE"))) + .thenReturn(Arrays.asList(config1)); + + Map payload = mappingEngineService.constructPayload("TRX-123", "1004"); + + // Harus menggunakan default value + assertEquals("NO_PROMO", payload.get("TLBF_PROMO")); + } + + // Helper method agar code rapi + private SoaMappingConfig createConfig(String targetFieldNm, String expressionRule, String defaultValue, Integer isMandatory) { + SoaMappingConfig config = new SoaMappingConfig(); + config.setTargetFieldNm(targetFieldNm); + config.setExpressionRule(expressionRule); + config.setDefaultValue(defaultValue); + config.setIsMandatory(isMandatory); + return config; + } +} \ No newline at end of file