update fuction filter
This commit is contained in:
678
README.md
678
README.md
@@ -1,313 +1,523 @@
|
||||
# 🚀 API Service Guide
|
||||
|
||||
## 📖 Introduction
|
||||
This API service is designed to provide a robust backend for managing products, orders, and user authentication. It integrates with BPJS for health insurance services and offers a comprehensive set of features for developers.
|
||||
# 🚀 API Service - Clean Architecture
|
||||
|
||||
## 🌟 Features
|
||||
- User authentication with JWT
|
||||
- CRUD operations for products and orders
|
||||
- Integration with BPJS services
|
||||
- Swagger documentation for easy API testing
|
||||
- Docker support for easy deployment
|
||||
> **Layanan API modern dengan arsitektur bersih untuk manajemen produk, pesanan, dan otentikasi pengguna**
|
||||
|
||||
## 📋 Quick Start (5 Menit)
|
||||
## 📑 Daftar Isi
|
||||
|
||||
### 1. Setup Environment
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd api-service
|
||||
cp .env.example .env
|
||||
- [✨ Fitur Utama](#-fitur-utama)
|
||||
- [🏗️ Arsitektur](#%EF%B8%8F-arsitektur)
|
||||
- [⚡ Quick Start](#-quick-start)
|
||||
- [🔐 Autentikasi](#-autentikasi)
|
||||
- [📊 API Endpoints](#-api-endpoints)
|
||||
- [🛠️ Development](#%EF%B8%8F-development)
|
||||
- [🚀 Deployment](#-deployment)
|
||||
- [📚 Dokumentasi](#-dokumentasi)
|
||||
|
||||
***
|
||||
|
||||
## ✨ Fitur Utama
|
||||
|
||||
### Core Features
|
||||
|
||||
- **🔒 JWT Authentication** - Sistem autentikasi yang aman
|
||||
- **📦 CRUD Operations** - Operasi lengkap untuk produk dan pesanan
|
||||
- **🏥 BPJS Integration** - Integrasi dengan layanan kesehatan BPJS
|
||||
- **🩺 FHIR/SATUSEHAT** - Dukungan standar kesehatan internasional
|
||||
- **📖 API Documentation** - Swagger/OpenAPI yang interaktif
|
||||
|
||||
|
||||
### Developer Experience
|
||||
|
||||
- **🔥 Hot Reload** - Development dengan auto-restart
|
||||
- **🐳 Docker Ready** - Deployment yang mudah
|
||||
- **⚡ Code Generator** - Buat handler dan model otomatis
|
||||
- **🧪 Testing Suite** - Unit dan integration tests
|
||||
- **📊 Health Monitoring** - Monitoring kesehatan aplikasi
|
||||
|
||||
***
|
||||
|
||||
## 🏗️ Arsitektur
|
||||
|
||||
### Clean Architecture Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Presentation Layer │ ← handlers/, routes/
|
||||
├─────────────────────────────────────┤
|
||||
│ Application Layer │ ← middleware/, validators/
|
||||
├─────────────────────────────────────┤
|
||||
│ Domain Layer │ ← models/, interfaces/
|
||||
├─────────────────────────────────────┤
|
||||
│ Infrastructure Layer │ ← database/, external APIs
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. Jalankan Database & API
|
||||
```bash
|
||||
# Opsi A: Docker (Rekomendasi)
|
||||
make docker-run
|
||||
|
||||
# Opsi B: Manual
|
||||
go mod download
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
### 3. Akses API
|
||||
- **API**: http://localhost:8080/api/v1
|
||||
- **Swagger**: http://localhost:8080/swagger/index.html
|
||||
- **Health Check**: http://localhost:8080/api/v1/health
|
||||
|
||||
## 🏗️ Struktur Project
|
||||
### Struktur Project
|
||||
|
||||
```
|
||||
api-service/
|
||||
├── cmd/api/main.go # Entry point
|
||||
├── internal/ # Core business logic
|
||||
│ ├── handlers/ # HTTP controllers
|
||||
│ ├── models/ # Data structures
|
||||
│ ├── middleware/ # Auth & error handling
|
||||
│ └── routes/ # API routes
|
||||
├── tools/ # CLI generators
|
||||
└── docs/ # API documentation
|
||||
├── 📁 cmd/
|
||||
│ └── api/main.go # 🚪 Entry point aplikasi
|
||||
├── 📁 internal/ # 🏠 Core business logic
|
||||
│ ├── handlers/ # 🎮 HTTP controllers (Presentation)
|
||||
│ ├── middleware/ # 🛡️ Auth & validation (Application)
|
||||
│ ├── models/ # 📊 Data structures (Domain)
|
||||
│ ├── routes/ # 🛣️ API routing (Presentation)
|
||||
│ ├── services/ # 💼 Business logic (Application)
|
||||
│ └── repository/ # 💾 Data access (Infrastructure)
|
||||
├── 📁 tools/ # 🔧 Development tools
|
||||
│ ├── general/ # 🎯 General generators
|
||||
│ ├── bpjs/ # 🏥 BPJS specific tools
|
||||
│ └── satusehat/ # 🩺 SATUSEHAT tools
|
||||
├── 📁 docs/ # 📚 Documentation
|
||||
├── 📁 configs/ # ⚙️ Configuration files
|
||||
└── 📁 scripts/ # 📜 Automation scripts
|
||||
```
|
||||
|
||||
## 🔐 Authentication
|
||||
|
||||
### Login (Dapatkan Token)
|
||||
***
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
### 1️⃣ Setup Environment (2 menit)
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd api-service
|
||||
|
||||
# Setup environment
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
|
||||
### 2️⃣ Pilih Method Setup
|
||||
|
||||
**🐳 Docker (Recommended)**
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
```
|
||||
|
||||
**🔧 Manual Setup**
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
go mod download
|
||||
|
||||
# Run database migrations
|
||||
make migrate-up
|
||||
|
||||
# Start server
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
|
||||
### 3️⃣ Verify Installation
|
||||
|
||||
| Service | URL | Status |
|
||||
| :-- | :-- | :-- |
|
||||
| **API** | http://localhost:8080/api/v1 | ✅ |
|
||||
| **Swagger** | http://localhost:8080/swagger/index.html | 📖 |
|
||||
| **Health Check** | http://localhost:8080/api/v1/health | 💚 |
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 🔐 Autentikasi
|
||||
|
||||
### Login \& Mendapatkan Token
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"password"}'
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"password": "password"
|
||||
}'
|
||||
```
|
||||
|
||||
### Gunakan Token
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"expires_in": 3600,
|
||||
"user": {
|
||||
"id": "123",
|
||||
"username": "admin",
|
||||
"role": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Menggunakan Token
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8080/api/v1/products \
|
||||
-H "Authorization: Bearer <your-token>"
|
||||
```
|
||||
|
||||
### Demo Users
|
||||
| Username | Password | Role |
|
||||
|----------|----------|------|
|
||||
| admin | password | admin |
|
||||
| user | password | user |
|
||||
|
||||
## 🛠️ Generate Handler Baru (30 Detik)
|
||||
### Demo Accounts
|
||||
|
||||
### Cara Cepat
|
||||
```bash
|
||||
# Windows
|
||||
tools/generate.bat product get post put delete
|
||||
| Username | Password | Role | Akses |
|
||||
| :-- | :-- | :-- | :-- |
|
||||
| `admin` | `password` | Admin | Semua endpoint |
|
||||
| `user` | `password` | User | Read-only |
|
||||
|
||||
# Linux/Mac
|
||||
./tools/generate.sh product get post put delete
|
||||
|
||||
# Atau langsung dengan Go
|
||||
go run tools/general/generate-handler.go orders get post
|
||||
|
||||
go run tools/general/generate-handler.go orders/product get post
|
||||
|
||||
go run tools/general/generate-handler.go orders/order get post put delete dynamic search stats
|
||||
|
||||
go run tools/bpjs/generate-bpjs-handler.go reference/peserta get
|
||||
|
||||
# 1. Test generate satu service
|
||||
go run tools/bpjs/generate-handler.go services-config-bpjs.yaml vclaim
|
||||
|
||||
# 2. Test generate semua service
|
||||
go run tools/bpjs/generate-handler.go services-config-bpjs.yaml
|
||||
|
||||
go run tools/satusehat/generate-satusehat-handler.go services-config-satusehat.yaml patient
|
||||
```
|
||||
|
||||
### Method Tersedia
|
||||
- `get` - Ambil data
|
||||
- `post` - Buat data baru
|
||||
- `put` - Update data
|
||||
- `delete` - Hapus data
|
||||
|
||||
### Hasil Generate
|
||||
- **Handler**: `internal/handlers/product/product.go`
|
||||
- **Models**: `internal/models/product/product.go`
|
||||
- **Routes**: Otomatis ditambahkan ke `/api/v1/products`
|
||||
***
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
### Public (Tanpa Auth)
|
||||
- `POST /api/v1/auth/login` - Login
|
||||
- `POST /api/v1/auth/register` - Register
|
||||
- `GET /api/v1/health` - Health check
|
||||
### 🌍 Public Endpoints
|
||||
|
||||
### Protected (Dengan Auth)
|
||||
- `GET /api/v1/auth/me` - Profile user
|
||||
- `GET /api/v1/products` - List products
|
||||
- `POST /api/v1/products` - Create product
|
||||
- `GET /api/v1/products/:id` - Detail product
|
||||
- `PUT /api/v1/products/:id` - Update product
|
||||
- `DELETE /api/v1/products/:id` - Delete product
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `POST` | `/api/v1/auth/login` | Login pengguna |
|
||||
| `POST` | `/api/v1/auth/register` | Registrasi pengguna baru |
|
||||
| `GET` | `/api/v1/health` | Status kesehatan API |
|
||||
|
||||
GET /api/v1/retribusi/dynamic
|
||||
GET /api/v1/retribusi/dynamic?fields=Jenis,Pelayanan,Dinas,Tarif
|
||||
GET /api/v1/retribusi/dynamic?filter[status][_eq]=active
|
||||
### 🔒 Protected Endpoints
|
||||
|
||||
#### User Management
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `GET` | `/api/v1/auth/me` | Profile pengguna |
|
||||
| `PUT` | `/api/v1/auth/me` | Update profile |
|
||||
|
||||
#### Product Management
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `GET` | `/api/v1/products` | List semua produk |
|
||||
| `POST` | `/api/v1/products` | Buat produk baru |
|
||||
| `GET` | `/api/v1/products/:id` | Detail produk |
|
||||
| `PUT` | `/api/v1/products/:id` | Update produk |
|
||||
| `DELETE` | `/api/v1/products/:id` | Hapus produk |
|
||||
|
||||
#### Dynamic Query (Advanced)
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `GET` | `/api/v1/retribusi/dynamic` | Query dengan filter dinamis |
|
||||
|
||||
**Contoh Query:**
|
||||
|
||||
```bash
|
||||
# Filter berdasarkan jenis
|
||||
GET /api/v1/retribusi/dynamic?filter[Jenis][_eq]=RETRIBUSI PELAYANAN KESEHATAN
|
||||
GET /api/v1/retribusi/dynamic?filter[status][_eq]=active&filter[Jenis][_contains]=KESEHATAN
|
||||
GET /api/v1/retribusi/dynamic?filter[_or][0][Jenis][_contains]=KESEHATAN&filter[_or][1][Jenis][_contains]=PENDIDIKAN
|
||||
GET /api/v1/retribusi/dynamic?filter[date_created][_gte]=2024-01-01&filter[date_created][_lte]=2024-12-31
|
||||
GET /api/v1/retribusi/dynamic?filter[Jenis][_contains]=KESEHATAN&filter[Pelayanan][_contains]=RUMAH SAKIT
|
||||
GET /api/v1/retribusi/dynamic?limit=10&offset=20
|
||||
GET /api/v1/retribusi/dynamic?filter[status][_neq]=deleted&filter[Tarif][_gt]=100000&filter[Jenis][_contains]=KESEHATAN&sort=-date_created&limit=50
|
||||
GET /api/v1/retribusi/dynamic?filter[Dinas][_eq]=DINAS KESEHATAN
|
||||
GET /api/v1/retribusi/dynamic?filter[Kode_tarif][_contains]=RS
|
||||
GET /api/v1/retribusi/dynamic?filter[date_created][_gte]=2024-01-15&filter[date_created][_lt]=2024-01-16
|
||||
GET /api/v1/retribusi/dynamic?filter[Uraian_1][_contains]=RAWAT INAP
|
||||
|
||||
Available Filter Operators
|
||||
_eq - Equal
|
||||
_neq - Not equal
|
||||
_lt - Less than
|
||||
_lte - Less than or equal
|
||||
_gt - Greater than
|
||||
_gte - Greater than or equal
|
||||
_contains - Contains (case-insensitive)
|
||||
_ncontains - Not contains
|
||||
_starts_with - Starts with
|
||||
_ends_with - Ends with
|
||||
_in - In array
|
||||
_nin - Not in array
|
||||
_null - Is null
|
||||
_nnull - Is not null
|
||||
Available Fields for Filtering
|
||||
id - Record ID
|
||||
status - Status (active, draft, inactive, deleted)
|
||||
Jenis - Type of retribution
|
||||
Pelayanan - Service type
|
||||
Dinas - Department
|
||||
Kelompok_obyek - Object group
|
||||
Kode_tarif - Tariff code
|
||||
Tarif - Tariff amount
|
||||
Satuan - Unit
|
||||
Tarif_overtime - Overtime tariff
|
||||
Satuan_overtime - Overtime unit
|
||||
Rekening_pokok - Main account
|
||||
Rekening_denda - Penalty account
|
||||
Uraian_1, Uraian_2, Uraian_3 - Descriptions
|
||||
date_created - Creation date
|
||||
date_updated - Update date
|
||||
user_created - Created by user
|
||||
user_updated - Updated by user
|
||||
Response Format
|
||||
The endpoint returns data in this format:
|
||||
# Kombinasi filter
|
||||
GET /api/v1/retribusi/dynamic?filter[status][_eq]=active&filter[Tarif][_gt]=100000
|
||||
|
||||
|
||||
{
|
||||
"message": "Data retribusi berhasil diambil",
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"status": "active",
|
||||
"Jenis": "RETRIBUSI PELAYANAN KESEHATAN",
|
||||
"Pelayanan": "RAWAT INAP",
|
||||
"Dinas": "DINAS KESEHATAN",
|
||||
"Tarif": 150000,
|
||||
"date_created": "2024-01-15T10:30:00Z"
|
||||
// ... other fields
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"total": 150,
|
||||
"total_pages": 15,
|
||||
"current_page": 1,
|
||||
"has_next": true,
|
||||
"has_prev": false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## 🧪 Development Workflow
|
||||
|
||||
### Perintah Penting
|
||||
```bash
|
||||
make watch # Jalankan dengan hot reload
|
||||
make test # Unit tests
|
||||
make itest # Integration tests
|
||||
make all # Semua tests
|
||||
make build # Build production
|
||||
# Pagination dan sorting
|
||||
GET /api/v1/retribusi/dynamic?sort=-date_created&limit=10&offset=20
|
||||
```
|
||||
|
||||
### Update Swagger
|
||||
|
||||
### 🏥 BPJS Integration
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `GET` | `/api/v1/bpjs/peserta/:no` | Data peserta BPJS |
|
||||
| `GET` | `/api/v1/bpjs/rujukan/:no` | Data rujukan |
|
||||
|
||||
### 🩺 SATUSEHAT Integration
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
| :-- | :-- | :-- |
|
||||
| `GET` | `/api/v1/satusehat/patient/:id` | Data pasien |
|
||||
| `POST` | `/api/v1/satusehat/encounter` | Buat encounter baru |
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Code Generation (30 detik)
|
||||
|
||||
**🎯 Generate CRUD Lengkap**
|
||||
|
||||
```bash
|
||||
swag init -g cmd/api/main.go --parseDependency --parseInternal # Alternative Kedua
|
||||
swag init -g cmd/api/main.go -o docs/
|
||||
# Generate handler untuk entity baru
|
||||
go run tools/general/generate-handler.go product get post put delete
|
||||
|
||||
# Generate dengan fitur advanced
|
||||
go run tools/general/generate-handler.go orders get post put delete dynamic search stats
|
||||
```
|
||||
|
||||
## 🔧 Environment Variables
|
||||
**🏥 Generate BPJS Handler**
|
||||
|
||||
### Database
|
||||
```bash
|
||||
# Single service
|
||||
go run tools/bpjs/generate-bpjs-handler.go reference/peserta get
|
||||
|
||||
# Semua service dari config
|
||||
go run tools/bpjs/generate-handler.go services-config-bpjs.yaml
|
||||
```
|
||||
|
||||
**🩺 Generate SATUSEHAT Handler**
|
||||
|
||||
```bash
|
||||
go run tools/satusehat/generate-satusehat-handler.go services-config-satusehat.yaml patient
|
||||
```
|
||||
|
||||
|
||||
### Development Commands
|
||||
|
||||
```bash
|
||||
# 🔥 Development dengan hot reload
|
||||
make watch
|
||||
|
||||
# 🧪 Testing
|
||||
make test # Unit tests
|
||||
make itest # Integration tests
|
||||
make test-all # Semua tests
|
||||
|
||||
# 📖 Update dokumentasi
|
||||
make docs # Generate Swagger docs
|
||||
|
||||
# 🔍 Code quality
|
||||
make lint # Linting
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
**📁 .env File:**
|
||||
|
||||
```bash
|
||||
# Database
|
||||
BLUEPRINT_DB_HOST=localhost
|
||||
BLUEPRINT_DB_PORT=5432
|
||||
BLUEPRINT_DB_USERNAME=postgres
|
||||
BLUEPRINT_DB_PASSWORD=postgres
|
||||
BLUEPRINT_DB_DATABASE=api_service
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-key-change-in-production
|
||||
|
||||
# External APIs
|
||||
BPJS_BASE_URL=https://api.bpjs-kesehatan.go.id
|
||||
SATUSEHAT_BASE_URL=https://api.satusehat.kemkes.go.id
|
||||
|
||||
# Application
|
||||
APP_ENV=development
|
||||
APP_PORT=8080
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
### JWT Secret
|
||||
|
||||
***
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### 🐳 Docker Deployment
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
JWT_SECRET=your-secret-key-change-this-in-production
|
||||
# Start semua services
|
||||
make docker-run
|
||||
|
||||
# Stop services
|
||||
make docker-down
|
||||
|
||||
# Rebuild dan restart
|
||||
make docker-rebuild
|
||||
```
|
||||
|
||||
## 🐳 Docker Commands
|
||||
**Production:**
|
||||
|
||||
### Development
|
||||
```bash
|
||||
make docker-run # Jalankan semua services
|
||||
docker-compose down # Stop services
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
# Build production image
|
||||
docker build -t api-service:prod .
|
||||
docker run -d -p 8080:8080 --env-file .env api-service:prod
|
||||
|
||||
# Run production container
|
||||
docker run -d \
|
||||
--name api-service \
|
||||
-p 8080:8080 \
|
||||
--env-file .env.prod \
|
||||
api-service:prod
|
||||
```
|
||||
|
||||
## 🎯 Tips Cepat
|
||||
|
||||
### 1. Generate CRUD Lengkap
|
||||
### 🔧 Manual Deployment
|
||||
|
||||
```bash
|
||||
go run tools/generate-handler.go user get post put delete
|
||||
# Build aplikasi
|
||||
make build
|
||||
|
||||
# Run migrations
|
||||
./scripts/migrate.sh up
|
||||
|
||||
# Start server
|
||||
./bin/api-service
|
||||
```
|
||||
|
||||
### 2. Test API dengan Swagger
|
||||
1. Buka http://localhost:8080/swagger/index.html
|
||||
2. Login untuk dapat token
|
||||
3. Klik "Authorize" dan masukkan token
|
||||
4. Test semua endpoint
|
||||
|
||||
### 3. Debug Database
|
||||
- **pgAdmin**: http://localhost:5050
|
||||
- **Database**: api_service
|
||||
- **User/Pass**: postgres/postgres
|
||||
***
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
## 📚 Dokumentasi
|
||||
|
||||
### Generate Handler Gagal
|
||||
- Pastikan di root project
|
||||
- Cek file `internal/routes/v1/routes.go` ada
|
||||
- Pastikan permission write
|
||||
### 📖 Interactive API Documentation
|
||||
|
||||
### Database Connection Error
|
||||
- Cek PostgreSQL running
|
||||
- Verifikasi .env configuration
|
||||
- Gunakan `make docker-run` untuk setup otomatis
|
||||
Kunjungi **Swagger UI** di: http://localhost:8080/swagger/index.html
|
||||
|
||||
### Token Invalid
|
||||
- Login ulang untuk dapat token baru
|
||||
- Token expire dalam 1 jam
|
||||
- Pastikan format: `Bearer <token>`
|
||||
**Cara menggunakan:**
|
||||
|
||||
## 📱 Contoh Integrasi Frontend
|
||||
1. 🔑 Login melalui `/auth/login` endpoint
|
||||
2. 📋 Copy token dari response
|
||||
3. 🔓 Klik tombol "Authorize" di Swagger
|
||||
4. 📝 Masukkan: `Bearer <your-token>`
|
||||
5. ✅ Test semua endpoint yang tersedia
|
||||
|
||||
### 🧪 Testing Examples
|
||||
|
||||
**JavaScript/Axios:**
|
||||
|
||||
### JavaScript/Axios
|
||||
```javascript
|
||||
// Login
|
||||
const login = await axios.post('/api/v1/auth/login', {
|
||||
// Login dan set token
|
||||
const auth = await axios.post('/api/v1/auth/login', {
|
||||
username: 'admin',
|
||||
password: 'password'
|
||||
});
|
||||
|
||||
// Set token
|
||||
axios.defaults.headers.common['Authorization'] =
|
||||
`Bearer ${login.data.access_token}`;
|
||||
`Bearer ${auth.data.access_token}`;
|
||||
|
||||
// Get data
|
||||
// Fetch data
|
||||
const products = await axios.get('/api/v1/products');
|
||||
console.log(products.data);
|
||||
```
|
||||
|
||||
## 🎉 Next Steps
|
||||
1. ✅ Setup environment selesai
|
||||
2. ✅ Generate handler pertama
|
||||
3. ✅ Test dengan Swagger
|
||||
4. 🔄 Tambahkan business logic
|
||||
5. 🔄 Deploy ke production
|
||||
**cURL Examples:**
|
||||
|
||||
```bash
|
||||
# Login
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"password"}' | jq -r '.access_token')
|
||||
|
||||
# Use token
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/v1/products
|
||||
```
|
||||
|
||||
|
||||
### 🔍 Health Monitoring
|
||||
|
||||
```bash
|
||||
# Basic health check
|
||||
curl http://localhost:8080/api/v1/health
|
||||
|
||||
# Detailed system info
|
||||
curl http://localhost:8080/api/v1/health/detailed
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2025-09-10T05:39:00Z",
|
||||
"services": {
|
||||
"database": "connected",
|
||||
"bpjs_api": "accessible",
|
||||
"satusehat_api": "accessible"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Masalah Umum
|
||||
|
||||
**❌ Database Connection Error**
|
||||
|
||||
```bash
|
||||
# Cek status PostgreSQL
|
||||
make db-status
|
||||
|
||||
# Reset database
|
||||
make db-reset
|
||||
|
||||
# Check logs
|
||||
make logs-db
|
||||
```
|
||||
|
||||
**❌ Generate Handler Gagal**
|
||||
|
||||
- ✅ Pastikan berada di root project
|
||||
- ✅ Cek permission write di folder `internal/`
|
||||
- ✅ Verifikasi file `internal/routes/v1/routes.go` exists
|
||||
|
||||
**❌ Token Invalid/Expired**
|
||||
|
||||
- 🔄 Login ulang untuk mendapatkan token baru
|
||||
- ⏰ Token expire dalam 1 jam (configurable)
|
||||
- 📝 Format harus: `Bearer <token>`
|
||||
|
||||
**❌ Import Error saat Generate**
|
||||
|
||||
- 🧹 Jalankan: `go mod tidy`
|
||||
- 🔄 Restart development server
|
||||
- 📝 Cek format import di generated files
|
||||
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
# Enable debug logging
|
||||
export LOG_LEVEL=debug
|
||||
|
||||
# Run dengan verbose output
|
||||
make run-debug
|
||||
|
||||
# Monitor performance
|
||||
make monitor
|
||||
```
|
||||
|
||||
|
||||
***
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
### 📋 Development Roadmap
|
||||
|
||||
- [ ] ✅ **Setup environment selesai**
|
||||
- [ ] ✅ **Generate handler pertama**
|
||||
- [ ] ✅ **Test dengan Swagger**
|
||||
- [ ] 🔄 **Implementasi business logic**
|
||||
- [ ] 🔄 **Tambahkan unit tests**
|
||||
- [ ] 🔄 **Setup CI/CD pipeline**
|
||||
- [ ] 🔄 **Deploy ke production**
|
||||
|
||||
|
||||
### 🚀 Advanced Features
|
||||
|
||||
- **📊 Monitoring \& Observability**
|
||||
- **🔒 Enhanced Security (Rate limiting, CORS)**
|
||||
- **📈 Performance Optimization**
|
||||
- **🌐 Multi-language Support**
|
||||
- **📱 Mobile SDK Integration**
|
||||
|
||||
***
|
||||
|
||||
**⚡ Total setup time: 5 menit | 🔧 Generate CRUD: 30 detik | 🧪 Testing: Langsung via Swagger**
|
||||
|
||||
> **💡 Pro Tip:** Gunakan `make help` untuk melihat semua command yang tersedia
|
||||
|
||||
***
|
||||
|
||||
---
|
||||
**Total waktu setup: 5 menit** | **Generate CRUD: 30 detik** | **Testing: Langsung di Swagger**
|
||||
|
||||
BIN
api-service
BIN
api-service
Binary file not shown.
610
dynamic_filter_test.go
Normal file
610
dynamic_filter_test.go
Normal file
@@ -0,0 +1,610 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueryBuilder_BuildQuery(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table").
|
||||
SetColumnMapping(map[string]string{
|
||||
"name": "full_name",
|
||||
}).
|
||||
SetAllowedColumns([]string{"id", "name", "age", "status"})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query DynamicQuery
|
||||
expected string
|
||||
argsLen int
|
||||
}{
|
||||
{
|
||||
name: "simple equality filter",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "name",
|
||||
Operator: OpEqual,
|
||||
Value: "John",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"full_name\" = $1",
|
||||
argsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "IN operator with multiple values",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "status",
|
||||
Operator: OpIn,
|
||||
Value: []interface{}{"active", "pending"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"status\" IN ($1, $2)",
|
||||
argsLen: 2,
|
||||
},
|
||||
{
|
||||
name: "BETWEEN operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "age",
|
||||
Operator: OpBetween,
|
||||
Value: []interface{}{"18", "65"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"age\" BETWEEN $1 AND $2",
|
||||
argsLen: 2,
|
||||
},
|
||||
{
|
||||
name: "NOT BETWEEN operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "age",
|
||||
Operator: OpNotBetween,
|
||||
Value: []interface{}{"18", "65"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"age\" NOT BETWEEN $1 AND $2",
|
||||
argsLen: 2,
|
||||
},
|
||||
{
|
||||
name: "LIKE operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "name",
|
||||
Operator: OpLike,
|
||||
Value: "John%",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"full_name\" LIKE $1",
|
||||
argsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "ILIKE operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "name",
|
||||
Operator: OpILike,
|
||||
Value: "john%",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"full_name\" ILIKE $1",
|
||||
argsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "greater than operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "age",
|
||||
Operator: OpGreaterThan,
|
||||
Value: "30",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"age\" > $1",
|
||||
argsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "less than or equal operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "age",
|
||||
Operator: OpLessThanEqual,
|
||||
Value: "65",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"age\" <= $1",
|
||||
argsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "NOT IN operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "status",
|
||||
Operator: OpNotIn,
|
||||
Value: []interface{}{"deleted", "inactive"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"status\" NOT IN ($1, $2)",
|
||||
argsLen: 2,
|
||||
},
|
||||
{
|
||||
name: "multiple filters with AND",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{
|
||||
{
|
||||
Column: "status",
|
||||
Operator: OpEqual,
|
||||
Value: "active",
|
||||
},
|
||||
{
|
||||
Column: "age",
|
||||
Operator: OpGreaterThan,
|
||||
Value: "18",
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"status\" = $1 AND \"age\" > $2",
|
||||
argsLen: 2,
|
||||
},
|
||||
{
|
||||
name: "NULL check",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "name",
|
||||
Operator: OpNull,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"full_name\" IS NULL",
|
||||
argsLen: 0,
|
||||
},
|
||||
{
|
||||
name: "NOT NULL check",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "name",
|
||||
Operator: OpNotNull,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"full_name\" IS NOT NULL",
|
||||
argsLen: 0,
|
||||
},
|
||||
{
|
||||
name: "CONTAINS operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "name",
|
||||
Operator: OpContains,
|
||||
Value: "John",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"full_name\" ILIKE $1",
|
||||
argsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "STARTS WITH operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "name",
|
||||
Operator: OpStartsWith,
|
||||
Value: "John",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"full_name\" ILIKE $1",
|
||||
argsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "ENDS WITH operator",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "name",
|
||||
Operator: OpEndsWith,
|
||||
Value: "son",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
expected: "SELECT * FROM test_table WHERE \"full_name\" ILIKE $1",
|
||||
argsLen: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sql, args, err := builder.BuildQuery(tt.query)
|
||||
if err != nil {
|
||||
t.Errorf("BuildQuery() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if sql != tt.expected {
|
||||
t.Errorf("BuildQuery() sql = %v, expected %v", sql, tt.expected)
|
||||
}
|
||||
|
||||
if len(args) != tt.argsLen {
|
||||
t.Errorf("BuildQuery() args length = %v, expected %v", len(args), tt.argsLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_BuildCountQuery(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table").
|
||||
SetColumnMapping(map[string]string{
|
||||
"name": "full_name",
|
||||
}).
|
||||
SetAllowedColumns([]string{"id", "name", "age", "status"})
|
||||
|
||||
query := DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "status",
|
||||
Operator: OpEqual,
|
||||
Value: "active",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildCountQuery(query)
|
||||
if err != nil {
|
||||
t.Errorf("BuildCountQuery() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := "SELECT COUNT(*) FROM test_table WHERE \"status\" = $1"
|
||||
if sql != expected {
|
||||
t.Errorf("BuildCountQuery() sql = %v, expected %v", sql, expected)
|
||||
}
|
||||
|
||||
if len(args) != 1 {
|
||||
t.Errorf("BuildCountQuery() args length = %v, expected 1", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_WithSorting(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table").
|
||||
SetColumnMapping(map[string]string{
|
||||
"name": "full_name",
|
||||
}).
|
||||
SetAllowedColumns([]string{"id", "name", "age", "status"})
|
||||
|
||||
query := DynamicQuery{
|
||||
Sort: []SortField{
|
||||
{Column: "name", Order: "ASC"},
|
||||
{Column: "age", Order: "DESC"},
|
||||
},
|
||||
}
|
||||
|
||||
sql, _, err := builder.BuildQuery(query)
|
||||
if err != nil {
|
||||
t.Errorf("BuildQuery() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := "SELECT * FROM test_table ORDER BY \"full_name\" ASC, \"age\" DESC"
|
||||
if sql != expected {
|
||||
t.Errorf("BuildQuery() sql = %v, expected %v", sql, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_WithPagination(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table")
|
||||
|
||||
query := DynamicQuery{
|
||||
Limit: 10,
|
||||
Offset: 20,
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildQuery(query)
|
||||
if err != nil {
|
||||
t.Errorf("BuildQuery() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := "SELECT * FROM test_table LIMIT $1 OFFSET $2"
|
||||
if sql != expected {
|
||||
t.Errorf("BuildQuery() sql = %v, expected %v", sql, expected)
|
||||
}
|
||||
|
||||
if len(args) != 2 {
|
||||
t.Errorf("BuildQuery() args length = %v, expected 2", len(args))
|
||||
}
|
||||
|
||||
if args[0] != 10 || args[1] != 20 {
|
||||
t.Errorf("BuildQuery() args = %v, expected [10, 20]", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_WithFields(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table").
|
||||
SetAllowedColumns([]string{"id", "name", "age"})
|
||||
|
||||
query := DynamicQuery{
|
||||
Fields: []string{"id", "name"},
|
||||
}
|
||||
|
||||
sql, _, err := builder.BuildQuery(query)
|
||||
if err != nil {
|
||||
t.Errorf("BuildQuery() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := "SELECT \"id\", \"name\" FROM test_table"
|
||||
if sql != expected {
|
||||
t.Errorf("BuildQuery() sql = %v, expected %v", sql, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_SecurityChecks(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table").
|
||||
SetAllowedColumns([]string{"id", "name"})
|
||||
|
||||
query := DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "password", // Not in allowed columns
|
||||
Operator: OpEqual,
|
||||
Value: "secret",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildQuery(query)
|
||||
if err != nil {
|
||||
t.Errorf("BuildQuery() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Should not include the password filter
|
||||
expected := "SELECT * FROM test_table"
|
||||
if sql != expected {
|
||||
t.Errorf("BuildQuery() sql = %v, expected %v", sql, expected)
|
||||
}
|
||||
|
||||
if len(args) != 0 {
|
||||
t.Errorf("BuildQuery() args length = %v, expected 0", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_GroupByAndHaving(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table").
|
||||
SetAllowedColumns([]string{"status", "count"})
|
||||
|
||||
query := DynamicQuery{
|
||||
Fields: []string{"status", "COUNT(*) as count"},
|
||||
GroupBy: []string{"status"},
|
||||
Having: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "count",
|
||||
Operator: OpGreaterThan,
|
||||
Value: "5",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildQuery(query)
|
||||
if err != nil {
|
||||
t.Errorf("BuildQuery() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := "SELECT \"status\", COUNT(*) as count FROM test_table GROUP BY \"status\" HAVING \"count\" > $1"
|
||||
if sql != expected {
|
||||
t.Errorf("BuildQuery() sql = %v, expected %v", sql, expected)
|
||||
}
|
||||
|
||||
if len(args) != 1 {
|
||||
t.Errorf("BuildQuery() args length = %v, expected 1", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_ComplexQuery(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table").
|
||||
SetColumnMapping(map[string]string{
|
||||
"name": "full_name",
|
||||
}).
|
||||
SetAllowedColumns([]string{"id", "name", "age", "status", "created_at"})
|
||||
|
||||
query := DynamicQuery{
|
||||
Fields: []string{"id", "name", "age"},
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{
|
||||
{
|
||||
Column: "status",
|
||||
Operator: OpIn,
|
||||
Value: []string{"active", "pending"},
|
||||
},
|
||||
{
|
||||
Column: "age",
|
||||
Operator: OpBetween,
|
||||
Value: []interface{}{"18", "65"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
Sort: []SortField{
|
||||
{Column: "name", Order: "ASC"},
|
||||
{Column: "created_at", Order: "DESC"},
|
||||
},
|
||||
Limit: 20,
|
||||
Offset: 10,
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildQuery(query)
|
||||
if err != nil {
|
||||
t.Errorf("BuildQuery() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := "SELECT \"id\", \"full_name\", \"age\" FROM test_table WHERE \"status\" IN ($1, $2) AND \"age\" BETWEEN $3 AND $4 ORDER BY \"full_name\" ASC, \"created_at\" DESC LIMIT $5 OFFSET $6"
|
||||
if sql != expected {
|
||||
t.Errorf("BuildQuery() sql = %v, expected %v", sql, expected)
|
||||
}
|
||||
|
||||
if len(args) != 6 {
|
||||
t.Errorf("BuildQuery() args length = %v, expected 6", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_ErrorCases(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query DynamicQuery
|
||||
}{
|
||||
{
|
||||
name: "invalid between operator - not enough values",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "age",
|
||||
Operator: OpBetween,
|
||||
Value: []interface{}{"18"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid not between operator - not enough values",
|
||||
query: DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "age",
|
||||
Operator: OpNotBetween,
|
||||
Value: []interface{}{"18"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, err := builder.BuildQuery(tt.query)
|
||||
if err == nil {
|
||||
t.Errorf("BuildQuery() expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_UnsupportedOperator(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table")
|
||||
|
||||
query := DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{{
|
||||
Column: "name",
|
||||
Operator: FilterOperator("unsupported"),
|
||||
Value: "test",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
_, _, err := builder.BuildQuery(query)
|
||||
if err == nil {
|
||||
t.Errorf("BuildQuery() expected error for unsupported operator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_EmptyFilters(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table")
|
||||
|
||||
query := DynamicQuery{
|
||||
Filters: []FilterGroup{{
|
||||
Filters: []DynamicFilter{},
|
||||
}},
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildQuery(query)
|
||||
if err != nil {
|
||||
t.Errorf("BuildQuery() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := "SELECT * FROM test_table"
|
||||
if sql != expected {
|
||||
t.Errorf("BuildQuery() sql = %v, expected %v", sql, expected)
|
||||
}
|
||||
|
||||
if len(args) != 0 {
|
||||
t.Errorf("BuildQuery() args length = %v, expected 0", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBuilder_ParseArrayValue(t *testing.T) {
|
||||
builder := NewQueryBuilder("test_table")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected []interface{}
|
||||
}{
|
||||
{
|
||||
name: "nil value",
|
||||
input: nil,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "string value",
|
||||
input: "test",
|
||||
expected: []interface{}{"test"},
|
||||
},
|
||||
{
|
||||
name: "comma separated string",
|
||||
input: "a,b,c",
|
||||
expected: []interface{}{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "slice value",
|
||||
input: []interface{}{"a", "b", "c"},
|
||||
expected: []interface{}{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "integer value",
|
||||
input: 42,
|
||||
expected: []interface{}{42},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := builder.parseArrayValue(tt.input)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("parseArrayValue() length = %v, expected %v", len(result), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
for i, v := range result {
|
||||
if v != tt.expected[i] {
|
||||
t.Errorf("parseArrayValue()[%d] = %v, expected %v", i, v, tt.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,52 +6,57 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"api-service/internal/config"
|
||||
"api-service/internal/database"
|
||||
"api-service/internal/models"
|
||||
"api-service/internal/models/vclaim/peserta"
|
||||
"api-service/internal/services/bpjs"
|
||||
services "api-service/internal/services/bpjs"
|
||||
"api-service/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// PesertaHandler handles Peserta BPJS services
|
||||
type PesertaHandler struct {
|
||||
service services.VClaimService
|
||||
db database.Service
|
||||
validator *validator.Validate
|
||||
logger logger.Logger
|
||||
config config.BpjsConfig
|
||||
}
|
||||
|
||||
// PesertaHandlerConfig contains configuration for PesertaHandler
|
||||
type PesertaHandlerConfig struct {
|
||||
BpjsConfig config.BpjsConfig
|
||||
Logger logger.Logger
|
||||
Validator *validator.Validate
|
||||
Config *config.Config
|
||||
Logger logger.Logger
|
||||
Validator *validator.Validate
|
||||
}
|
||||
|
||||
// NewPesertaHandler creates a new PesertaHandler
|
||||
func NewPesertaHandler(cfg PesertaHandlerConfig) *PesertaHandler {
|
||||
return &PesertaHandler{
|
||||
service: services.NewService(cfg.BpjsConfig),
|
||||
db: database.New(cfg.Config),
|
||||
service: services.NewService(cfg.Config.Bpjs),
|
||||
validator: cfg.Validator,
|
||||
logger: cfg.Logger,
|
||||
config: cfg.BpjsConfig,
|
||||
config: cfg.Config.Bpjs,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GetBynik godoc
|
||||
// @Summary Get Bynik data
|
||||
// @Description Get participant eligibility information by NIK
|
||||
// @Tags Peserta
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Param X-Request-ID header string false "Request ID for tracking"
|
||||
// @Param nik path string true "nik" example("example_value")
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Param X-Request-ID header string false "Request ID for tracking"
|
||||
// @Param nik path string true "nik" example("example_value")
|
||||
// @Success 200 {object} peserta.PesertaResponse "Successfully retrieved Bynik data"
|
||||
// @Failure 400 {object} models.ErrorResponseBpjs "Bad request - invalid parameters"
|
||||
// @Failure 401 {object} models.ErrorResponseBpjs "Unauthorized - invalid API credentials"
|
||||
@@ -59,123 +64,140 @@ func NewPesertaHandler(cfg PesertaHandlerConfig) *PesertaHandler {
|
||||
// @Failure 500 {object} models.ErrorResponseBpjs "Internal server error"
|
||||
// @Router /Peserta/nik/:nik [get]
|
||||
func (h *PesertaHandler) GetBynik(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
// Generate request ID if not present
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.New().String()
|
||||
c.Header("X-Request-ID", requestID)
|
||||
}
|
||||
|
||||
// Generate request ID if not present
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.New().String()
|
||||
c.Header("X-Request-ID", requestID)
|
||||
}
|
||||
// Get database connection
|
||||
dbConn, err := h.db.GetDB("postgres_satudata")
|
||||
if err != nil {
|
||||
h.logger.Error("Database connection failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Database connection failed",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Note: dbConn is available for future database operations (e.g., caching, logging)
|
||||
_ = dbConn // Prevent unused variable warning
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
h.logger.Info("Processing GetBynik request", map[string]interface{}{
|
||||
"request_id": requestID,
|
||||
"endpoint": "/peserta/nik/:nik",
|
||||
|
||||
"nik": c.Param("nik"),
|
||||
|
||||
})
|
||||
|
||||
h.logger.Info("Processing GetBynik request", map[string]interface{}{
|
||||
"request_id": requestID,
|
||||
"endpoint": "/peserta/nik/:nik",
|
||||
|
||||
// Extract path parameters
|
||||
|
||||
nik := c.Param("nik")
|
||||
if nik == "" {
|
||||
|
||||
h.logger.Error("Missing required parameter nik", map[string]interface{}{
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Missing required parameter nik",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
"nik": c.Param("nik"),
|
||||
})
|
||||
|
||||
// Call service method
|
||||
var response peserta.PesertaResponse
|
||||
|
||||
endpoint := "/peserta/nik/:nik"
|
||||
|
||||
endpoint = strings.Replace(endpoint, ":nik", nik, 1)
|
||||
|
||||
resp, err := h.service.GetRawResponse(ctx, endpoint)
|
||||
|
||||
if err != nil {
|
||||
|
||||
h.logger.Error("Failed to get Bynik", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Internal server error",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Extract path parameters
|
||||
|
||||
// Map the raw response
|
||||
response.MetaData = resp.MetaData
|
||||
if resp.Response != nil {
|
||||
response.Data = &peserta.PesertaData{}
|
||||
if respStr, ok := resp.Response.(string); ok {
|
||||
// Decrypt the response string
|
||||
consID, secretKey, _, tstamp, _ := h.config.SetHeader()
|
||||
decryptedResp, err := services.ResponseVclaim(respStr, consID+secretKey+tstamp)
|
||||
if err != nil {
|
||||
|
||||
h.logger.Error("Failed to decrypt response", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
} else {
|
||||
json.Unmarshal([]byte(decryptedResp), response.Data)
|
||||
}
|
||||
} else if respMap, ok := resp.Response.(map[string]interface{}); ok {
|
||||
// Response is already unmarshaled JSON
|
||||
if dataMap, exists := respMap["peserta"]; exists {
|
||||
dataBytes, _ := json.Marshal(dataMap)
|
||||
json.Unmarshal(dataBytes, response.Data)
|
||||
} else {
|
||||
// Try to unmarshal the whole response
|
||||
respBytes, _ := json.Marshal(resp.Response)
|
||||
json.Unmarshal(respBytes, response.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
nik := c.Param("nik")
|
||||
if nik == "" {
|
||||
|
||||
// Ensure response has proper fields
|
||||
response.Status = "success"
|
||||
response.RequestID = requestID
|
||||
c.JSON(http.StatusOK, response)
|
||||
h.logger.Error("Missing required parameter nik", map[string]interface{}{
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Missing required parameter nik",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Call service method
|
||||
var response peserta.PesertaResponse
|
||||
|
||||
endpoint := "/peserta/nik/:nik"
|
||||
|
||||
endpoint = strings.Replace(endpoint, ":nik", nik, 1)
|
||||
|
||||
resp, err := h.service.GetRawResponse(ctx, endpoint)
|
||||
|
||||
if err != nil {
|
||||
// Check if error message contains 404 status code
|
||||
if strings.Contains(err.Error(), "HTTP error: 404") {
|
||||
h.logger.Error("Bynik not found", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Bynik not found",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to get Bynik", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Internal server error",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Map the raw response
|
||||
response.MetaData = resp.MetaData
|
||||
if resp.Response != nil {
|
||||
response.Data = &peserta.PesertaData{}
|
||||
if respStr, ok := resp.Response.(string); ok {
|
||||
// Decrypt the response string
|
||||
consID, secretKey, _, tstamp, _ := h.config.SetHeader()
|
||||
decryptedResp, err := services.ResponseVclaim(respStr, consID+secretKey+tstamp)
|
||||
if err != nil {
|
||||
|
||||
h.logger.Error("Failed to decrypt response", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
} else {
|
||||
json.Unmarshal([]byte(decryptedResp), response.Data)
|
||||
}
|
||||
} else if respMap, ok := resp.Response.(map[string]interface{}); ok {
|
||||
// Response is already unmarshaled JSON
|
||||
if dataMap, exists := respMap["peserta"]; exists {
|
||||
dataBytes, _ := json.Marshal(dataMap)
|
||||
json.Unmarshal(dataBytes, response.Data)
|
||||
} else {
|
||||
// Try to unmarshal the whole response
|
||||
respBytes, _ := json.Marshal(resp.Response)
|
||||
json.Unmarshal(respBytes, response.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure response has proper fields
|
||||
response.Status = "success"
|
||||
response.RequestID = requestID
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// GetBynokartu godoc
|
||||
// @Summary Get Bynokartu data
|
||||
// @Description Get participant eligibility information by card number
|
||||
// @Tags Peserta
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Param X-Request-ID header string false "Request ID for tracking"
|
||||
// @Param nokartu path string true "nokartu" example("example_value")
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Param X-Request-ID header string false "Request ID for tracking"
|
||||
// @Param nokartu path string true "nokartu" example("example_value")
|
||||
// @Success 200 {object} peserta.PesertaResponse "Successfully retrieved Bynokartu data"
|
||||
// @Failure 400 {object} models.ErrorResponseBpjs "Bad request - invalid parameters"
|
||||
// @Failure 401 {object} models.ErrorResponseBpjs "Unauthorized - invalid API credentials"
|
||||
@@ -183,109 +205,122 @@ func (h *PesertaHandler) GetBynik(c *gin.Context) {
|
||||
// @Failure 500 {object} models.ErrorResponseBpjs "Internal server error"
|
||||
// @Router /Peserta/nokartu/:nokartu [get]
|
||||
func (h *PesertaHandler) GetBynokartu(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Generate request ID if not present
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.New().String()
|
||||
c.Header("X-Request-ID", requestID)
|
||||
}
|
||||
// Generate request ID if not present
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.New().String()
|
||||
c.Header("X-Request-ID", requestID)
|
||||
}
|
||||
|
||||
|
||||
h.logger.Info("Processing GetBynokartu request", map[string]interface{}{
|
||||
"request_id": requestID,
|
||||
"endpoint": "/peserta/:nokartu",
|
||||
|
||||
"nokartu": c.Param("nokartu"),
|
||||
|
||||
})
|
||||
|
||||
h.logger.Info("Processing GetBynokartu request", map[string]interface{}{
|
||||
"request_id": requestID,
|
||||
"endpoint": "/peserta/:nokartu",
|
||||
|
||||
// Extract path parameters
|
||||
|
||||
nokartu := c.Param("nokartu")
|
||||
if nokartu == "" {
|
||||
|
||||
h.logger.Error("Missing required parameter nokartu", map[string]interface{}{
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Missing required parameter nokartu",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
"nokartu": c.Param("nokartu"),
|
||||
})
|
||||
|
||||
// Call service method
|
||||
var response peserta.PesertaResponse
|
||||
|
||||
endpoint := "/peserta/:nokartu"
|
||||
|
||||
endpoint = strings.Replace(endpoint, ":nokartu", nokartu, 1)
|
||||
|
||||
resp, err := h.service.GetRawResponse(ctx, endpoint)
|
||||
|
||||
if err != nil {
|
||||
|
||||
h.logger.Error("Failed to get Bynokartu", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Internal server error",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Extract path parameters
|
||||
|
||||
// Map the raw response
|
||||
response.MetaData = resp.MetaData
|
||||
if resp.Response != nil {
|
||||
response.Data = &peserta.PesertaData{}
|
||||
if respStr, ok := resp.Response.(string); ok {
|
||||
// Decrypt the response string
|
||||
consID, secretKey, _, tstamp, _ := h.config.SetHeader()
|
||||
decryptedResp, err := services.ResponseVclaim(respStr, consID+secretKey+tstamp)
|
||||
if err != nil {
|
||||
|
||||
h.logger.Error("Failed to decrypt response", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
} else {
|
||||
json.Unmarshal([]byte(decryptedResp), response.Data)
|
||||
}
|
||||
} else if respMap, ok := resp.Response.(map[string]interface{}); ok {
|
||||
// Response is already unmarshaled JSON
|
||||
if dataMap, exists := respMap["peserta"]; exists {
|
||||
dataBytes, _ := json.Marshal(dataMap)
|
||||
json.Unmarshal(dataBytes, response.Data)
|
||||
} else {
|
||||
// Try to unmarshal the whole response
|
||||
respBytes, _ := json.Marshal(resp.Response)
|
||||
json.Unmarshal(respBytes, response.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
nokartu := c.Param("nokartu")
|
||||
if nokartu == "" {
|
||||
|
||||
// Ensure response has proper fields
|
||||
response.Status = "success"
|
||||
response.RequestID = requestID
|
||||
c.JSON(http.StatusOK, response)
|
||||
h.logger.Error("Missing required parameter nokartu", map[string]interface{}{
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Missing required parameter nokartu",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Call service method
|
||||
var response peserta.PesertaResponse
|
||||
|
||||
endpoint := "/peserta/:nokartu"
|
||||
|
||||
endpoint = strings.Replace(endpoint, ":nokartu", nokartu, 1)
|
||||
|
||||
resp, err := h.service.GetRawResponse(ctx, endpoint)
|
||||
|
||||
if err != nil {
|
||||
|
||||
h.logger.Error("Failed to get Bynokartu", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: "Internal server error",
|
||||
RequestID: requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Map the raw response
|
||||
response.MetaData = resp.MetaData
|
||||
if resp.Response != nil {
|
||||
response.Data = &peserta.PesertaData{}
|
||||
if respStr, ok := resp.Response.(string); ok {
|
||||
// Decrypt the response string
|
||||
consID, secretKey, _, tstamp, _ := h.config.SetHeader()
|
||||
decryptedResp, err := services.ResponseVclaim(respStr, consID+secretKey+tstamp)
|
||||
if err != nil {
|
||||
|
||||
h.logger.Error("Failed to decrypt response", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
} else {
|
||||
json.Unmarshal([]byte(decryptedResp), response.Data)
|
||||
}
|
||||
} else if respMap, ok := resp.Response.(map[string]interface{}); ok {
|
||||
// Response is already unmarshaled JSON
|
||||
if dataMap, exists := respMap["peserta"]; exists {
|
||||
dataBytes, _ := json.Marshal(dataMap)
|
||||
json.Unmarshal(dataBytes, response.Data)
|
||||
} else {
|
||||
// Try to unmarshal the whole response
|
||||
respBytes, _ := json.Marshal(resp.Response)
|
||||
json.Unmarshal(respBytes, response.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure response has proper fields
|
||||
response.Status = "success"
|
||||
response.RequestID = requestID
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Enhanced error handling
|
||||
func (h *PesertaHandler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) {
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
h.logger.Error(message, map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"status_code": statusCode,
|
||||
"request_id": requestID,
|
||||
})
|
||||
h.respondError(c, message, err, statusCode)
|
||||
}
|
||||
|
||||
func (h *PesertaHandler) respondError(c *gin.Context, message string, err error, statusCode int) {
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
errorMessage := message
|
||||
if gin.Mode() == gin.ReleaseMode {
|
||||
errorMessage = "Internal server error"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
c.JSON(statusCode, models.ErrorResponseBpjs{
|
||||
Status: "error",
|
||||
Message: errorMessage,
|
||||
RequestID: requestID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,7 +282,33 @@ func (h *RetribusiHandler) GetRetribusiDynamic(c *gin.Context) {
|
||||
// fetchRetribusisDynamic executes dynamic query
|
||||
func (h *RetribusiHandler) fetchRetribusisDynamic(ctx context.Context, dbConn *sql.DB, query utils.DynamicQuery) ([]retribusi.Retribusi, int, error) {
|
||||
// Setup query builder
|
||||
builder := utils.NewQueryBuilder("data_retribusi").
|
||||
countBuilder := utils.NewQueryBuilder("data_retribusi").
|
||||
SetColumnMapping(map[string]string{
|
||||
"jenis": "Jenis",
|
||||
"pelayanan": "Pelayanan",
|
||||
"dinas": "Dinas",
|
||||
"kelompok_obyek": "Kelompok_obyek",
|
||||
"Kode_tarif": "Kode_tarif",
|
||||
"kode_tarif": "Kode_tarif",
|
||||
"tarif": "Tarif",
|
||||
"satuan": "Satuan",
|
||||
"tarif_overtime": "Tarif_overtime",
|
||||
"satuan_overtime": "Satuan_overtime",
|
||||
"rekening_pokok": "Rekening_pokok",
|
||||
"rekening_denda": "Rekening_denda",
|
||||
"uraian_1": "Uraian_1",
|
||||
"uraian_2": "Uraian_2",
|
||||
"uraian_3": "Uraian_3",
|
||||
}).
|
||||
SetAllowedColumns([]string{
|
||||
"id", "status", "sort", "user_created", "date_created",
|
||||
"user_updated", "date_updated", "Jenis", "Pelayanan",
|
||||
"Dinas", "Kelompok_obyek", "Kode_tarif", "Tarif", "Satuan",
|
||||
"Tarif_overtime", "Satuan_overtime", "Rekening_pokok",
|
||||
"Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3",
|
||||
})
|
||||
|
||||
mainBuilder := utils.NewQueryBuilder("data_retribusi").
|
||||
SetColumnMapping(map[string]string{
|
||||
"jenis": "Jenis",
|
||||
"pelayanan": "Pelayanan",
|
||||
@@ -310,7 +336,6 @@ func (h *RetribusiHandler) fetchRetribusisDynamic(ctx context.Context, dbConn *s
|
||||
|
||||
// Add default filter to exclude deleted records
|
||||
if len(query.Filters) > 0 {
|
||||
// If there are existing filters, add the default filter as the first group
|
||||
query.Filters = append([]utils.FilterGroup{{
|
||||
Filters: []utils.DynamicFilter{{
|
||||
Column: "status",
|
||||
@@ -320,7 +345,6 @@ func (h *RetribusiHandler) fetchRetribusisDynamic(ctx context.Context, dbConn *s
|
||||
LogicOp: "AND",
|
||||
}}, query.Filters...)
|
||||
} else {
|
||||
// If no existing filters, just add the default filter
|
||||
query.Filters = []utils.FilterGroup{{
|
||||
Filters: []utils.DynamicFilter{{
|
||||
Column: "status",
|
||||
@@ -331,82 +355,46 @@ func (h *RetribusiHandler) fetchRetribusisDynamic(ctx context.Context, dbConn *s
|
||||
}}
|
||||
}
|
||||
|
||||
// Execute concurrent queries
|
||||
var (
|
||||
retribusis []retribusi.Retribusi
|
||||
total int
|
||||
wg sync.WaitGroup
|
||||
errChan = make(chan error, 2)
|
||||
mu sync.Mutex
|
||||
)
|
||||
// Execute queries sequentially to avoid race conditions
|
||||
var total int
|
||||
var retribusis []retribusi.Retribusi
|
||||
|
||||
// Fetch total count
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
countQuery := query
|
||||
countQuery.Limit = 0
|
||||
countQuery.Offset = 0
|
||||
// 1. Get total count first
|
||||
countQuery := query
|
||||
countQuery.Limit = 0
|
||||
countQuery.Offset = 0
|
||||
|
||||
countSQL, countArgs, err := builder.BuildCountQuery(countQuery)
|
||||
countSQL, countArgs, err := countBuilder.BuildCountQuery(countQuery)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to build count query: %w", err)
|
||||
}
|
||||
|
||||
if err := dbConn.QueryRowContext(ctx, countSQL, countArgs...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
|
||||
}
|
||||
|
||||
// 2. Get main data
|
||||
mainSQL, mainArgs, err := mainBuilder.BuildQuery(query)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to build main query: %w", err)
|
||||
}
|
||||
|
||||
rows, err := dbConn.QueryContext(ctx, mainSQL, mainArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to execute main query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
retribusi, err := h.scanRetribusi(rows)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to build count query: %w", err)
|
||||
return
|
||||
return nil, 0, fmt.Errorf("failed to scan retribusi: %w", err)
|
||||
}
|
||||
retribusis = append(retribusis, retribusi)
|
||||
}
|
||||
|
||||
if err := dbConn.QueryRowContext(ctx, countSQL, countArgs...).Scan(&total); err != nil {
|
||||
errChan <- fmt.Errorf("failed to get total count: %w", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch main data
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
mainSQL, mainArgs, err := builder.BuildQuery(query)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to build main query: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := dbConn.QueryContext(ctx, mainSQL, mainArgs...)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to execute main query: %w", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []retribusi.Retribusi
|
||||
for rows.Next() {
|
||||
retribusi, err := h.scanRetribusi(rows)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to scan retribusi: %w", err)
|
||||
return
|
||||
}
|
||||
results = append(results, retribusi)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
errChan <- fmt.Errorf("rows iteration error: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
retribusis = results
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
// Wait for all goroutines
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
|
||||
// Check for errors
|
||||
for err := range errChan {
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return retribusis, total, nil
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
healthcheckHandlers "api-service/internal/handlers/healthcheck"
|
||||
pesertaHandlers "api-service/internal/handlers/peserta"
|
||||
retribusiHandlers "api-service/internal/handlers/retribusi"
|
||||
rujukanHandlers "api-service/internal/handlers/rujukan"
|
||||
"api-service/internal/middleware"
|
||||
services "api-service/internal/services/auth"
|
||||
"api-service/pkg/logger"
|
||||
@@ -83,37 +82,37 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine {
|
||||
|
||||
// Participant eligibility information (peserta) routes
|
||||
pesertaHandler := pesertaHandlers.NewPesertaHandler(pesertaHandlers.PesertaHandlerConfig{
|
||||
BpjsConfig: cfg.Bpjs,
|
||||
Logger: *logger.Default(),
|
||||
Validator: validator.New(),
|
||||
Config: cfg,
|
||||
Logger: *logger.Default(),
|
||||
Validator: validator.New(),
|
||||
})
|
||||
pesertaGroup := v1.Group("/peserta")
|
||||
pesertaGroup.GET("/Peserta/nokartu/:nokartu", pesertaHandler.GetBynokartu)
|
||||
pesertaGroup.GET("/Peserta/nik/:nik", pesertaHandler.GetBynik)
|
||||
pesertaGroup := v1.Group("/Peserta")
|
||||
pesertaGroup.GET("/nokartu/:nokartu", pesertaHandler.GetBynokartu)
|
||||
pesertaGroup.GET("/nik/:nik", pesertaHandler.GetBynik)
|
||||
|
||||
// Rujukan management endpoints (rujukan) routes
|
||||
rujukanHandler := rujukanHandlers.NewRujukanHandler(rujukanHandlers.RujukanHandlerConfig{
|
||||
BpjsConfig: cfg.Bpjs,
|
||||
Logger: *logger.Default(),
|
||||
Validator: validator.New(),
|
||||
})
|
||||
rujukanGroup := v1.Group("/rujukan")
|
||||
rujukanGroup.POST("/Rujukan/:norujukan", rujukanHandler.CreateRujukan)
|
||||
rujukanGroup.PUT("/Rujukan/:norujukan", rujukanHandler.UpdateRujukan)
|
||||
rujukanGroup.DELETE("/Rujukan/:norujukan", rujukanHandler.DeleteRujukan)
|
||||
rujukanGroup.POST("/Rujukanbalik/:norujukan", rujukanHandler.CreateRujukanbalik)
|
||||
rujukanGroup.PUT("/Rujukanbalik/:norujukan", rujukanHandler.UpdateRujukanbalik)
|
||||
rujukanGroup.DELETE("/Rujukanbalik/:norujukan", rujukanHandler.DeleteRujukanbalik)
|
||||
// // Rujukan management endpoints (rujukan) routes
|
||||
// rujukanHandler := rujukanHandlers.NewRujukanHandler(rujukanHandlers.RujukanHandlerConfig{
|
||||
// BpjsConfig: cfg.Bpjs,
|
||||
// Logger: *logger.Default(),
|
||||
// Validator: validator.New(),
|
||||
// })
|
||||
// rujukanGroup := v1.Group("/rujukan")
|
||||
// rujukanGroup.POST("/Rujukan/:norujukan", rujukanHandler.CreateRujukan)
|
||||
// rujukanGroup.PUT("/Rujukan/:norujukan", rujukanHandler.UpdateRujukan)
|
||||
// rujukanGroup.DELETE("/Rujukan/:norujukan", rujukanHandler.DeleteRujukan)
|
||||
// rujukanGroup.POST("/Rujukanbalik/:norujukan", rujukanHandler.CreateRujukanbalik)
|
||||
// rujukanGroup.PUT("/Rujukanbalik/:norujukan", rujukanHandler.UpdateRujukanbalik)
|
||||
// rujukanGroup.DELETE("/Rujukanbalik/:norujukan", rujukanHandler.DeleteRujukanbalik)
|
||||
|
||||
// Search for rujukan endpoints (search) routes
|
||||
searchHandler := rujukanHandlers.NewSearchHandler(rujukanHandlers.SearchHandlerConfig{
|
||||
BpjsConfig: cfg.Bpjs,
|
||||
Logger: *logger.Default(),
|
||||
Validator: validator.New(),
|
||||
})
|
||||
searchGroup := v1.Group("/search")
|
||||
searchGroup.GET("/bynorujukan/:norujukan", searchHandler.GetBynorujukan)
|
||||
searchGroup.GET("/bynokartu/:nokartu", searchHandler.GetBynokartu)
|
||||
// // Search for rujukan endpoints (search) routes
|
||||
// searchHandler := rujukanHandlers.NewSearchHandler(rujukanHandlers.SearchHandlerConfig{
|
||||
// BpjsConfig: cfg.Bpjs,
|
||||
// Logger: *logger.Default(),
|
||||
// Validator: validator.New(),
|
||||
// })
|
||||
// searchGroup := v1.Group("/search")
|
||||
// searchGroup.GET("/bynorujukan/:norujukan", searchHandler.GetBynorujukan)
|
||||
// searchGroup.GET("/bynokartu/:nokartu", searchHandler.GetBynokartu)
|
||||
|
||||
// // Retribusi endpoints
|
||||
retribusiHandler := retribusiHandlers.NewRetribusiHandler()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FilterOperator represents supported filter operators
|
||||
@@ -67,6 +68,7 @@ type QueryBuilder struct {
|
||||
columnMapping map[string]string // Maps API field names to DB column names
|
||||
allowedColumns map[string]bool // Security: only allow specified columns
|
||||
paramCounter int
|
||||
mu *sync.RWMutex
|
||||
}
|
||||
|
||||
// NewQueryBuilder creates a new query builder instance
|
||||
@@ -174,16 +176,23 @@ func (qb *QueryBuilder) buildSelectClause(fields []string) string {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's an expression (contains spaces, parentheses, etc.)
|
||||
if strings.Contains(field, " ") || strings.Contains(field, "(") || strings.Contains(field, ")") {
|
||||
// Expression, add as is
|
||||
selectedFields = append(selectedFields, field)
|
||||
continue
|
||||
}
|
||||
|
||||
// Security check: only allow specified columns (check original field name)
|
||||
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[field] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Map field name if mapping exists
|
||||
if mappedCol, exists := qb.columnMapping[field]; exists {
|
||||
field = mappedCol
|
||||
}
|
||||
|
||||
// Security check: only allow specified columns
|
||||
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[field] {
|
||||
continue
|
||||
}
|
||||
|
||||
selectedFields = append(selectedFields, fmt.Sprintf(`"%s"`, field))
|
||||
}
|
||||
|
||||
@@ -218,7 +227,7 @@ func (qb *QueryBuilder) buildWhereClause(filterGroups []FilterGroup) (string, []
|
||||
conditions = append(conditions, logicOp)
|
||||
}
|
||||
|
||||
conditions = append(conditions, "("+groupCondition+")")
|
||||
conditions = append(conditions, groupCondition)
|
||||
args = append(args, groupArgs...)
|
||||
}
|
||||
}
|
||||
@@ -262,34 +271,46 @@ func (qb *QueryBuilder) buildFilterGroup(group FilterGroup) (string, []interface
|
||||
|
||||
// buildFilterCondition builds a single filter condition
|
||||
func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []interface{}, error) {
|
||||
// Security check (check original field name)
|
||||
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[filter.Column] {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// Map column name if mapping exists
|
||||
column := filter.Column
|
||||
if mappedCol, exists := qb.columnMapping[column]; exists {
|
||||
column = mappedCol
|
||||
}
|
||||
|
||||
// Security check
|
||||
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] {
|
||||
return "", nil, fmt.Errorf("column '%s' is not allowed", filter.Column)
|
||||
}
|
||||
|
||||
// Wrap column name in quotes for PostgreSQL
|
||||
column = fmt.Sprintf(`"%s"`, column)
|
||||
|
||||
switch filter.Operator {
|
||||
case OpEqual:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s = $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpNotEqual:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s != $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpLike:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s LIKE $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpILike:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
@@ -326,22 +347,37 @@ func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []in
|
||||
return fmt.Sprintf("%s NOT IN (%s)", column, strings.Join(placeholders, ", ")), args, nil
|
||||
|
||||
case OpGreaterThan:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s > $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpGreaterThanEqual:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s >= $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpLessThan:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s < $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpLessThanEqual:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
return fmt.Sprintf("%s <= $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil
|
||||
|
||||
case OpBetween:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
values := qb.parseArrayValue(filter.Value)
|
||||
if len(values) != 2 {
|
||||
return "", nil, fmt.Errorf("between operator requires exactly 2 values")
|
||||
@@ -353,6 +389,9 @@ func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []in
|
||||
return fmt.Sprintf("%s BETWEEN $%d AND $%d", column, param1, param2), []interface{}{values[0], values[1]}, nil
|
||||
|
||||
case OpNotBetween:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
values := qb.parseArrayValue(filter.Value)
|
||||
if len(values) != 2 {
|
||||
return "", nil, fmt.Errorf("not between operator requires exactly 2 values")
|
||||
@@ -370,21 +409,33 @@ func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []in
|
||||
return fmt.Sprintf("%s IS NOT NULL", column), nil, nil
|
||||
|
||||
case OpContains:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
value := fmt.Sprintf("%%%v%%", filter.Value)
|
||||
return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil
|
||||
|
||||
case OpNotContains:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
value := fmt.Sprintf("%%%v%%", filter.Value)
|
||||
return fmt.Sprintf("%s NOT ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil
|
||||
|
||||
case OpStartsWith:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
value := fmt.Sprintf("%v%%", filter.Value)
|
||||
return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil
|
||||
|
||||
case OpEndsWith:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
qb.paramCounter++
|
||||
value := fmt.Sprintf("%%%v", filter.Value)
|
||||
return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil
|
||||
@@ -435,15 +486,16 @@ func (qb *QueryBuilder) buildOrderClause(sortFields []SortField) string {
|
||||
var orderParts []string
|
||||
for _, sort := range sortFields {
|
||||
column := sort.Column
|
||||
if mappedCol, exists := qb.columnMapping[column]; exists {
|
||||
column = mappedCol
|
||||
}
|
||||
|
||||
// Security check
|
||||
// Security check (check original field name)
|
||||
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] {
|
||||
continue
|
||||
}
|
||||
|
||||
if mappedCol, exists := qb.columnMapping[column]; exists {
|
||||
column = mappedCol
|
||||
}
|
||||
|
||||
order := "ASC"
|
||||
if sort.Order != "" {
|
||||
order = strings.ToUpper(sort.Order)
|
||||
|
||||
Reference in New Issue
Block a user