diff --git a/README.md b/README.md index 305a6b7d..30920272 100644 --- a/README.md +++ b/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 -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 +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 " ``` -### 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 ` +**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 ` +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 ` + +**โŒ 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** diff --git a/api-service b/api-service index 5a73de34..6a4f638b 100644 Binary files a/api-service and b/api-service differ diff --git a/dynamic_filter_test.go b/dynamic_filter_test.go new file mode 100644 index 00000000..d229711d --- /dev/null +++ b/dynamic_filter_test.go @@ -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]) + } + } + }) + } +} diff --git a/internal/handlers/peserta/peserta.go b/internal/handlers/peserta/peserta.go index 8d6652f2..14c17016 100644 --- a/internal/handlers/peserta/peserta.go +++ b/internal/handlers/peserta/peserta.go @@ -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, + }) +} diff --git a/internal/handlers/retribusi/retribusi.go b/internal/handlers/retribusi/retribusi.go index b5af0a98..b5e9a943 100644 --- a/internal/handlers/retribusi/retribusi.go +++ b/internal/handlers/retribusi/retribusi.go @@ -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 diff --git a/internal/routes/v1/routes.go b/internal/routes/v1/routes.go index 3f4b3e11..5c8aceb4 100644 --- a/internal/routes/v1/routes.go +++ b/internal/routes/v1/routes.go @@ -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() diff --git a/internal/utils/filters/dynamic_filter.go b/internal/utils/filters/dynamic_filter.go index e012cde3..d735ce24 100644 --- a/internal/utils/filters/dynamic_filter.go +++ b/internal/utils/filters/dynamic_filter.go @@ -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)