readme
This commit is contained in:
69
README.md
Normal file
69
README.md
Normal file
@@ -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
|
||||||
@@ -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<String, Object> 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<String, Object> payload = mappingEngineService.constructPayload("TRX-123", "1002");
|
||||||
|
|
||||||
|
// 3. Verifikasi hierarki JSON (Nested Map)
|
||||||
|
assertTrue(payload.containsKey("header"));
|
||||||
|
Map<String, Object> headerMap = (Map<String, Object>) payload.get("header");
|
||||||
|
assertEquals("MSG-TRX-123", headerMap.get("msgId"));
|
||||||
|
|
||||||
|
assertTrue(payload.containsKey("body"));
|
||||||
|
Map<String, Object> bodyMap = (Map<String, Object>) payload.get("body");
|
||||||
|
Map<String, Object> accountMap = (Map<String, Object>) 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<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user