first commit

This commit is contained in:
2025-09-24 18:42:16 +07:00
commit daffbc67dc
72 changed files with 40710 additions and 0 deletions

46
.air.toml Normal file
View File

@@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = ".\\main.exe"
cmd = "make build"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with "go test -c"
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
tmp/
# IDE specific files
.vscode
.idea
# .env file
.env
# Project build
main
*templ.go
# OS X generated file
.DS_Store

42
.goreleaser.yml Normal file
View File

@@ -0,0 +1,42 @@
version: 2
before:
hooks:
- go mod tidy
env:
- PACKAGE_PATH=github.com/<user>/<repo>/cmd
builds:
- binary: "{{ .ProjectName }}"
main: ./cmd/api
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X {{.Env.PACKAGE_PATH}}={{.Version}}
release:
prerelease: auto
universal_binaries:
- replace: true
archives:
- name_template: >
{{- .ProjectName }}_{{- .Version }}_{{- title .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "386" }}i386{{- else }}{{ .Arch }}{{ end }}{{- if .Arm }}v{{ .Arm }}{{ end -}}
format_overrides:
- goos: windows
format: zip
builds_info:
group: root
owner: root
files:
- README.md
checksum:
name_template: 'checksums.txt'

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM golang:1.24.4-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o main cmd/api/main.go
FROM alpine:3.20.1 AS prod
WORKDIR /app
COPY --from=build /app/main /app/main
COPY --from=build /app/.env /app/.env
EXPOSE 8080
CMD ["./main"]

49
Makefile Normal file
View File

@@ -0,0 +1,49 @@
# Simple Makefile for a Go project
# Build the application
all: build test
build:
@echo "Building..."
@go build -o main.exe cmd/api/main.go
# Run the application
run:
@go run cmd/api/main.go
# Create DB container
docker-run:
@docker compose up --build
# Shutdown DB container
docker-down:
@docker compose down
# Test the application
test:
@echo "Testing..."
@go test ./... -v
# Integrations Tests for the application
itest:
@echo "Running integration tests..."
@go test ./internal/database -v
# Clean the binary
clean:
@echo "Cleaning..."
@rm -f main
# Live Reload
watch:
@powershell -ExecutionPolicy Bypass -Command "if (Get-Command air -ErrorAction SilentlyContinue) { \
air; \
Write-Output 'Watching...'; \
} else { \
Write-Output 'Installing air...'; \
go install github.com/air-verse/air@latest; \
air; \
Write-Output 'Watching...'; \
}"
.PHONY: all build run test clean watch docker-run docker-down itest

689
README.md Normal file
View File

@@ -0,0 +1,689 @@
# 🚀 WebSocket API Service - Real-Time Communication
> **Modern WebSocket API service with advanced real-time communication, client management, and broadcasting capabilities**
## 📑 Daftar Isi
- [✨ 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
### Real-Time Communication
- **🔄 WebSocket Server** - High-performance WebSocket server dengan auto-reconnect
- **🏠 Room Management** - Multi-room support untuk isolated communication
- **👥 Client Tracking** - Advanced client identification (IP-based, static ID, generated)
- **📡 Real-time Broadcasting** - Server-initiated broadcasts ke semua atau specific clients
- **💬 Direct Messaging** - Peer-to-peer messaging antar clients
- **🔄 Database Notifications** - PostgreSQL LISTEN/NOTIFY integration
### Advanced Features
- **📊 Connection Monitoring** - Real-time statistics dan health monitoring
- **🧹 Auto Cleanup** - Automatic cleanup untuk inactive connections
- **🔒 Secure Authentication** - JWT-based authentication untuk WebSocket connections
- **📈 Performance Metrics** - Built-in performance tracking dan analytics
- **🎯 Message Queue** - High-throughput message processing dengan worker pools
- **🔍 Activity Logging** - Comprehensive activity logging untuk debugging
### Developer Experience
- **🔥 Hot Reload** - Development dengan auto-restart
- **🐳 Docker Ready** - Easy deployment dengan Docker
- **⚡ Code Generator** - Buat handler dan model otomatis
- **🧪 Testing Suite** - Unit dan integration tests
- **📊 Health Monitoring** - Monitoring kesehatan aplikasi
***
## 🏗️ Arsitektur WebSocket
### WebSocket Architecture Layers
```
┌─────────────────────────────────────┐
│ WebSocket Layer │ ← websocket.go, broadcast.go
├─────────────────────────────────────┤
│ Application Layer │ ← handlers/, middleware/
├─────────────────────────────────────┤
│ Domain Layer │ ← models/, services/
├─────────────────────────────────────┤
│ Infrastructure Layer │ ← database/, external APIs
└─────────────────────────────────────┘
```
### Core Components
```
api-service/
├── 📁 cmd/api/ # 🚪 WebSocket server entry point
├── 📁 internal/ # 🏠 Core WebSocket logic
│ ├── handlers/websocket/ # 🔄 WebSocket handlers & hub
│ ├── middleware/ # 🛡️ Auth & validation middleware
│ ├── models/ # 📊 Data structures & validation
│ ├── routes/ # 🛣️ API routing
│ ├── services/ # 💼 Business logic services
│ └── database/ # 💾 Database connections
├── 📁 examples/clientsocket/ # 🌐 Vue.js WebSocket client
├── 📁 docs/ # 📚 Swagger documentation
└── 📁 configs/ # ⚙️ Configuration files
```
### WebSocket Hub Architecture
```
┌─────────────────────────────────────┐
│ Hub │ ← Central WebSocket manager
├─────────────────────────────────────┤
│ • Client Management │ ← Register/unregister clients
│ • Message Broadcasting │ ← Broadcast to clients/rooms
│ • Room Management │ ← Multi-room support
│ • Connection Monitoring │ ← Track active connections
│ • Database Notifications │ ← PostgreSQL LISTEN/NOTIFY
└─────────────────────────────────────┘
```
***
## ⚡ 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
# Start server
go run cmd/api/main.go
```
### 3⃣ Setup WebSocket Client
**Start Vue.js Client Example:**
```bash
cd examples/clientsocket
# Install dependencies
npm install
# Start development server
npm run dev
```
### 4⃣ Verify Installation
| Service | URL | Status |
| :-- | :-- | :-- |
| **WebSocket API** | ws://localhost:8080/api/v1/ws | ✅ |
| **WebSocket Client** | http://localhost:3000 | 🌐 |
| **API Documentation** | http://localhost:8080/swagger/index.html | 📖 |
| **Health Check** | http://localhost:8080/api/sistem/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"
}'
```
**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 Accounts
| Username | Password | Role | Akses |
| :-- | :-- | :-- | :-- |
| `admin` | `password` | Admin | Semua endpoint |
| `user` | `password` | User | Read-only |
***
## 📊 API Endpoints
### 🔄 WebSocket Endpoints
#### Main WebSocket Connection
| Protocol | Endpoint | Deskripsi |
| :-- | :-- | :-- |
| `WS` | `ws://localhost:8080/api/v1/ws` | WebSocket connection utama |
**Connection Parameters:**
```javascript
// Basic connection
const ws = new WebSocket('ws://localhost:8080/api/v1/ws');
```
#### WebSocket Management API
| Method | Endpoint | Deskripsi |
| :-- | :-- | :-- |
| `GET` | `/api/websocket/stats` | WebSocket connection statistics |
| `GET` | `/api/websocket/clients` | List all connected clients |
| `GET` | `/api/websocket/rooms` | List all active rooms |
| `POST` | `/api/websocket/broadcast` | Server-initiated broadcast |
| `POST` | `/api/websocket/broadcast/room/:room` | Broadcast to specific room |
| `POST` | `/api/websocket/send/:clientId` | Send message to specific client |
| `POST` | `/api/websocket/cleanup/inactive` | Cleanup inactive clients |
### 🌍 Public REST Endpoints
| Method | Endpoint | Deskripsi |
| :-- | :-- | :-- |
| `POST` | `/api/v1/auth/login` | Login pengguna |
| `POST` | `/api/v1/auth/register` | Registrasi pengguna baru |
| `GET` | `/api/sistem/health` | Status kesehatan API |
| `GET` | `/api/sistem/info` | System information |
### 🔒 Protected REST Endpoints
#### User Management
| Method | Endpoint | Deskripsi |
| :-- | :-- | :-- |
| `GET` | `/api/v1/auth/me` | Profile pengguna |
| `PUT` | `/api/v1/auth/me` | Update profile |
#### Retribusi Management
| Method | Endpoint | Deskripsi |
| :-- | :-- | :-- |
| `GET` | `/api/v1/retribusi` | List semua retribusi |
| `GET` | `/api/v1/retribusi/dynamic` | Query dengan filter dinamis |
| `GET` | `/api/v1/retribusi/search` | Search retribusi advanced |
| `GET` | `/api/v1/retribusi/id/:id` | Detail retribusi by ID |
| `POST` | `/api/v1/retribusi` | Buat retribusi baru |
| `PUT` | `/api/v1/retribusi/id/:id` | Update retribusi |
| `DELETE` | `/api/v1/retribusi/id/:id` | Hapus retribusi |
***
## 🛠️ Development
### Code Generation (30 detik)
**🎯 Generate CRUD Lengkap**
```bash
# 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
```
**🏥 Generate BPJS Handler**
```bash
# Single service
go run tools/bpjs/generate-bpjs-handler.go tools/bpjs/reference/peserta get
# Semua service dari config
go run tools/bpjs/generate-handler.go tools/bpjs/services-config-bpjs.yaml
```
**🩺 Generate SATUSEHAT Handler**
```bash
go run tools/satusehat/generate-satusehat-handler.go tools/satusehat/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
```
***
## 🚀 Deployment
### 🐳 Docker Deployment
**Development:**
```bash
# Start semua services
make docker-run
# Stop services
make docker-down
# Rebuild dan restart
make docker-rebuild
```
**Production:**
```bash
# Build production image
docker build -t api-service:prod .
# Run production container
docker run -d \
--name api-service \
-p 8080:8080 \
--env-file .env.prod \
api-service:prod
```
### 🔧 Manual Deployment
```bash
# Build aplikasi
make build
# Run migrations
./scripts/migrate.sh up
# Start server
./bin/api-service
```
***
## 📚 Dokumentasi
### 📖 Interactive API Documentation
Kunjungi **Swagger UI** di: http://localhost:8080/swagger/index.html
**Cara menggunakan:**
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
### 🔄 WebSocket Client Examples
#### JavaScript WebSocket Client
**Basic Connection:**
```javascript
// Create WebSocket connection
const ws = new WebSocket('ws://localhost:8080/api/v1/ws');
// Connection opened
ws.onopen = function(event) {
console.log('Connected to WebSocket server');
};
// Listen for messages
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log('Message received:', data);
};
// Send a message
function sendMessage(message) {
ws.send(JSON.stringify({
type: 'broadcast',
message: message
}));
}
// Handle connection close
ws.onclose = function(event) {
console.log('Disconnected from WebSocket server');
};
```
#### Vue.js Component Example
```vue
<template>
<div class="websocket-chat">
<div class="messages" ref="messages">
<div v-for="message in messages" :key="message.id" class="message">
<strong>{{ message.client_id }}:</strong> {{ message.content }}
</div>
</div>
<div class="input-area">
<input
v-model="newMessage"
@keyup.enter="sendMessage"
placeholder="Type a message..."
>
<button @click="sendMessage">Send</button>
</div>
</div>
</template>
<script>
export default {
name: 'WebSocketChat',
data() {
return {
ws: null,
messages: [],
newMessage: ''
};
},
mounted() {
this.connectWebSocket();
},
beforeUnmount() {
if (this.ws) {
this.ws.close();
}
},
methods: {
connectWebSocket() {
this.ws = new WebSocket('ws://localhost:8080/api/v1/ws');
this.ws.onopen = () => {
console.log('WebSocket connected');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
// Auto-reconnect after 5 seconds
setTimeout(() => this.connectWebSocket(), 5000);
};
},
sendMessage() {
if (this.newMessage.trim() && this.ws) {
this.ws.send(JSON.stringify({
type: 'broadcast',
message: this.newMessage
}));
this.newMessage = '';
}
},
handleMessage(data) {
this.messages.push({
id: Date.now(),
client_id: data.client_id || 'server',
content: data.message
});
// Auto-scroll to bottom
this.$nextTick(() => {
this.$refs.messages.scrollTop = this.$refs.messages.scrollHeight;
});
}
}
};
</script>
```
### 🧪 Testing Examples
**WebSocket Testing with wscat:**
```bash
# Install wscat globally
npm install -g wscat
# Connect to WebSocket server
wscat -c ws://localhost:8080/api/v1/ws
# Send a message
{"type": "broadcast", "message": "Hello from wscat!"}
```
**cURL for REST endpoints:**
```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')
# Get WebSocket statistics
curl http://localhost:8080/api/websocket/stats
# Broadcast message to all clients
curl -X POST http://localhost:8080/api/websocket/broadcast \
-H "Content-Type: application/json" \
-d '{"type": "broadcast", "message": "Server broadcast message"}'
# Get system information
curl http://localhost:8080/api/sistem/info
# Health check
curl http://localhost:8080/api/sistem/health
```
**Response Examples:**
```json
{
"connected_clients": 5,
"databases": ["default"],
"database_health": "connected",
"timestamp": 1694325600
}
```
***
## 🚨 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
### 📋 WebSocket Development Roadmap
- [x]**Setup environment selesai**
- [x]**Implementasi WebSocket server**
- [x]**Setup Vue.js client example**
- [x]**Test WebSocket functionality**
- [ ] 🔄 **Implementasi room management**
- [ ] 🔄 **Tambahkan authentication ke WebSocket**
- [ ] 🔄 **Implementasi database notifications**
- [ ] 🔄 **Tambahkan unit tests untuk WebSocket**
- [ ] 🔄 **Setup monitoring dan observability**
- [ ] 🔄 **Deploy ke production**
### 🚀 Advanced WebSocket Features
- **🔄 Database Integration** - PostgreSQL LISTEN/NOTIFY
- **📊 Real-time Analytics** - Connection metrics dan monitoring
- **🔒 Enhanced Security** - Rate limiting, CORS, authentication
- **📈 Performance Optimization** - Connection pooling, message queuing
- **🏠 Multi-room Support** - Advanced room management
- **📱 Mobile SDK Integration** - React Native, Flutter support
- **🌐 Multi-protocol Support** - MQTT, Server-Sent Events
- **📡 Load Balancing** - Horizontal scaling untuk WebSocket
### 🛠️ Immediate Next Steps
1. **Test WebSocket Connection**
```bash
# Start the server
go run cmd/api/main.go
# Test with wscat
wscat -c ws://localhost:8080/api/v1/ws
```
2. **Try Vue.js Client Example**
```bash
cd examples/clientsocket
npm install
npm run dev
```
3. **Explore WebSocket Features**
- Join/leave rooms
- Send broadcast messages
- Direct messaging between clients
- Monitor connection statistics
***
**⚡ Total setup time: 5 menit | 🔄 WebSocket ready: Langsung test | 🌐 Client example: Vue.js included**
> **💡 Pro Tip:** Gunakan `make help` untuk melihat semua command yang tersedia
***

86
cmd/api/main.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"api-service/internal/server"
"github.com/joho/godotenv" // Import the godotenv package
_ "api-service/docs"
)
// @title API Service
// @version 1.0.0
// @description A comprehensive Go API service with Swagger documentation
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.url http://www.swagger.io/support
// @contact.email support@swagger.io
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @host localhost:8080
// @BasePath /api/v1
// @schemes http https
func gracefulShutdown(apiServer *http.Server, done chan bool) {
// Create context that listens for the interrupt signal from the OS.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Listen for the interrupt signal.
<-ctx.Done()
log.Println("shutting down gracefully, press Ctrl+C again to force")
stop() // Allow Ctrl+C to force shutdown
// The context is used to inform the server it has 5 seconds to finish
// the request it is currently handling
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := apiServer.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown with error: %v", err)
}
log.Println("Server exiting")
// Notify the main goroutine that the shutdown is complete
done <- true
}
func main() {
log.Println("Starting API Service...")
// Load environment variables from .env file
if err := godotenv.Load(); err != nil {
log.Printf("Warning: .env file not found or could not be loaded: %v", err)
log.Println("Continuing with system environment variables...")
}
server := server.NewServer()
// Create a done channel to signal when the shutdown is complete
done := make(chan bool, 1)
// Run graceful shutdown in a separate goroutine
go gracefulShutdown(server, done)
log.Printf("Server starting on port %s", server.Addr)
err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
panic(fmt.Sprintf("http server error: %s", err))
}
// Wait for the graceful shutdown to complete
<-done
log.Println("Graceful shutdown complete.")
}

109
cmd/logging/main.go Normal file
View File

@@ -0,0 +1,109 @@
package main
import (
"fmt"
"log"
"time"
"api-service/pkg/logger"
)
func main() {
fmt.Println("Testing Dynamic Logging Functions...")
fmt.Println("====================================")
// Test fungsi penyimpanan log dinamis
testDynamicLogging()
// Tunggu sebentar untuk memastikan goroutine selesai
time.Sleep(500 * time.Millisecond)
fmt.Println("\n====================================")
fmt.Println("Dynamic logging test completed!")
fmt.Println("Check the log files in pkg/logger/data/ directory")
}
func testDynamicLogging() {
// Buat logger instance
loggerInstance := logger.New("test-app", logger.DEBUG, false)
// Test 1: Log dengan penyimpanan otomatis
fmt.Println("\n1. Testing automatic log saving...")
loggerInstance.LogAndSave(logger.INFO, "Application started successfully", map[string]interface{}{
"version": "1.0.0",
"build_date": time.Now().Format("2006-01-02"),
"environment": "development",
})
// Test 2: Log dengan request context
fmt.Println("\n2. Testing log with request context...")
requestLogger := loggerInstance.WithRequestID("req-001").WithCorrelationID("corr-001")
requestLogger.LogAndSave(logger.INFO, "User login attempt", map[string]interface{}{
"username": "john_doe",
"ip": "192.168.1.100",
"success": true,
})
// Test 3: Error logging
fmt.Println("\n3. Testing error logging...")
loggerInstance.LogAndSave(logger.ERROR, "Database connection failed", map[string]interface{}{
"error": "connection timeout",
"retry_count": 3,
"host": "db.example.com:5432",
})
// Test 4: Manual log entry saving
fmt.Println("\n4. Testing manual log entry saving...")
manualEntry := logger.LogEntry{
Timestamp: time.Now().Format(time.RFC3339),
Level: "DEBUG",
Service: "manual-test",
Message: "Manual log entry created",
RequestID: "manual-req-001",
CorrelationID: "manual-corr-001",
File: "main.go",
Line: 42,
Fields: map[string]interface{}{
"custom_field": "test_value",
"number": 123,
"active": true,
},
}
// Simpan manual ke berbagai format
if err := logger.SaveLogText(manualEntry); err != nil {
log.Printf("Error saving text log: %v", err)
} else {
fmt.Println("✓ Text log saved successfully")
}
if err := logger.SaveLogJSON(manualEntry); err != nil {
log.Printf("Error saving JSON log: %v", err)
} else {
fmt.Println("✓ JSON log saved successfully")
}
if err := logger.SaveLogToDatabase(manualEntry); err != nil {
log.Printf("Error saving database log: %v", err)
} else {
fmt.Println("✓ Database log saved successfully")
}
// Test 5: Performance logging dengan durasi
fmt.Println("\n5. Testing performance logging...")
start := time.Now()
// Simulasi proses yang memakan waktu
time.Sleep(200 * time.Millisecond)
duration := time.Since(start)
loggerInstance.LogAndSave(logger.INFO, "Data processing completed", map[string]interface{}{
"operation": "data_import",
"duration": duration.String(),
"duration_ms": duration.Milliseconds(),
"records": 1000,
"throughput": fmt.Sprintf("%.2f records/ms", 1000/float64(duration.Milliseconds())),
})
fmt.Println("\n✓ All logging tests completed successfully!")
}

130
diagnostic/main.go Normal file
View File

@@ -0,0 +1,130 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/joho/godotenv"
)
func main() {
fmt.Println("=== Database Connection Diagnostic Tool ===")
// Load environment variables from .env file
if err := godotenv.Load(); err != nil {
log.Printf("Warning: Error loading .env file: %v", err)
}
// Get configuration from environment
host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
username := os.Getenv("DB_USERNAME")
password := os.Getenv("DB_PASSWORD")
database := os.Getenv("DB_DATABASE")
sslmode := os.Getenv("DB_SSLMODE")
if sslmode == "" {
sslmode = "disable"
}
fmt.Printf("Host: %s\n", host)
fmt.Printf("Port: %s\n", port)
fmt.Printf("Username: %s\n", username)
fmt.Printf("Database: %s\n", database)
fmt.Printf("SSL Mode: %s\n", sslmode)
if host == "" || username == "" || password == "" {
fmt.Println("❌ Missing required environment variables")
return
}
// Test connection to PostgreSQL server
fmt.Println("\n--- Testing PostgreSQL Server Connection ---")
serverConnStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=%s",
host, port, username, password, sslmode)
db, err := sql.Open("pgx", serverConnStr)
if err != nil {
fmt.Printf("❌ Failed to connect to PostgreSQL server: %v\n", err)
return
}
defer db.Close()
err = db.Ping()
if err != nil {
fmt.Printf("❌ Failed to ping PostgreSQL server: %v\n", err)
return
}
fmt.Println("✅ Successfully connected to PostgreSQL server")
// Check if database exists
fmt.Println("\n--- Checking Database Existence ---")
var exists bool
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", database).Scan(&exists)
if err != nil {
fmt.Printf("❌ Failed to check database existence: %v\n", err)
return
}
if !exists {
fmt.Printf("❌ Database '%s' does not exist\n", database)
// List available databases
fmt.Println("\n--- Available Databases ---")
rows, err := db.Query("SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname")
if err != nil {
fmt.Printf("❌ Failed to list databases: %v\n", err)
return
}
defer rows.Close()
fmt.Println("Available databases:")
for rows.Next() {
var dbName string
if err := rows.Scan(&dbName); err != nil {
continue
}
fmt.Printf(" - %s\n", dbName)
}
return
}
fmt.Printf("✅ Database '%s' exists\n", database)
// Test direct connection to the database
fmt.Println("\n--- Testing Direct Database Connection ---")
directConnStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
host, port, username, password, database, sslmode)
targetDB, err := sql.Open("pgx", directConnStr)
if err != nil {
fmt.Printf("❌ Failed to connect to database '%s': %v\n", database, err)
return
}
defer targetDB.Close()
err = targetDB.Ping()
if err != nil {
fmt.Printf("❌ Failed to ping database '%s': %v\n", database, err)
return
}
fmt.Printf("✅ Successfully connected to database '%s'\n", database)
// Test basic query
fmt.Println("\n--- Testing Basic Query ---")
var version string
err = targetDB.QueryRow("SELECT version()").Scan(&version)
if err != nil {
fmt.Printf("❌ Failed to execute query: %v\n", err)
return
}
fmt.Printf("✅ PostgreSQL Version: %s\n", version)
fmt.Println("\n🎉 All tests passed! Database connection is working correctly.")
}

194
docker-compose.yml Normal file
View File

@@ -0,0 +1,194 @@
services:
# # PostgreSQL Database
# psql_bp:
# image: postgres:15-alpine
# restart: unless-stopped
# environment:
# POSTGRES_USER: stim
# POSTGRES_PASSWORD: stim*RS54
# POSTGRES_DB: satu_db
# ports:
# - "5432:5432"
# volumes:
# - postgres_data:/var/lib/postgresql/data
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U stim -d satu_db"]
# interval: 10s
# timeout: 5s
# retries: 5
# networks:
# - blueprint
# # MongoDB Database
# mongodb:
# image: mongo:7-jammy
# restart: unless-stopped
# environment:
# MONGO_INITDB_ROOT_USERNAME: admin
# MONGO_INITDB_ROOT_PASSWORD: stim*rs54
# ports:
# - "27017:27017"
# volumes:
# - mongodb_data:/data/db
# networks:
# - blueprint
# # MySQL Antrian Database
# mysql_antrian:
# image: mysql:8.0
# restart: unless-stopped
# environment:
# MYSQL_ROOT_PASSWORD: www-data
# MYSQL_USER: www-data
# MYSQL_PASSWORD: www-data
# MYSQL_DATABASE: antrian_rssa
# ports:
# - "3306:3306"
# volumes:
# - mysql_antrian_data:/var/lib/mysql
# healthcheck:
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
# interval: 10s
# timeout: 5s
# retries: 5
# networks:
# - blueprint
# # MySQL Medical Database
# mysql_medical:
# image: mysql:8.0
# restart: unless-stopped
# environment:
# MYSQL_ROOT_PASSWORD: meninjar*RS54
# MYSQL_USER: meninjardev
# MYSQL_PASSWORD: meninjar*RS54
# MYSQL_DATABASE: healtcare_database
# ports:
# - "3307:3306"
# volumes:
# - mysql_medical_data:/var/lib/mysql
# healthcheck:
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
# interval: 10s
# timeout: 5s
# retries: 5
# networks:
# - blueprint
# Main Application
app:
build:
context: .
dockerfile: Dockerfile
target: prod
restart: unless-stopped
ports:
- "8080:8080"
environment:
# Server Configuration
APP_ENV: production
PORT: 8080
GIN_MODE: release
# Default Database Configuration (PostgreSQL)
DB_CONNECTION: postgres
DB_USERNAME: stim
DB_PASSWORD: stim*RS54
DB_HOST: 10.10.123.165
DB_DATABASE: satu_db
DB_PORT: 5432
DB_SSLMODE: disable
# satudata Database Configuration (PostgreSQL)
POSTGRES_SATUDATA_CONNECTION: postgres
POSTGRES_SATUDATA_USERNAME: stim
POSTGRES_SATUDATA_PASSWORD: stim*RS54
POSTGRES_SATUDATA_HOST: 10.10.123.165
POSTGRES_SATUDATA_DATABASE: satu_db
POSTGRES_SATUDATA_PORT: 5432
POSTGRES_SATUDATA_SSLMODE: disable
# Mongo Database
MONGODB_MONGOHL7_CONNECTION: mongodb
MONGODB_MONGOHL7_HOST: 10.10.123.206
MONGODB_MONGOHL7_PORT: 27017
MONGODB_MONGOHL7_USER: admin
MONGODB_MONGOHL7_PASS: stim*rs54
MONGODB_MONGOHL7_MASTER: master
MONGODB_MONGOHL7_LOCAL: local
MONGODB_MONGOHL7_SSLMODE: disable
# MYSQL Antrian Database
# MYSQL_ANTRIAN_CONNECTION: mysql
# MYSQL_ANTRIAN_HOST: mysql_antrian
# MYSQL_ANTRIAN_USERNAME: www-data
# MYSQL_ANTRIAN_PASSWORD: www-data
# MYSQL_ANTRIAN_DATABASE: antrian_rssa
# MYSQL_ANTRIAN_PORT: 3306
# MYSQL_ANTRIAN_SSLMODE: disable
# MYSQL Medical Database
MYSQL_MEDICAL_CONNECTION: mysql
MYSQL_MEDICAL_HOST: 10.10.123.163
MYSQL_MEDICAL_USERNAME: meninjardev
MYSQL_MEDICAL_PASSWORD: meninjar*RS54
MYSQL_MEDICAL_DATABASE: healtcare_database
MYSQL_MEDICAL_PORT: 3306
MYSQL_MEDICAL_SSLMODE: disable
# Keycloak Configuration
KEYCLOAK_ISSUER: https://auth.rssa.top/realms/sandbox
KEYCLOAK_AUDIENCE: nuxtsim-pendaftaran
KEYCLOAK_JWKS_URL: https://auth.rssa.top/realms/sandbox/protocol/openid-connect/certs
KEYCLOAK_ENABLED: true
# BPJS Configuration
BPJS_BASEURL: https://apijkn.bpjs-kesehatan.go.id/vclaim-rest
BPJS_CONSID: 5257
BPJS_USERKEY: 4cf1cbef8c008440bbe9ef9ba789e482
BPJS_SECRETKEY: 1bV363512D
# SatuSehat Configuration
BRIDGING_SATUSEHAT_ORG_ID: 100026555
BRIDGING_SATUSEHAT_FASYAKES_ID: 3573011
BRIDGING_SATUSEHAT_CLIENT_ID: l1ZgJGW6K5pnrqGUikWM7fgIoquA2AQ5UUG0U8WqHaq2VEyZ
BRIDGING_SATUSEHAT_CLIENT_SECRET: Al3PTYAW6axPiAFwaFlpn8qShLFW5YGMgG8w1qhexgCc7lGTEjjcR6zxa06ThPDy
BRIDGING_SATUSEHAT_AUTH_URL: https://api-satusehat.kemkes.go.id/oauth2/v1
BRIDGING_SATUSEHAT_BASE_URL: https://api-satusehat.kemkes.go.id/fhir-r4/v1
BRIDGING_SATUSEHAT_CONSENT_URL: https://api-satusehat.dto.kemkes.go.id/consent/v1
BRIDGING_SATUSEHAT_KFA_URL: https://api-satusehat.kemkes.go.id/kfa-v2
# Swagger Configuration
SWAGGER_TITLE: My Custom API Service
SWAGGER_DESCRIPTION: This is a custom API service for managing various resources
SWAGGER_VERSION: 2.0.0
SWAGGER_CONTACT_NAME: Support Team
SWAGGER_HOST: api.mycompany.com:8080
SWAGGER_BASE_PATH: /api/v2
SWAGGER_SCHEMES: https
# API Configuration
API_TITLE: API Service UJICOBA
API_DESCRIPTION: Dokumentation SWAGGER
API_VERSION: 3.0.0
# depends_on:
# psql_bp:
# condition: service_healthy
# mongodb:
# condition: service_started
# mysql_antrian:
# condition: service_healthy
# mysql_medical:
# condition: service_healthy
networks:
- goservice
# volumes:
# postgres_data:
# mongodb_data:
# mysql_antrian_data:
# mysql_medical_data:
networks:
goservice:

2238
docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

2218
docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1464
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

92
example.env Normal file
View File

@@ -0,0 +1,92 @@
# Server Configuration
PORT=8080
GIN_MODE=debug
# Default Database Configuration (PostgreSQL)
DB_CONNECTION=postgres
DB_USERNAME=stim
DB_PASSWORD=stim*RS54
DB_HOST=10.10.123.165
DB_DATABASE=satu_db
DB_PORT=5000
DB_SSLMODE=disable
# satudata Database Configuration (PostgreSQL)
# POSTGRES_CONNECTION=postgres
# POSTGRES_USERNAME=stim
# POSTGRES_PASSWORD=stim*RS54
# POSTGRES_HOST=10.10.123.165
# POSTGRES_DATABASE=satu_db
# POSTGRES_NAME=satu_db
# POSTGRES_PORT=5000
# POSTGRES_SSLMODE=disable
POSTGRES_SATUDATA_CONNECTION=postgres
POSTGRES_SATUDATA_USERNAME=stim
POSTGRES_SATUDATA_PASSWORD=stim*RS54
POSTGRES_SATUDATA_HOST=10.10.123.165
POSTGRES_SATUDATA_DATABASE=satu_db
POSTGRES_SATUDATA_PORT=5000
POSTGRES_SATUDATA_SSLMODE=disable
# Mongo Database
MONGODB_MONGOHL7_CONNECTION=mongodb
MONGODB_MONGOHL7_HOST=10.10.123.206
MONGODB_MONGOHL7_PORT=27017
MONGODB_MONGOHL7_USER=admin
MONGODB_MONGOHL7_PASS=stim*rs54
MONGODB_MONGOHL7_MASTER=master
MONGODB_MONGOHL7_LOCAL=local
MONGODB_MONGOHL7_SSLMODE=disable
# MYSQL Antrian Database
MYSQL_ANTRIAN_CONNECTION=mysql
MYSQL_ANTRIAN_HOST=10.10.123.163
MYSQL_ANTRIAN_USERNAME=www-data
MYSQL_ANTRIAN_PASSWORD=www-data
MYSQL_ANTRIAN_DATABASE=antrian_rssa
MYSQL_ANTRIAN_PORT=3306
MYSQL_ANTRIAN_SSLMODE=disable
MYSQL_MEDICAL_CONNECTION=mysql
MYSQL_MEDICAL_HOST=10.10.123.147
MYSQL_MEDICAL_USERNAME=meninjardev
MYSQL_MEDICAL_PASSWORD=meninjar*RS54
MYSQL_MEDICAL_DATABASE=healtcare_database
MYSQL_MEDICAL_PORT=3306
MYSQL_MEDICAL_SSLMODE=disable
# Keycloak Configuration (optional)
KEYCLOAK_ISSUER=https://auth.rssa.top/realms/sandbox
KEYCLOAK_AUDIENCE=nuxtsim-pendaftaran
KEYCLOAK_JWKS_URL=https://auth.rssa.top/realms/sandbox/protocol/openid-connect/certs
KEYCLOAK_ENABLED=true
# BPJS Configuration
BPJS_BASEURL=https://apijkn.bpjs-kesehatan.go.id/vclaim-rest
BPJS_CONSID=5257
BPJS_USERKEY=4cf1cbef8c008440bbe9ef9ba789e482
BPJS_SECRETKEY=1bV363512D
BRIDGING_SATUSEHAT_ORG_ID=100026555
BRIDGING_SATUSEHAT_FASYAKES_ID=3573011
BRIDGING_SATUSEHAT_CLIENT_ID=l1ZgJGW6K5pnrqGUikWM7fgIoquA2AQ5UUG0U8WqHaq2VEyZ
BRIDGING_SATUSEHAT_CLIENT_SECRET=Al3PTYAW6axPiAFwaFlpn8qShLFW5YGMgG8w1qhexgCc7lGTEjjcR6zxa06ThPDy
BRIDGING_SATUSEHAT_AUTH_URL=https://api-satusehat.kemkes.go.id/oauth2/v1
BRIDGING_SATUSEHAT_BASE_URL=https://api-satusehat.kemkes.go.id/fhir-r4/v1
BRIDGING_SATUSEHAT_CONSENT_URL=https://api-satusehat.dto.kemkes.go.id/consent/v1
BRIDGING_SATUSEHAT_KFA_URL=https://api-satusehat.kemkes.go.id/kfa-v2
SWAGGER_TITLE=My Custom API Service
SWAGGER_DESCRIPTION=This is a custom API service for managing various resources
SWAGGER_VERSION=2.0.0
SWAGGER_CONTACT_NAME=STIM IT Support
SWAGGER_HOST=api.mycompany.com:8080
SWAGGER_BASE_PATH=/api/v2
SWAGGER_SCHEMES=https
API_TITLE=API Service UJICOBA
API_DESCRIPTION=Dokumentation SWAGGER
API_VERSION=3.0.0

24
examples/clientsocket/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View File

@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View File

@@ -0,0 +1,6 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
</template>

View File

@@ -0,0 +1,672 @@
/* Enhanced WebSocket Client Styles */
* {
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
padding: 24px;
overflow: hidden;
}
h1 {
color: #333;
text-align: center;
margin-bottom: 32px;
font-weight: 600;
}
.status-bar {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
margin-bottom: 24px;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.status-connected {
background: #4CAF50;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
}
.status-connecting {
background: #FFC107;
animation: pulse 1.5s infinite;
}
.status-disconnected {
background: #F44336;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.health-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: bold;
}
.health-excellent {
background: #d4edda;
color: #155724;
}
.health-good {
background: #d1ecf1;
color: #0c5460;
}
.health-warning {
background: #fff3cd;
color: #856404;
}
.health-poor {
background: #f8d7da;
color: #721c24;
}
.tabs {
display: flex;
border-bottom: 2px solid #e9ecef;
margin-bottom: 24px;
overflow-x: auto;
background: white;
border-radius: 8px 8px 0 0;
}
.tab {
padding: 16px 24px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
white-space: nowrap;
transition: all 0.3s ease;
font-weight: 500;
color: #666;
}
.tab:hover {
background: #f8f9fa;
color: #333;
}
.tab.active {
border-bottom-color: #1976d2;
color: #1976d2;
font-weight: 600;
background: #f8f9fa;
}
.tab-content {
display: none;
animation: fadeIn 0.3s ease;
}
.tab-content.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 24px;
padding: 24px;
background: #f8f9fa;
border-radius: 8px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.input-group input,
.input-group select,
.input-group textarea {
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.input-group input:focus,
.input-group select:focus,
.input-group textarea:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0;
}
.checkbox-group input[type="checkbox"] {
margin: 0;
width: 18px;
height: 18px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: #1976d2;
color: white;
}
.btn-primary:hover {
background: #1565c0;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
}
.btn-warning {
background: #FFC107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-info:hover {
background: #138496;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
}
.messages-container {
height: 500px;
overflow-y: auto;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 20px;
background: #fafafa;
font-family: "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace;
font-size: 13px;
line-height: 1.5;
}
.messages-container::-webkit-scrollbar {
width: 8px;
}
.messages-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.messages-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.message {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
border-left: 4px solid #1976d2;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
.message.error {
border-left-color: #dc3545;
background: #fff5f5;
}
.message.warning {
border-left-color: #FFC107;
background: #fffbf0;
}
.message.info {
border-left-color: #17a2b8;
background: #f0f9ff;
}
.message.success {
border-left-color: #28a745;
background: #f0fff4;
}
.message-time {
font-size: 11px;
color: #666;
margin-bottom: 8px;
font-weight: 500;
}
.message-type {
font-weight: 600;
color: #333;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 24px 0;
}
.stat-card {
background: white;
padding: 24px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
text-align: center;
border: 1px solid #e9ecef;
}
.stat-number {
font-size: 2.5em;
font-weight: 700;
color: #1976d2;
margin-bottom: 8px;
}
.stat-label {
color: #666;
font-size: 14px;
font-weight: 500;
}
.online-users {
max-height: 400px;
overflow-y: auto;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e9ecef;
}
.online-users::-webkit-scrollbar {
width: 6px;
}
.online-users::-webkit-scrollbar-track {
background: #f1f1f1;
}
.online-users::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
margin: 8px 0;
background: white;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #e9ecef;
}
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-id {
font-weight: 600;
color: #333;
}
.user-details {
font-size: 12px;
color: #666;
}
.loading-indicator {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #f3f3f3;
border-top: 2px solid #1976d2;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 8px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.message-limit-warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
padding: 16px;
margin: 16px 0;
border-radius: 6px;
font-size: 14px;
color: #856404;
display: flex;
align-items: center;
gap: 8px;
}
.admin-controls {
background: #fff3cd;
padding: 20px;
border-radius: 8px;
margin-bottom: 24px;
border: 1px solid #ffeaa7;
}
.admin-warning {
color: #856404;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
pre {
background: #f8f9fa;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
border: 1px solid #e9ecef;
margin: 8px 0;
}
code {
background: #f8f9fa;
padding: 2px 6px;
border-radius: 3px;
font-family: "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace;
font-size: 13px;
}
.message pre {
white-space: pre-wrap;
word-break: break-word;
}
.message code {
display: block;
margin: 8px 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
margin: 10px;
padding: 16px;
border-radius: 8px;
}
.controls {
grid-template-columns: 1fr;
padding: 16px;
}
.status-bar {
grid-template-columns: 1fr;
padding: 16px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.tabs {
flex-wrap: wrap;
}
.tab {
padding: 12px 16px;
font-size: 14px;
}
.messages-container {
height: 300px;
font-size: 12px;
}
.message {
padding: 12px;
font-size: 13px;
}
.btn {
padding: 10px 16px;
font-size: 13px;
}
}
@media (max-width: 480px) {
.container {
padding: 12px;
}
.controls {
padding: 12px;
}
.status-bar {
padding: 12px;
}
.messages-container {
height: 250px;
padding: 12px;
}
.tab {
padding: 8px 12px;
font-size: 12px;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
body {
background-color: #121212;
color: #e0e0e0;
}
.container {
background: #1e1e1e;
border: 1px solid #333;
}
.controls {
background: #2a2a2a;
border: 1px solid #404040;
}
.message {
background: #2a2a2a;
border: 1px solid #404040;
}
.stat-card {
background: #2a2a2a;
border: 1px solid #404040;
}
.online-users {
background: #2a2a2a;
border: 1px solid #404040;
}
.user-item {
background: #333;
border: 1px solid #404040;
}
.messages-container {
background: #1a1a1a;
border-color: #404040;
}
pre {
background: #2a2a2a;
border-color: #404040;
}
code {
background: #333;
}
}
/* Accessibility improvements */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Focus styles for keyboard navigation */
.btn:focus,
.tab:focus,
.input-group input:focus,
.input-group select:focus,
.input-group textarea:focus {
outline: 2px solid #1976d2;
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.btn {
border: 2px solid currentColor;
}
.message {
border: 2px solid currentColor;
}
.stat-card {
border: 2px solid currentColor;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,367 @@
<template>
<div class="container">
<h1>WebSocket Client</h1>
<!-- Status Bar -->
<div class="status-bar">
<div class="status-item">
<div
class="status-indicator"
:class="connectionStatusClass"
></div>
<span>{{ connectionStatusText }}</span>
</div>
<div class="status-item">
<span>Client ID:</span>
<strong>{{ connectionState?.clientId || 'Not connected' }}</strong>
</div>
<div class="status-item">
<span>Room:</span>
<strong>{{ connectionState?.currentRoom || 'None' }}</strong>
</div>
<div class="status-item">
<span>Messages:</span>
<strong>{{ messages?.length || 0 }}/{{ config?.maxMessages || 1000 }}</strong>
</div>
<div class="status-item">
<span>Uptime:</span>
<strong>{{ connectionState?.uptime || '00:00:00' }}</strong>
</div>
<div class="status-item">
<span>Health:</span>
<div
class="health-indicator"
:class="connectionHealthClass"
>
{{ connectionHealthText }}
</div>
</div>
</div>
<!-- Message Limit Warning -->
<div v-if="shouldShowMessageWarning" class="message-limit-warning">
<v-icon>mdi-alert</v-icon>
Message limit approaching ({{ messages?.length || 0 }}/{{ config?.maxMessages || 1000 }})
</div>
<!-- Tabs -->
<div class="tabs">
<button
class="tab"
:class="{ active: activeTab === 'connection' }"
@click="activeTab = 'connection'"
>
Connection
</button>
<button
class="tab"
:class="{ active: activeTab === 'messaging' }"
@click="activeTab = 'messaging'"
>
Messaging
</button>
<button
class="tab"
:class="{ active: activeTab === 'database' }"
@click="activeTab = 'database'"
>
Database
</button>
<button
class="tab"
:class="{ active: activeTab === 'monitoring' }"
@click="activeTab = 'monitoring'"
>
Monitoring
</button>
<button
class="tab"
:class="{ active: activeTab === 'admin' }"
@click="activeTab = 'admin'"
>
Admin
</button>
</div>
<!-- Tab Content -->
<div class="tab-content" :class="{ active: activeTab === 'connection' }">
<ConnectionTab />
</div>
<div class="tab-content" :class="{ active: activeTab === 'messaging' }">
<MessagingTab />
</div>
<div class="tab-content" :class="{ active: activeTab === 'database' }">
<DatabaseTab />
</div>
<div class="tab-content" :class="{ active: activeTab === 'monitoring' }">
<MonitoringTab />
</div>
<div class="tab-content" :class="{ active: activeTab === 'admin' }">
<AdminTab />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useWebSocket } from '~/composables/useWebSocket'
import ConnectionTab from './tabs/ConnectionTab.vue'
import MessagingTab from './tabs/MessagingTab.vue'
import DatabaseTab from './tabs/DatabaseTab.vue'
import MonitoringTab from './tabs/MonitoringTab.vue'
import AdminTab from './tabs/AdminTab.vue'
const activeTab = ref('connection')
const {
isConnected,
connectionStatus,
connectionState,
config,
messages,
stats,
onlineUsers,
activityLog,
connect,
disconnect,
cleanup,
isMessageLimitReached,
shouldShowMessageWarning,
connectionHealthColor,
connectionHealthText
} = useWebSocket()
const connectionStatusClass = computed(() => {
switch (connectionStatus.value) {
case 'connected': return 'status-connected'
case 'connecting': return 'status-connecting'
case 'disconnected': return 'status-disconnected'
default: return 'status-disconnected'
}
})
const connectionStatusText = computed(() => {
switch (connectionStatus.value) {
case 'connected': return 'Connected'
case 'connecting': return 'Connecting...'
case 'disconnected': return 'Disconnected'
case 'error': return 'Error'
default: return 'Unknown'
}
})
const connectionHealthClass = computed(() => {
switch (connectionState?.connectionHealth) {
case 'excellent': return 'health-excellent'
case 'good': return 'health-good'
case 'warning': return 'health-warning'
case 'poor': return 'health-poor'
default: return 'health-poor'
}
})
onMounted(() => {
// Auto-connect on mount
connect()
})
onUnmounted(() => {
cleanup()
})
</script>
<style scoped>
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
padding: 24px;
overflow: hidden;
}
h1 {
color: #333;
text-align: center;
margin-bottom: 32px;
font-weight: 600;
}
.status-bar {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
margin-bottom: 24px;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.status-connected {
background: #4CAF50;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
}
.status-connecting {
background: #FFC107;
animation: pulse 1.5s infinite;
}
.status-disconnected {
background: #F44336;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.health-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: bold;
}
.health-excellent {
background: #d4edda;
color: #155724;
}
.health-good {
background: #d1ecf1;
color: #0c5460;
}
.health-warning {
background: #fff3cd;
color: #856404;
}
.health-poor {
background: #f8d7da;
color: #721c24;
}
.tabs {
display: flex;
border-bottom: 2px solid #e9ecef;
margin-bottom: 24px;
overflow-x: auto;
background: white;
border-radius: 8px 8px 0 0;
}
.tab {
padding: 16px 24px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
white-space: nowrap;
transition: all 0.3s ease;
font-weight: 500;
color: #666;
}
.tab:hover {
background: #f8f9fa;
color: #333;
}
.tab.active {
border-bottom-color: #1976d2;
color: #1976d2;
font-weight: 600;
background: #f8f9fa;
}
.tab-content {
display: none;
animation: fadeIn 0.3s ease;
}
.tab-content.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message-limit-warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
padding: 16px;
margin: 16px 0;
border-radius: 6px;
font-size: 14px;
color: #856404;
display: flex;
align-items: center;
gap: 8px;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
margin: 10px;
padding: 16px;
border-radius: 8px;
}
.status-bar {
grid-template-columns: 1fr;
padding: 16px;
}
.tabs {
flex-wrap: wrap;
}
.tab {
padding: 12px 16px;
font-size: 14px;
}
}
@media (max-width: 480px) {
.container {
padding: 12px;
}
.status-bar {
padding: 12px;
}
.tab {
padding: 8px 12px;
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,665 @@
<template>
<div class="admin-tab">
<div class="controls">
<div class="input-group">
<label for="adminCommand">Admin Command</label>
<select id="adminCommand" v-model="adminCommand">
<option value="restart_server">Restart Server</option>
<option value="shutdown_server">Shutdown Server</option>
<option value="clear_cache">Clear Cache</option>
<option value="reload_config">Reload Configuration</option>
<option value="backup_database">Backup Database</option>
<option value="restore_database">Restore Database</option>
<option value="export_logs">Export Logs</option>
<option value="clear_logs">Clear Logs</option>
<option value="update_permissions">Update Permissions</option>
<option value="custom">Custom Command</option>
</select>
</div>
<div class="input-group">
<label for="commandParams">Command Parameters (JSON)</label>
<textarea
id="commandParams"
v-model="commandParams"
placeholder='{"target": "all", "force": false}'
rows="3"
></textarea>
</div>
<div class="input-group">
<label for="customCommand">Custom Command</label>
<input
id="customCommand"
v-model="customCommand"
type="text"
placeholder="custom_admin_command"
:disabled="adminCommand !== 'custom'"
/>
</div>
<div class="input-group">
<label for="confirmationCode">Confirmation Code</label>
<input
id="confirmationCode"
v-model="confirmationCode"
type="text"
placeholder="Enter confirmation code"
/>
</div>
</div>
<div class="controls">
<button
class="btn btn-danger"
:disabled="!isConnected || !confirmationCode.trim()"
@click="executeAdminCommand"
>
Execute Command
</button>
<button
class="btn btn-secondary"
:disabled="!isConnected"
@click="getServerInfo"
>
Get Server Info
</button>
<button
class="btn btn-secondary"
:disabled="!isConnected"
@click="getSystemHealth"
>
Get System Health
</button>
<button
class="btn btn-warning"
@click="clearCommandHistory"
>
Clear History
</button>
</div>
<!-- Server Information -->
<div class="server-info-section" v-if="serverInfo">
<h3>Server Information</h3>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Server Version</div>
<div class="info-value">{{ serverInfo.version }}</div>
</div>
<div class="info-item">
<div class="info-label">Environment</div>
<div class="info-value">{{ serverInfo.environment }}</div>
</div>
<div class="info-item">
<div class="info-label">Node Version</div>
<div class="info-value">{{ serverInfo.nodeVersion }}</div>
</div>
<div class="info-item">
<div class="info-label">Platform</div>
<div class="info-value">{{ serverInfo.platform }}</div>
</div>
<div class="info-item">
<div class="info-label">Architecture</div>
<div class="info-value">{{ serverInfo.architecture }}</div>
</div>
<div class="info-item">
<div class="info-label">CPU Cores</div>
<div class="info-value">{{ serverInfo.cpuCores }}</div>
</div>
<div class="info-item">
<div class="info-label">Memory Usage</div>
<div class="info-value">{{ formatBytes(serverInfo.memoryUsage) }}</div>
</div>
<div class="info-item">
<div class="info-label">Uptime</div>
<div class="info-value">{{ formatUptime(serverInfo.uptime) }}</div>
</div>
</div>
</div>
<!-- System Health -->
<div class="system-health-section" v-if="systemHealth">
<h3>System Health</h3>
<div class="health-grid">
<div class="health-item">
<div class="health-label">CPU Usage</div>
<div class="health-value">{{ systemHealth.cpuUsage }}%</div>
<div class="health-bar">
<div
class="health-bar-fill"
:style="{ width: systemHealth.cpuUsage + '%' }"
:class="getCpuUsageClass(systemHealth.cpuUsage)"
></div>
</div>
</div>
<div class="health-item">
<div class="health-label">Memory Usage</div>
<div class="health-value">{{ systemHealth.memoryUsage }}%</div>
<div class="health-bar">
<div
class="health-bar-fill"
:style="{ width: systemHealth.memoryUsage + '%' }"
:class="getMemoryUsageClass(systemHealth.memoryUsage)"
></div>
</div>
</div>
<div class="health-item">
<div class="health-label">Disk Usage</div>
<div class="health-value">{{ systemHealth.diskUsage }}%</div>
<div class="health-bar">
<div
class="health-bar-fill"
:style="{ width: systemHealth.diskUsage + '%' }"
:class="getDiskUsageClass(systemHealth.diskUsage)"
></div>
</div>
</div>
<div class="health-item">
<div class="health-label">Network I/O</div>
<div class="health-value">{{ formatBytes(systemHealth.networkRx) }} / {{ formatBytes(systemHealth.networkTx) }}</div>
</div>
<div class="health-item">
<div class="health-label">Active Connections</div>
<div class="health-value">{{ systemHealth.activeConnections }}</div>
</div>
<div class="health-item">
<div class="health-label">Error Rate</div>
<div class="health-value">{{ systemHealth.errorRate }}%</div>
</div>
</div>
</div>
<!-- Command History -->
<div class="command-history-section">
<h3>Command History</h3>
<div class="command-history">
<div
v-for="(command, index) in commandHistory.slice(0, 10)"
:key="index"
class="command-item"
>
<div class="command-header">
<div class="command-type">{{ command.command }}</div>
<div class="command-time">{{ formatTime(command.timestamp) }}</div>
<div class="command-status" :class="command.success ? 'success' : 'error'">
{{ command.success ? 'Success' : 'Error' }}
</div>
</div>
<div class="command-details">
<div v-if="command.params" class="command-params">
<strong>Parameters:</strong> {{ command.params }}
</div>
<div v-if="command.result" class="command-result">
<strong>Result:</strong>
<pre>{{ JSON.stringify(command.result, null, 2) }}</pre>
</div>
<div v-if="command.error" class="command-error">
<strong>Error:</strong> {{ command.error }}
</div>
</div>
</div>
<div v-if="commandHistory.length === 0" class="no-commands">
No commands executed yet
</div>
</div>
</div>
<!-- Security Notice -->
<div class="security-notice">
<h4> Security Notice</h4>
<p>Admin commands can have serious consequences. Please ensure you have proper authorization and understand the impact of each command before execution.</p>
<ul>
<li>Server restart will disconnect all clients</li>
<li>Database operations are irreversible</li>
<li>Always backup before destructive operations</li>
<li>Some commands require elevated permissions</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useWebSocket } from '../../composables/useWebSocket'
const {
isConnected,
serverInfo,
systemHealth,
executeAdminCommand: executeAdminCommandFn,
getServerInfo,
getSystemHealth
} = useWebSocket()
const adminCommand = ref('restart_server')
const commandParams = ref('')
const customCommand = ref('')
const confirmationCode = ref('')
const commandHistory = ref<Array<{
command: string
params: string
result: any
error: string
success: boolean
timestamp: number
}>>([])
const executeAdminCommand = async () => {
if (!isConnected.value || !confirmationCode.value.trim()) return
let command = adminCommand.value
let params = {}
try {
if (adminCommand.value === 'custom') {
command = customCommand.value.trim()
if (!command) {
alert('Please enter a custom command')
return
}
}
if (commandParams.value.trim()) {
params = JSON.parse(commandParams.value)
}
const result = await executeAdminCommandFn(command, {
...params,
confirmationCode: confirmationCode.value.trim(),
timestamp: Date.now()
})
// Add to history
commandHistory.value.unshift({
command,
params: JSON.stringify(params),
result: result,
error: '',
success: true,
timestamp: Date.now()
})
// Clear form
confirmationCode.value = ''
commandParams.value = ''
} catch (error) {
// Add error to history
commandHistory.value.unshift({
command,
params: commandParams.value,
result: null,
error: error instanceof Error ? error.message : String(error),
success: false,
timestamp: Date.now()
})
}
}
const clearCommandHistory = () => {
commandHistory.value = []
}
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
const formatUptime = (uptime: number) => {
const seconds = Math.floor(uptime / 1000)
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const getCpuUsageClass = (usage: number) => {
if (usage < 50) return 'usage-low'
if (usage < 80) return 'usage-medium'
return 'usage-high'
}
const getMemoryUsageClass = (usage: number) => {
if (usage < 60) return 'usage-low'
if (usage < 85) return 'usage-medium'
return 'usage-high'
}
const getDiskUsageClass = (usage: number) => {
if (usage < 70) return 'usage-low'
if (usage < 90) return 'usage-medium'
return 'usage-high'
}
</script>
<style scoped>
.admin-tab {
padding: 20px 0;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 24px;
padding: 24px;
background: #f8f9fa;
border-radius: 8px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.input-group input,
.input-group select,
.input-group textarea {
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.input-group input:focus,
.input-group select:focus,
.input-group textarea:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.input-group input:disabled {
background: #e9ecef;
cursor: not-allowed;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c82333;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
}
.btn-warning {
background: #FFC107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.server-info-section,
.system-health-section {
margin: 32px 0;
}
.server-info-section h3,
.system-health-section h3 {
margin-bottom: 16px;
color: #333;
font-size: 18px;
}
.info-grid,
.health-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.info-item,
.health-item {
background: white;
padding: 20px;
border-radius: 8px;
border: 1px solid #e9ecef;
text-align: center;
}
.info-label,
.health-label {
color: #666;
font-size: 14px;
margin-bottom: 8px;
}
.info-value,
.health-value {
color: #333;
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
}
.health-bar {
width: 100%;
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.health-bar-fill {
height: 100%;
transition: width 0.3s ease;
}
.usage-low {
background: #28a745;
}
.usage-medium {
background: #ffc107;
}
.usage-high {
background: #dc3545;
}
.command-history-section {
margin-top: 32px;
}
.command-history-section h3 {
margin-bottom: 16px;
color: #333;
font-size: 18px;
}
.command-history {
max-height: 300px;
overflow-y: auto;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e9ecef;
}
.command-item {
margin: 16px 0;
background: white;
border-radius: 8px;
border: 1px solid #e9ecef;
overflow: hidden;
}
.command-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.command-type {
font-weight: 600;
color: #1976d2;
text-transform: uppercase;
font-size: 12px;
}
.command-time {
color: #666;
font-size: 12px;
}
.command-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.command-status.success {
background: #d4edda;
color: #155724;
}
.command-status.error {
background: #f8d7da;
color: #721c24;
}
.command-details {
padding: 16px;
}
.command-params,
.command-result,
.command-error {
margin: 12px 0;
font-size: 13px;
}
.command-params strong,
.command-result strong,
.command-error strong {
color: #333;
display: block;
margin-bottom: 4px;
}
.command-result pre {
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
color: #333;
}
.command-error {
color: #dc3545;
background: #f8d7da;
padding: 12px;
border-radius: 4px;
}
.no-commands {
text-align: center;
color: #666;
font-style: italic;
padding: 40px;
}
.security-notice {
margin-top: 32px;
padding: 20px;
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
border-left: 4px solid #ffc107;
}
.security-notice h4 {
margin: 0 0 12px 0;
color: #856404;
font-size: 16px;
}
.security-notice p {
margin: 0 0 12px 0;
color: #856404;
font-size: 14px;
}
.security-notice ul {
margin: 0;
padding-left: 20px;
color: #856404;
}
.security-notice li {
margin: 4px 0;
font-size: 13px;
}
/* Responsive Design */
@media (max-width: 768px) {
.controls {
grid-template-columns: 1fr;
padding: 16px;
}
.info-grid,
.health-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,397 @@
<template>
<div class="connection-tab">
<div class="controls">
<div class="input-group">
<label for="wsUrl">WebSocket URL</label>
<input
id="wsUrl"
v-model="config.wsUrl"
type="text"
placeholder="ws://localhost:8080/api/v1/ws"
/>
</div>
<div class="input-group">
<label for="userId">User ID</label>
<input
id="userId"
v-model="config.userId"
type="text"
placeholder="anonymous"
/>
</div>
<div class="input-group">
<label for="room">Room</label>
<input
id="room"
v-model="config.room"
type="text"
placeholder="default"
/>
</div>
<div class="input-group">
<label for="staticId">Static ID (Optional)</label>
<input
id="staticId"
v-model="config.staticId"
type="text"
placeholder="Leave empty for dynamic ID"
/>
</div>
</div>
<div class="controls">
<div class="input-group">
<label class="checkbox-group">
<input v-model="config.useIPBasedId" type="checkbox" />
Use IP-based ID
</label>
</div>
<div class="input-group">
<label class="checkbox-group">
<input v-model="config.autoReconnect" type="checkbox" />
Auto Reconnect
</label>
</div>
<div class="input-group">
<label class="checkbox-group">
<input v-model="config.heartbeatEnabled" type="checkbox" />
Enable Heartbeat
</label>
</div>
</div>
<div class="controls">
<button
class="btn btn-primary"
:class="{ 'btn-disabled': isConnecting }"
:disabled="isConnecting"
@click="connect"
>
<span v-if="isConnecting" class="loading-indicator"></span>
{{ isConnected ? "Reconnect" : "Connect" }}
</button>
<button
class="btn btn-danger"
:disabled="!isConnected"
@click="disconnect"
>
Disconnect
</button>
<button
class="btn btn-info"
:disabled="!isConnected"
@click="testConnection"
>
Test Connection
</button>
<button class="btn btn-secondary" @click="clearMessages">
Clear Messages
</button>
</div>
<div class="controls">
<div class="input-group">
<label for="maxReconnectAttempts">Max Reconnect Attempts</label>
<input
id="maxReconnectAttempts"
v-model.number="config.maxReconnectAttempts"
type="number"
min="1"
max="50"
/>
</div>
<div class="input-group">
<label for="reconnectDelay">Reconnect Delay (ms)</label>
<input
id="reconnectDelay"
v-model.number="config.reconnectDelay"
type="number"
min="100"
max="10000"
/>
</div>
<div class="input-group">
<label for="heartbeatInterval">Heartbeat Interval (ms)</label>
<input
id="heartbeatInterval"
v-model.number="config.heartbeatInterval"
type="number"
min="1000"
max="60000"
/>
</div>
<div class="input-group">
<label for="maxMessages">Max Messages</label>
<input
id="maxMessages"
v-model.number="config.maxMessages"
type="number"
min="100"
max="5000"
/>
</div>
</div>
<div class="connection-info" v-if="isConnected">
<h3>Connection Information</h3>
<div class="info-grid">
<div class="info-item">
<strong>Client ID:</strong>
<span>{{ connectionState.clientId }}</span>
</div>
<div class="info-item">
<strong>Static ID:</strong>
<span>{{ connectionState.staticId || "N/A" }}</span>
</div>
<div class="info-item">
<strong>IP Address:</strong>
<span>{{ connectionState.ipAddress || "N/A" }}</span>
</div>
<div class="info-item">
<strong>User ID:</strong>
<span>{{ connectionState.userId }}</span>
</div>
<div class="info-item">
<strong>Room:</strong>
<span>{{ connectionState.currentRoom }}</span>
</div>
<div class="info-item">
<strong>Connected At:</strong>
<span>{{
new Date(connectionState.connectionStartTime || 0).toLocaleString()
}}</span>
</div>
<div class="info-item">
<strong>Latency:</strong>
<span>{{ connectionState.connectionLatency }}ms</span>
</div>
<div class="info-item">
<strong>Reconnect Attempts:</strong>
<span>{{ connectionState.reconnectAttempts }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useWebSocket } from "../../composables/useWebSocket";
const {
isConnected,
isConnecting,
connectionState,
config,
connect,
disconnect,
testConnection,
clearMessages,
} = useWebSocket();
</script>
<style scoped>
.connection-tab {
padding: 20px 0;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 24px;
padding: 24px;
background: #f8f9fa;
border-radius: 8px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.input-group input {
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.input-group input:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0;
}
.checkbox-group input[type="checkbox"] {
margin: 0;
width: 18px;
height: 18px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: #1976d2;
color: white;
}
.btn-primary:hover:not(.btn-disabled) {
background: #1565c0;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c82333;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-info:hover:not(:disabled) {
background: #138496;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
}
.btn-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-indicator {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #f3f3f3;
border-top: 2px solid #1976d2;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.connection-info {
margin-top: 32px;
padding: 24px;
background: white;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.connection-info h3 {
margin-top: 0;
margin-bottom: 20px;
color: #333;
font-size: 18px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.info-item strong {
color: #333;
font-weight: 600;
}
.info-item span {
color: #666;
font-family: monospace;
font-size: 13px;
}
/* Responsive Design */
@media (max-width: 768px) {
.controls {
grid-template-columns: 1fr;
padding: 16px;
}
.info-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,536 @@
<template>
<div class="database-tab">
<div class="controls">
<div class="input-group">
<label for="queryType">Query Type</label>
<select id="queryType" v-model="queryType">
<option value="select">SELECT</option>
<option value="insert">INSERT</option>
<option value="update">UPDATE</option>
<option value="delete">DELETE</option>
<option value="create_table">CREATE TABLE</option>
<option value="drop_table">DROP TABLE</option>
<option value="show_tables">SHOW TABLES</option>
<option value="describe_table">DESCRIBE TABLE</option>
</select>
</div>
<div class="input-group">
<label for="tableName">Table Name</label>
<input
id="tableName"
v-model="tableName"
type="text"
placeholder="Enter table name"
/>
</div>
<div class="input-group">
<label for="queryParams">Query Parameters (JSON)</label>
<textarea
id="queryParams"
v-model="queryParams"
placeholder='{"column": "value", "column2": "value2"}'
rows="3"
></textarea>
</div>
<div class="input-group">
<label for="customQuery">Custom SQL Query</label>
<textarea
id="customQuery"
v-model="customQuery"
placeholder="Enter custom SQL query"
rows="4"
></textarea>
</div>
</div>
<div class="controls">
<button
class="btn btn-primary"
:disabled="!isConnected || (!tableName.trim() && !customQuery.trim())"
@click="executeQuery"
>
Execute Query
</button>
<button
class="btn btn-secondary"
:disabled="!isConnected"
@click="getStats"
>
Get Database Stats
</button>
<button
class="btn btn-secondary"
:disabled="!isConnected"
@click="getMonitoringData"
>
Get Monitoring Data
</button>
<button class="btn btn-secondary" @click="clearQueryHistory">
Clear Query History
</button>
</div>
<!-- Query History -->
<div class="query-history-section">
<h3>Query History</h3>
<div class="query-history">
<div
v-for="(query, index) in queryHistory.slice(0, 10)"
:key="index"
class="query-item"
>
<div class="query-header">
<div class="query-type">{{ query.type }}</div>
<div class="query-time">{{ formatTime(query.timestamp) }}</div>
<div
class="query-status"
:class="query.success ? 'success' : 'error'"
>
{{ query.success ? "Success" : "Error" }}
</div>
</div>
<div class="query-details">
<div class="query-sql">{{ query.sql }}</div>
<div v-if="query.params" class="query-params">
<strong>Parameters:</strong> {{ query.params }}
</div>
<div v-if="query.result" class="query-result">
<strong>Result:</strong>
<pre>{{ JSON.stringify(query.result, null, 2) }}</pre>
</div>
<div v-if="query.error" class="query-error">
<strong>Error:</strong> {{ query.error }}
</div>
</div>
</div>
<div v-if="queryHistory.length === 0" class="no-queries">
No queries executed yet
</div>
</div>
</div>
<!-- Database Stats -->
<div class="database-stats-section" v-if="stats">
<h3>Database Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">Connected Clients</div>
<div class="stat-value">{{ stats.connected_clients }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Unique IPs</div>
<div class="stat-value">{{ stats.unique_ips }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Static Clients</div>
<div class="stat-value">{{ stats.static_clients }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Active Rooms</div>
<div class="stat-value">{{ stats.active_rooms }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Message Queue Size</div>
<div class="stat-value">{{ stats.message_queue_size }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Queue Workers</div>
<div class="stat-value">{{ stats.queue_workers }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Uptime</div>
<div class="stat-value">{{ formatUptime(stats.uptime) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Last Updated</div>
<div class="stat-value">{{ formatTime(stats.timestamp) }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useWebSocket } from "../../composables/useWebSocket";
const {
isConnected,
stats,
executeDatabaseQuery,
getStats,
getMonitoringData,
} = useWebSocket();
const queryType = ref("select");
const tableName = ref("");
const queryParams = ref("");
const customQuery = ref("");
const queryHistory = ref<
Array<{
type: string;
sql: string;
params: string;
result: any;
error: string;
success: boolean;
timestamp: number;
}>
>([]);
const executeQuery = async () => {
if (!isConnected.value) return;
let sql = "";
let params = {};
try {
if (customQuery.value.trim()) {
sql = customQuery.value.trim();
params = queryParams.value ? JSON.parse(queryParams.value) : {};
} else {
switch (queryType.value) {
case "select":
sql = `SELECT * FROM ${tableName.value}`;
break;
case "insert":
params = JSON.parse(queryParams.value || "{}");
sql = `INSERT INTO ${tableName.value} SET ?`;
break;
case "update":
params = JSON.parse(queryParams.value || "{}");
sql = `UPDATE ${tableName.value} SET ? WHERE id = ?`;
break;
case "delete":
sql = `DELETE FROM ${tableName.value} WHERE id = ?`;
break;
case "create_table":
sql = `CREATE TABLE IF NOT EXISTS ${tableName.value} (id INT AUTO_INCREMENT PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`;
break;
case "drop_table":
sql = `DROP TABLE IF EXISTS ${tableName.value}`;
break;
case "show_tables":
sql = "SHOW TABLES";
break;
case "describe_table":
sql = `DESCRIBE ${tableName.value}`;
break;
}
}
async function executeDatabaseQuery(queryData: {
sql: string;
params?: any;
table?: string;
}): Promise<any> {
// Function implementation
}
// Add to history
queryHistory.value.unshift({
type: queryType.value,
sql,
params: JSON.stringify(params),
result: await executeDatabaseQuery({
sql,
params,
table: tableName.value,
}),
error: "",
success: true,
timestamp: Date.now(),
});
} catch (error) {
// Add error to history
queryHistory.value.unshift({
type: queryType.value,
sql: customQuery.value || sql,
params: queryParams.value,
result: null,
error: error instanceof Error ? error.message : String(error),
success: false,
timestamp: Date.now(),
});
}
};
const clearQueryHistory = () => {
queryHistory.value = [];
};
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
const formatUptime = (uptime: number) => {
const seconds = Math.floor(uptime / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
</script>
<style scoped>
.database-tab {
padding: 20px 0;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 24px;
padding: 24px;
background: #f8f9fa;
border-radius: 8px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.input-group input,
.input-group select,
.input-group textarea {
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.input-group input:focus,
.input-group select:focus,
.input-group textarea:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: #1976d2;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #1565c0;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.query-history-section {
margin: 32px 0;
}
.query-history-section h3 {
margin-bottom: 16px;
color: #333;
font-size: 18px;
}
.query-history {
max-height: 400px;
overflow-y: auto;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e9ecef;
}
.query-item {
margin: 16px 0;
background: white;
border-radius: 8px;
border: 1px solid #e9ecef;
overflow: hidden;
}
.query-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.query-type {
font-weight: 600;
color: #1976d2;
text-transform: uppercase;
font-size: 12px;
}
.query-time {
color: #666;
font-size: 12px;
}
.query-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.query-status.success {
background: #d4edda;
color: #155724;
}
.query-status.error {
background: #f8d7da;
color: #721c24;
}
.query-details {
padding: 16px;
}
.query-sql {
font-family: monospace;
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
margin-bottom: 12px;
font-size: 13px;
color: #333;
}
.query-params,
.query-result,
.query-error {
margin: 12px 0;
font-size: 13px;
}
.query-params strong,
.query-result strong,
.query-error strong {
color: #333;
display: block;
margin-bottom: 4px;
}
.query-result pre {
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
color: #333;
}
.query-error {
color: #dc3545;
background: #f8d7da;
padding: 12px;
border-radius: 4px;
}
.no-queries {
text-align: center;
color: #666;
font-style: italic;
padding: 40px;
}
.database-stats-section {
margin-top: 32px;
}
.database-stats-section h3 {
margin-bottom: 16px;
color: #333;
font-size: 18px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.stat-item {
background: white;
padding: 20px;
border-radius: 8px;
border: 1px solid #e9ecef;
text-align: center;
}
.stat-label {
color: #666;
font-size: 14px;
margin-bottom: 8px;
}
.stat-value {
color: #333;
font-size: 24px;
font-weight: 600;
}
/* Responsive Design */
@media (max-width: 768px) {
.controls {
grid-template-columns: 1fr;
padding: 16px;
}
.query-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,602 @@
<template>
<div class="messaging-tab">
<!-- Connection Status -->
<div class="connection-status" :class="connectionState.connectionStatus">
<div class="status-indicator">
<div class="status-dot" :class="connectionState.connectionStatus"></div>
<span class="status-text">
{{
connectionState.connectionStatus === "connected"
? "Connected"
: connectionState.connectionStatus === "connecting"
? "Connecting..."
: connectionState.connectionStatus === "error"
? "Connection Error"
: "Disconnected"
}}
</span>
</div>
<div class="connection-details" v-if="connectionState.clientId">
<span>Client ID: {{ connectionState.clientId }}</span>
<span v-if="connectionState.currentRoom"
>Room: {{ connectionState.currentRoom }}</span
>
<span v-if="connectionState.connectionLatency > 0"
>Latency: {{ connectionState.connectionLatency }}ms</span
>
<span v-if="connectionState.connectionHealth"
>Health: {{ connectionState.connectionHealth }}</span
>
</div>
</div>
<div class="controls">
<div class="input-group">
<label for="broadcastMessage">Broadcast Message</label>
<textarea
id="broadcastMessage"
v-model="messageText"
placeholder="Enter message to broadcast to all clients"
rows="3"
></textarea>
</div>
<div class="input-group">
<label for="targetClientId"
>Target Client ID (for direct messages)</label
>
<input
id="targetClientId"
v-model="targetClientId"
type="text"
placeholder="Client ID for direct message"
/>
</div>
<div class="input-group">
<label for="roomMessage">Room Message</label>
<textarea
id="roomMessage"
v-model="roomMessage"
placeholder="Enter message to send to specific room"
rows="3"
></textarea>
</div>
<div class="input-group">
<label for="targetRoom">Target Room</label>
<input
id="targetRoom"
v-model="targetRoom"
type="text"
placeholder="Room name"
/>
</div>
</div>
<div class="controls">
<button
class="btn btn-primary"
:disabled="!isConnected || !messageText.trim()"
@click="handleSendBroadcast"
>
Send Broadcast
</button>
<button
class="btn btn-info"
:disabled="
!isConnected || !targetClientId.trim() || !messageText.trim()
"
@click="handleSendDirectMessage"
>
Send Direct Message
</button>
<button
class="btn btn-success"
:disabled="!isConnected || !targetRoom.trim() || !roomMessage.trim()"
@click="handleSendRoomMessage"
>
Send Room Message
</button>
<button
class="btn btn-warning"
:disabled="!isConnected"
@click="sendHeartbeat"
>
Send Heartbeat
</button>
</div>
<div class="controls">
<button
class="btn btn-secondary"
:disabled="!isConnected"
@click="getOnlineUsers"
>
Get Online Users
</button>
<button
class="btn btn-secondary"
:disabled="!isConnected"
@click="getServerInfo"
>
Get Server Info
</button>
<button class="btn btn-secondary" @click="clearMessages">
Clear Messages
</button>
<button class="btn btn-secondary" @click="clearActivityLog">
Clear Activity Log
</button>
</div>
<!-- Online Users -->
<div class="online-users-section" v-if="onlineUsers.length > 0">
<h3>Online Users ({{ onlineUsers.length }})</h3>
<div class="online-users">
<div
v-for="user in onlineUsers"
:key="user.client_id"
class="user-item"
:class="{
'current-user': user.client_id === connectionState.clientId,
}"
>
<div class="user-info">
<div class="user-id">{{ user.client_id }}</div>
<div class="user-details">
<span>Room: {{ user.room }}</span>
<span>IP: {{ user.ip_address }}</span>
<span>Connected: {{ formatTime(user.connected_at) }}</span>
</div>
</div>
<div class="user-actions">
<button
class="btn btn-sm btn-info"
@click="setTargetClient(user.client_id)"
>
Direct Message
</button>
</div>
</div>
</div>
</div>
<!-- Activity Log -->
<div class="activity-log-section">
<h3>Activity Log</h3>
<div class="activity-log">
<div
v-for="(activity, index) in activityLog.slice(0, 20)"
:key="index"
class="activity-item"
>
<div class="activity-time">{{ formatTime(activity.timestamp) }}</div>
<div class="activity-event">{{ activity.event }}</div>
<div class="activity-details">{{ activity.details }}</div>
</div>
<div v-if="activityLog.length === 0" class="no-activity">
No activity logged yet
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useWebSocket } from "../../composables/useWebSocket";
const {
isConnected,
connectionState,
onlineUsers,
activityLog,
broadcastMessage,
sendDirectMessage,
sendRoomMessage,
sendHeartbeat,
getOnlineUsers,
getServerInfo,
clearMessages,
clearActivityLog,
connect,
} = useWebSocket();
const messageText = ref("");
const targetClientId = ref("");
const roomMessage = ref("");
const targetRoom = ref("");
const handleSendBroadcast = () => {
if (messageText.value.trim()) {
broadcastMessage(messageText.value.trim());
messageText.value = "";
}
};
const handleSendDirectMessage = () => {
if (targetClientId.value.trim() && messageText.value.trim()) {
sendDirectMessage(targetClientId.value.trim(), messageText.value.trim());
messageText.value = "";
}
};
const handleSendRoomMessage = () => {
if (targetRoom.value.trim() && roomMessage.value.trim()) {
sendRoomMessage(targetRoom.value.trim(), roomMessage.value.trim());
roomMessage.value = "";
}
};
const setTargetClient = (clientId: string) => {
targetClientId.value = clientId;
};
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
// Initialize connection on component mount
onMounted(() => {
console.log("MessagingTab mounted, initializing WebSocket connection...");
if (!isConnected.value) {
console.log("Attempting to connect to WebSocket...");
connect();
}
});
</script>
<style scoped>
.messaging-tab {
padding: 20px 0;
}
/* Connection Status Styles */
.connection-status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
margin-bottom: 24px;
border-radius: 8px;
border: 2px solid;
font-weight: 600;
}
.connection-status.connected {
background: #e8f5e8;
border-color: #4caf50;
color: #2e7d32;
}
.connection-status.connecting {
background: #fff3e0;
border-color: #ff9800;
color: #ef6c00;
}
.connection-status.error {
background: #ffebee;
border-color: #f44336;
color: #c62828;
}
.connection-status.disconnected {
background: #f5f5f5;
border-color: #9e9e9e;
color: #616161;
}
.status-indicator {
display: flex;
align-items: center;
gap: 12px;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.connected {
background: #4caf50;
}
.status-dot.connecting {
background: #ff9800;
animation: pulse 1s infinite;
}
.status-dot.error {
background: #f44336;
animation: pulse 0.5s infinite;
}
.status-dot.disconnected {
background: #9e9e9e;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.connection-details {
display: flex;
gap: 20px;
font-size: 12px;
opacity: 0.8;
}
.connection-details span {
padding: 4px 8px;
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 24px;
padding: 24px;
background: #f8f9fa;
border-radius: 8px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.input-group input,
.input-group textarea {
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.input-group input:focus,
.input-group textarea:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: #1976d2;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #1565c0;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-info:hover:not(:disabled) {
background: #138496;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #218838;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-warning:hover:not(:disabled) {
background: #e0a800;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 8px 16px;
font-size: 12px;
}
.online-users-section {
margin: 32px 0;
}
.online-users-section h3 {
margin-bottom: 16px;
color: #333;
font-size: 18px;
}
.online-users {
max-height: 400px;
overflow-y: auto;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e9ecef;
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
margin: 8px 0;
background: white;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #e9ecef;
}
.user-item.current-user {
border-left: 4px solid #1976d2;
background: #f0f8ff;
}
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-id {
font-weight: 600;
color: #333;
}
.user-details {
font-size: 12px;
color: #666;
display: flex;
gap: 12px;
}
.user-actions {
display: flex;
gap: 8px;
}
.activity-log-section {
margin-top: 32px;
}
.activity-log-section h3 {
margin-bottom: 16px;
color: #333;
font-size: 18px;
}
.activity-log {
max-height: 300px;
overflow-y: auto;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e9ecef;
}
.activity-item {
display: grid;
grid-template-columns: 150px 120px 1fr;
gap: 16px;
padding: 12px 16px;
margin: 8px 0;
background: white;
border-radius: 6px;
border: 1px solid #e9ecef;
font-size: 13px;
}
.activity-time {
color: #666;
font-family: monospace;
}
.activity-event {
font-weight: 600;
color: #333;
}
.activity-details {
color: #666;
}
.no-activity {
text-align: center;
color: #666;
font-style: italic;
padding: 40px;
}
/* Responsive Design */
@media (max-width: 768px) {
.controls {
grid-template-columns: 1fr;
padding: 16px;
}
.activity-item {
grid-template-columns: 1fr;
gap: 8px;
}
.user-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.user-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,637 @@
<template>
<div class="monitoring-tab">
<div class="controls">
<div class="input-group">
<label for="notificationChannel">Notification Channel</label>
<input
id="notificationChannel"
v-model="notificationChannel"
type="text"
placeholder="Enter notification channel"
/>
</div>
<div class="input-group">
<label for="notificationPayload">Notification Payload (JSON)</label>
<textarea
id="notificationPayload"
v-model="notificationPayload"
placeholder='{"message": "Hello", "type": "info"}'
rows="3"
></textarea>
</div>
<div class="input-group">
<label for="triggerEvent">Trigger Event</label>
<select id="triggerEvent" v-model="triggerEvent">
<option value="user_joined">User Joined</option>
<option value="user_left">User Left</option>
<option value="message_sent">Message Sent</option>
<option value="connection_lost">Connection Lost</option>
<option value="server_restart">Server Restart</option>
<option value="custom">Custom Event</option>
</select>
</div>
<div class="input-group">
<label for="customEvent">Custom Event Name</label>
<input
id="customEvent"
v-model="customEvent"
type="text"
placeholder="custom_event_name"
:disabled="triggerEvent !== 'custom'"
/>
</div>
</div>
<div class="controls">
<button
class="btn btn-primary"
:disabled="
!isConnected ||
!notificationChannel.trim() ||
!notificationPayload.trim()
"
@click="triggerNotification"
>
Trigger Notification
</button>
<button class="btn btn-info" :disabled="!isConnected" @click="getStats">
Refresh Stats
</button>
<button
class="btn btn-secondary"
:disabled="!isConnected"
@click="getMonitoringData"
>
Get Monitoring Data
</button>
<button class="btn btn-warning" @click="clearMonitoringData">
Clear Data
</button>
</div>
<!-- Connection Health -->
<div class="health-section">
<h3>Connection Health</h3>
<div class="health-grid">
<div class="health-item">
<div class="health-label">Status</div>
<div class="health-value" :class="connectionHealthClass">
{{ connectionHealthText }}
</div>
</div>
<div class="health-item">
<div class="health-label">Latency</div>
<div class="health-value">
{{ connectionState.connectionLatency }}ms
</div>
</div>
<div class="health-item">
<div class="health-label">Messages Sent</div>
<div class="health-value">{{ connectionState.messagesSent }}</div>
</div>
<div class="health-item">
<div class="health-label">Messages Received</div>
<div class="health-value">{{ connectionState.messagesReceived }}</div>
</div>
<div class="health-item">
<div class="health-label">Reconnect Attempts</div>
<div class="health-value">
{{ connectionState.reconnectAttempts }}
</div>
</div>
<div class="health-item">
<div class="health-label">Connection Time</div>
<div class="health-value">{{ connectionState.uptime }}</div>
</div>
</div>
</div>
<!-- Server Statistics -->
<div class="stats-section" v-if="stats">
<h3>Server Statistics</h3>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">Connected Clients</div>
<div class="stat-value">{{ stats.connected_clients }}</div>
<div
class="stat-change"
:class="getStatChangeClass('connected_clients')"
>
{{ getStatChange("connected_clients") }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">Unique IPs</div>
<div class="stat-value">{{ stats.unique_ips }}</div>
<div class="stat-change" :class="getStatChangeClass('unique_ips')">
{{ getStatChange("unique_ips") }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">Static Clients</div>
<div class="stat-value">{{ stats.static_clients }}</div>
<div
class="stat-change"
:class="getStatChangeClass('static_clients')"
>
{{ getStatChange("static_clients") }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">Active Rooms</div>
<div class="stat-value">{{ stats.active_rooms }}</div>
<div class="stat-change" :class="getStatChangeClass('active_rooms')">
{{ getStatChange("active_rooms") }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">Message Queue</div>
<div class="stat-value">{{ stats.message_queue_size }}</div>
<div
class="stat-change"
:class="getStatChangeClass('message_queue_size')"
>
{{ getStatChange("message_queue_size") }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">Queue Workers</div>
<div class="stat-value">{{ stats.queue_workers }}</div>
<div class="stat-change" :class="getStatChangeClass('queue_workers')">
{{ getStatChange("queue_workers") }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">Server Uptime</div>
<div class="stat-value">{{ formatUptime(stats.uptime) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Last Updated</div>
<div class="stat-value">{{ formatTime(stats.timestamp) }}</div>
</div>
</div>
</div>
<!-- Monitoring Data -->
<div class="monitoring-data-section" v-if="monitoringData">
<h3>Monitoring Data</h3>
<div class="monitoring-content">
<pre>{{ JSON.stringify(monitoringData, null, 2) }}</pre>
</div>
</div>
<!-- Notification History -->
<div class="notification-history-section">
<h3>Notification History</h3>
<div class="notification-history">
<div
v-for="(notification, index) in notificationHistory.slice(0, 10)"
:key="index"
class="notification-item"
>
<div class="notification-header">
<div class="notification-channel">{{ notification.channel }}</div>
<div class="notification-time">
{{ formatTime(notification.timestamp) }}
</div>
</div>
<div class="notification-payload">
<pre>{{ JSON.stringify(notification.payload, null, 2) }}</pre>
</div>
</div>
<div v-if="notificationHistory.length === 0" class="no-notifications">
No notifications sent yet
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useWebSocket } from "../../composables/useWebSocket";
const {
isConnected,
connectionState,
stats,
monitoringData,
triggerNotification: triggerNotificationFn,
getStats,
getMonitoringData,
} = useWebSocket();
const notificationChannel = ref("");
const notificationPayload = ref("");
const triggerEvent = ref("user_joined");
const customEvent = ref("");
const notificationHistory = ref<
Array<{
channel: string;
payload: any;
timestamp: number;
}>
>([]);
const connectionHealthClass = computed(() => {
switch (connectionState.connectionHealth) {
case "excellent":
return "health-excellent";
case "good":
return "health-good";
case "warning":
return "health-warning";
case "poor":
return "health-poor";
default:
return "health-poor";
}
});
const connectionHealthText = computed(() => {
switch (connectionState.connectionHealth) {
case "excellent":
return "Excellent";
case "good":
return "Good";
case "warning":
return "Warning";
case "poor":
return "Poor";
default:
return "Unknown";
}
});
const triggerNotification = () => {
if (!notificationChannel.value.trim() || !notificationPayload.value.trim())
return;
try {
const payload = JSON.parse(notificationPayload.value);
const eventName =
triggerEvent.value === "custom" ? customEvent.value : triggerEvent.value;
triggerNotificationFn(notificationChannel.value.trim());
// Add to history
notificationHistory.value.unshift({
channel: notificationChannel.value.trim(),
payload,
timestamp: Date.now(),
});
// Clear form
notificationChannel.value = "";
notificationPayload.value = "";
} catch (error) {
alert(
"Invalid JSON payload: " +
(error instanceof Error ? error.message : String(error))
);
}
};
const clearMonitoringData = () => {
notificationHistory.value = [];
};
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
const formatUptime = (uptime: number) => {
const seconds = Math.floor(uptime / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
// Mock stat changes for demonstration
const previousStats = ref<any>(null);
const getStatChange = (statName: string) => {
if (!previousStats.value || !stats.value) return "";
const current = stats.value[statName as keyof typeof stats.value] as number;
const previous =
previousStats.value[statName as keyof typeof previousStats.value];
if (current > previous) return `+${current - previous}`;
if (current < previous) return `-${previous - current}`;
return "0";
};
const getStatChangeClass = (statName: string) => {
if (!previousStats.value || !stats.value) return "";
const current = stats.value[statName as keyof typeof stats.value];
const previous =
previousStats.value[statName as keyof typeof previousStats.value];
if (current > previous) return "stat-increase";
if (current < previous) return "stat-decrease";
return "stat-unchanged";
};
// Update previous stats when new stats arrive
const updatePreviousStats = () => {
if (stats.value) {
previousStats.value = { ...stats.value };
}
};
// Watch for stats changes
import { watch } from "vue";
watch(stats, updatePreviousStats, { deep: true });
</script>
<style scoped>
.monitoring-tab {
padding: 20px 0;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 24px;
padding: 24px;
background: #f8f9fa;
border-radius: 8px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.input-group input,
.input-group select,
.input-group textarea {
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.input-group input:focus,
.input-group select:focus,
.input-group textarea:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.input-group input:disabled {
background: #e9ecef;
cursor: not-allowed;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: #1976d2;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #1565c0;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-info:hover:not(:disabled) {
background: #138496;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.health-section,
.stats-section {
margin: 32px 0;
}
.health-section h3,
.stats-section h3 {
margin-bottom: 16px;
color: #333;
font-size: 18px;
}
.health-grid,
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.health-item,
.stat-item {
background: white;
padding: 20px;
border-radius: 8px;
border: 1px solid #e9ecef;
text-align: center;
}
.health-label,
.stat-label {
color: #666;
font-size: 14px;
margin-bottom: 8px;
}
.health-value {
color: #333;
font-size: 24px;
font-weight: 600;
}
.stat-value {
color: #333;
font-size: 24px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-change {
font-size: 12px;
font-weight: 600;
}
.stat-increase {
color: #28a745;
}
.stat-decrease {
color: #dc3545;
}
.stat-unchanged {
color: #6c757d;
}
.monitoring-data-section {
margin: 32px 0;
}
.monitoring-data-section h3 {
margin-bottom: 16px;
color: #333;
font-size: 18px;
}
.monitoring-content {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
overflow-x: auto;
}
.monitoring-content pre {
margin: 0;
font-size: 12px;
color: #333;
}
.notification-history-section {
margin-top: 32px;
}
.notification-history-section h3 {
margin-bottom: 16px;
color: #333;
font-size: 18px;
}
.notification-history {
max-height: 300px;
overflow-y: auto;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e9ecef;
}
.notification-item {
margin: 16px 0;
background: white;
border-radius: 8px;
border: 1px solid #e9ecef;
overflow: hidden;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.notification-channel {
font-weight: 600;
color: #1976d2;
}
.notification-time {
color: #666;
font-size: 12px;
}
.notification-payload {
padding: 16px;
}
.notification-payload pre {
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
color: #333;
margin: 0;
}
.no-notifications {
text-align: center;
color: #666;
font-style: italic;
padding: 40px;
}
/* Responsive Design */
@media (max-width: 768px) {
.controls {
grid-template-columns: 1fr;
padding: 16px;
}
.health-grid,
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,318 @@
import { ref, computed, reactive, nextTick } from 'vue'
import type {
WebSocketMessage,
ConnectionState,
WebSocketConfig,
MessageHistory,
ConnectionStats,
MonitoringData,
ClientInfo,
OnlineUser,
ActivityLog
} from '../types/websocket'
export const useWebSocket = () => {
// Check if we're in browser environment
const isBrowser = process.client
const ws = ref<WebSocket | null>(null)
const isConnected = ref(false)
const isConnecting = ref(false)
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected')
const connectionState = reactive<ConnectionState>({
isConnected: false,
isConnecting: false,
connectionStatus: 'disconnected',
clientId: null,
staticId: null,
currentRoom: null,
userId: 'anonymous',
ipAddress: null,
connectionStartTime: null,
lastPingTime: null,
connectionLatency: 0,
connectionHealth: 'poor',
reconnectAttempts: 0,
messagesReceived: 0,
messagesSent: 0,
uptime: '00:00:00'
})
const config = reactive<WebSocketConfig>({
wsUrl: 'ws://localhost:8080/api/v1/ws',
userId: 'anonymous',
room: 'default',
staticId: '',
useIPBasedId: false,
autoReconnect: true,
heartbeatEnabled: true,
maxReconnectAttempts: 10,
reconnectDelay: 1000,
maxReconnectDelay: 30000,
heartbeatInterval: 30000,
heartbeatTimeout: 5000,
maxMissedHeartbeats: 3,
maxMessages: 1000,
messageWarningThreshold: 800,
actionThrottle: 100
})
const messages = ref<MessageHistory[]>([])
const stats = ref<ConnectionStats | null>(null)
const monitoringData = ref<MonitoringData | null>(null)
const onlineUsers = ref<OnlineUser[]>([])
const activityLog = ref<ActivityLog[]>([])
let reconnectTimeout: number | null = null
let heartbeatInterval: number | null = null
let heartbeatTimeout: number | null = null
let missedHeartbeats = 0
let lastHeartbeatTime = 0
let messageCount = 0
// Only run WebSocket logic in browser
if (isBrowser) {
// WebSocket connection logic here
}
const addMessage = (type: string, data: any, messageId?: string) => {
if (!isBrowser) return
const message: MessageHistory = {
timestamp: new Date(),
type,
data,
messageId,
size: JSON.stringify(data).length
}
messages.value.unshift(message)
messageCount++
// Keep only the last maxMessages
if (messages.value.length > config.maxMessages) {
messages.value = messages.value.slice(0, config.maxMessages)
}
// Update connection state
connectionState.messagesReceived++
}
const connectionHealthColor = computed(() => {
switch (connectionState.connectionHealth) {
case 'excellent': return '#4CAF50'
case 'good': return '#2196F3'
case 'warning': return '#FFC107'
case 'poor': return '#F44336'
default: return '#9E9E9E'
}
})
const connectionHealthText = computed(() => {
switch (connectionState.connectionHealth) {
case 'excellent': return 'Excellent'
case 'good': return 'Good'
case 'warning': return 'Warning'
case 'poor': return 'Poor'
default: return 'Unknown'
}
})
// Admin functionality
const serverInfo = ref<any>(null)
const systemHealth = ref<any>(null)
const executeAdminCommand = async (command: string, params: any) => {
if (!isBrowser || !ws.value) throw new Error('Not connected')
const message = {
type: 'admin_command',
command,
params,
timestamp: Date.now()
}
ws.value.send(JSON.stringify(message))
return { success: true, message: 'Command sent successfully' }
}
const getServerInfo = async () => {
if (!isBrowser || !ws.value) throw new Error('Not connected')
const message = {
type: 'get_server_info',
timestamp: Date.now()
}
ws.value.send(JSON.stringify(message))
}
const getSystemHealth = async () => {
if (!isBrowser || !ws.value) throw new Error('Not connected')
const message = {
type: 'get_system_health',
timestamp: Date.now()
}
ws.value.send(JSON.stringify(message))
}
// Cleanup on unmount
const cleanup = () => {
if (!isBrowser) return
disconnect()
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
}
stopHeartbeat()
}
// WebSocket connection methods (only available in browser)
const connect = () => {
if (!isBrowser) return
// WebSocket connection logic
}
const disconnect = () => {
if (!isBrowser) return
// WebSocket disconnection logic
}
const sendMessage = (message: any) => {
if (!isBrowser || !ws.value) return
// Send message logic
}
const broadcastMessage = (message: string) => {
if (!isBrowser || !ws.value) return
// Broadcast message logic
}
const sendDirectMessage = (clientId: string, message: string) => {
if (!isBrowser || !ws.value) return
// Direct message logic
}
const sendRoomMessage = (room: string, message: string) => {
if (!isBrowser || !ws.value) return
// Room message logic
}
const getOnlineUsers = () => {
if (!isBrowser || !ws.value) return
// Get online users logic
}
const testConnection = () => {
if (!isBrowser || !ws.value) return
// Test connection logic
}
const sendHeartbeat = () => {
if (!isBrowser || !ws.value) return
// Send heartbeat logic
}
const executeDatabaseQuery = async (query: string) => {
if (!isBrowser || !ws.value) return
// Database query logic
}
const triggerNotification = async (message: string) => {
if (!isBrowser || !ws.value) return
// Notification logic
}
const getStats = () => {
if (!isBrowser || !ws.value) return
// Get stats logic
}
const getMonitoringData = () => {
if (!isBrowser || !ws.value) return
// Get monitoring data logic
}
const clearMessages = () => {
if (!isBrowser) return
messages.value = []
messageCount = 0
}
const clearActivityLog = () => {
if (!isBrowser) return
activityLog.value = []
}
const getMessagesByType = (type: string) => {
return messages.value.filter(msg => msg.type === type)
}
const getRecentMessages = (count: number = 10) => {
return messages.value.slice(0, count)
}
const stopHeartbeat = () => {
if (!isBrowser) return
// Stop heartbeat logic
}
const isMessageLimitReached = computed(() => {
return messages.value.length >= config.maxMessages
})
const shouldShowMessageWarning = computed(() => {
return messages.value.length >= config.messageWarningThreshold
})
return {
// State
ws,
isConnected,
isConnecting,
connectionStatus,
connectionState,
config,
messages,
stats,
monitoringData,
onlineUsers,
activityLog,
// Admin state
serverInfo,
systemHealth,
// Methods
connect,
disconnect,
sendMessage,
broadcastMessage,
sendDirectMessage,
sendRoomMessage,
getServerInfo,
getOnlineUsers,
testConnection,
sendHeartbeat,
executeDatabaseQuery,
triggerNotification,
getStats,
getMonitoringData,
executeAdminCommand,
getSystemHealth,
clearMessages,
clearActivityLog,
getMessagesByType,
getRecentMessages,
cleanup,
// Computed
isMessageLimitReached,
shouldShowMessageWarning,
connectionHealthColor,
connectionHealthText
}
}

View File

@@ -0,0 +1,22 @@
export default defineNuxtConfig({
devtools: { enabled: true },
modules: [],
css: ['~/assets/css/main.css', 'vuetify/styles'],
build: {
transpile: ['vuetify']
},
runtimeConfig: {
public: {
wsUrl: 'ws://localhost:8080/api/v1/ws'
}
},
typescript: {
typeCheck: false
},
vite: {
define: {
global: 'globalThis'
}
},
compatibilityDate: '2024-04-03'
})

11504
examples/clientsocket/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"name": "nuxt3-websocket-client",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"nuxt": "^3.8.0",
"vue": "^3.3.8",
"vue-router": "^4.2.5",
"vue-tsc": "^3.0.8"
},
"dependencies": {
"@mdi/font": "^7.3.67",
"@nuxtjs/vuetify": "^1.12.3",
"highlight.js": "^11.9.0",
"pinia": "^2.1.7",
"vue3-highlightjs": "^1.0.5",
"vuetify": "^3.4.0"
}
}

View File

@@ -0,0 +1,14 @@
<template>
<div>
<WebSocketClient />
</div>
</template>
<script setup lang="ts">
// Main page for the WebSocket client application
// This page serves as the entry point and displays the WebSocket client interface
</script>
<style scoped>
/* Additional page-specific styles if needed */
</style>

View File

@@ -0,0 +1,15 @@
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
export default defineNuxtPlugin((nuxtApp) => {
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'light'
}
})
nuxtApp.vueApp.use(vuetify)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

View File

@@ -0,0 +1,182 @@
export interface WebSocketMessage {
type: string;
data: any;
timestamp?: number;
client_id?: string;
message_id?: string;
}
export interface ConnectionInfo {
client_id: string;
static_id: string;
ip_address: string;
room: string;
user_id: string;
connected_at: number;
id_type: string;
}
export interface ClientInfo {
id: string;
static_id: string;
ip_address: string;
user_id: string;
room: string;
connected_at: number;
last_ping: number;
is_active?: boolean;
}
export interface OnlineUser {
client_id: string;
static_id: string;
user_id: string;
room: string;
ip_address: string;
connected_at: number;
last_ping: number;
}
export interface ConnectionStats {
connected_clients: number;
unique_ips: number;
static_clients: number;
active_rooms: number;
ip_distribution: Record<string, number>;
room_distribution: Record<string, number>;
message_queue_size: number;
queue_workers: number;
uptime: number;
timestamp: number;
}
export interface SystemHealth {
databases: any;
available_dbs: string[];
websocket_status: string;
uptime_seconds: number;
}
export interface PerformanceMetrics {
messages_per_second: number;
average_latency_ms: number;
error_rate_percent: number;
memory_usage_bytes: number;
}
export interface MonitoringData {
stats: ConnectionStats;
recent_activity: ActivityLog[];
system_health: SystemHealth;
performance: PerformanceMetrics;
}
export interface ActivityLog {
timestamp: number;
event: string;
client_id: string;
details: string;
}
export interface MessageHistory {
timestamp: Date;
type: string;
data: any;
messageId?: string;
size: number;
icon?: string;
timeString?: string;
}
export interface ConnectionState {
isConnected: boolean;
isConnecting: boolean;
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
clientId: string | null;
staticId: string | null;
currentRoom: string | null;
userId: string;
ipAddress: string | null;
connectionStartTime: number | null;
lastPingTime: number | null;
connectionLatency: number;
connectionHealth: "poor" | "warning" | "good" | "excellent";
reconnectAttempts: number;
messagesReceived: number;
messagesSent: number;
uptime: string;
}
export interface WebSocketConfig {
wsUrl: string;
userId: string;
room: string;
staticId?: string;
useIPBasedId?: boolean;
autoReconnect: boolean;
heartbeatEnabled: boolean;
maxReconnectAttempts: number;
reconnectDelay: number;
maxReconnectDelay: number;
heartbeatInterval: number;
heartbeatTimeout: number;
maxMissedHeartbeats: number;
maxMessages: number;
messageWarningThreshold: number;
actionThrottle: number;
}
export type MessageType =
| "welcome"
| "broadcast"
| "direct_message"
| "room_message"
| "ping"
| "pong"
| "heartbeat"
| "heartbeat_ack"
| "connection_test"
| "connection_test_result"
| "get_online_users"
| "online_users"
| "get_server_info"
| "server_info"
| "error"
| "message_received"
| "broadcast_sent"
| "direct_message_sent"
| "room_message_sent"
| "db_insert"
| "db_query"
| "db_custom_query"
| "query_result"
| "admin_kick_client"
| "admin_kill_server"
| "get_server_stats"
| "get_system_health"
| "admin_clear_logs"
| "get_stats"
| "get_room_info"
| "join_room"
| "leave_room"
| "database_change"
| "data_stream"
| "server_heartbeat"
| "system_status"
| "clients_by_ip"
| "client_info"
| "get_clients_by_ip"
| "get_client_info"
| "health_check"
| "database_list"
| "connection_stats"
| "trigger_notification"
| "notification_sent"
| "API_TEST"
| "manual_test"
| "retribusi_created"
| "retribusi_updated"
| "retribusi_deleted"
| "peserta_changes"
| "retribusi_changes"
| "system_changes";

90
go.mod Normal file
View File

@@ -0,0 +1,90 @@
module api-service
go 1.24.4
require (
github.com/gin-gonic/gin v1.10.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
github.com/jackc/pgx/v5 v5.7.2 // Ensure pgx is a direct dependency
go.mongodb.org/mongo-driver v1.17.3
golang.org/x/crypto v0.41.0
golang.org/x/sync v0.16.0
gorm.io/driver/mysql v1.6.0 // GORM MySQL driver
gorm.io/driver/postgres v1.5.11 // Added GORM PostgreSQL driver
gorm.io/driver/sqlserver v1.6.1 // GORM SQL Server driver
)
require (
github.com/daku10/go-lz-string v0.0.6
github.com/go-playground/validator/v10 v10.27.0
github.com/go-sql-driver/mysql v1.8.1
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/mashingan/smapping v0.1.19
github.com/rs/zerolog v1.34.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.6
github.com/tidwall/gjson v1.18.0
gopkg.in/yaml.v2 v2.4.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microsoft/go-mssqldb v1.8.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/gorm v1.30.0 // indirect
)

361
go.sum Normal file
View File

@@ -0,0 +1,361 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/daku10/go-lz-string v0.0.6 h1:aO8FFp4QPuNp7+WNyh1DyNjGF3UbZu95tUv9xOZNsYQ=
github.com/daku10/go-lz-string v0.0.6/go.mod h1:Vk++rSG3db8HXJaHEAbxiy/ukjTmPBw/iI+SrVZDzfs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mashingan/smapping v0.1.19 h1:SsEtuPn2UcM1croIupPtGLgWgpYRuS0rSQMvKD9g2BQ=
github.com/mashingan/smapping v0.1.19/go.mod h1:FjfiwFxGOuNxL/OT1WcrNAwTPx0YJeg5JiXwBB1nyig=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

739
internal/config/config.go Normal file
View File

@@ -0,0 +1,739 @@
package config
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/go-playground/validator/v10"
)
type Config struct {
Server ServerConfig
Databases map[string]DatabaseConfig
ReadReplicas map[string][]DatabaseConfig // For read replicas
Keycloak KeycloakConfig
Bpjs BpjsConfig
SatuSehat SatuSehatConfig
Swagger SwaggerConfig
Validator *validator.Validate
}
type SwaggerConfig struct {
Title string
Description string
Version string
TermsOfService string
ContactName string
ContactURL string
ContactEmail string
LicenseName string
LicenseURL string
Host string
BasePath string
Schemes []string
}
type ServerConfig struct {
Port int
Mode string
}
type DatabaseConfig struct {
Name string
Type string // postgres, mysql, sqlserver, sqlite, mongodb
Host string
Port int
Username string
Password string
Database string
Schema string
SSLMode string
Path string // For SQLite
Options string // Additional connection options
MaxOpenConns int // Max open connections
MaxIdleConns int // Max idle connections
ConnMaxLifetime time.Duration // Connection max lifetime
}
type KeycloakConfig struct {
Issuer string
Audience string
JwksURL string
Enabled bool
}
type BpjsConfig struct {
BaseURL string `json:"base_url"`
ConsID string `json:"cons_id"`
UserKey string `json:"user_key"`
SecretKey string `json:"secret_key"`
Timeout time.Duration `json:"timeout"`
}
type SatuSehatConfig struct {
OrgID string `json:"org_id"`
FasyakesID string `json:"fasyakes_id"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AuthURL string `json:"auth_url"`
BaseURL string `json:"base_url"`
ConsentURL string `json:"consent_url"`
KFAURL string `json:"kfa_url"`
Timeout time.Duration `json:"timeout"`
}
// SetHeader generates required headers for BPJS VClaim API
// func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) {
// timenow := time.Now().UTC()
// t, err := time.Parse(time.RFC3339, "1970-01-01T00:00:00Z")
// if err != nil {
// log.Fatal(err)
// }
// tstamp := timenow.Unix() - t.Unix()
// secret := []byte(cfg.SecretKey)
// message := []byte(cfg.ConsID + "&" + fmt.Sprint(tstamp))
// hash := hmac.New(sha256.New, secret)
// hash.Write(message)
// // to lowercase hexits
// hex.EncodeToString(hash.Sum(nil))
// // to base64
// xSignature := base64.StdEncoding.EncodeToString(hash.Sum(nil))
// return cfg.ConsID, cfg.SecretKey, cfg.UserKey, fmt.Sprint(tstamp), xSignature
// }
func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) {
timenow := time.Now().UTC()
t, err := time.Parse(time.RFC3339, "1970-01-01T00:00:00Z")
if err != nil {
log.Fatal(err)
}
tstamp := timenow.Unix() - t.Unix()
secret := []byte(cfg.SecretKey)
message := []byte(cfg.ConsID + "&" + fmt.Sprint(tstamp))
hash := hmac.New(sha256.New, secret)
hash.Write(message)
// to lowercase hexits
hex.EncodeToString(hash.Sum(nil))
// to base64
xSignature := base64.StdEncoding.EncodeToString(hash.Sum(nil))
return cfg.ConsID, cfg.SecretKey, cfg.UserKey, fmt.Sprint(tstamp), xSignature
}
type ConfigBpjs struct {
Cons_id string
Secret_key string
User_key string
}
// SetHeader for backward compatibility
func (cfg ConfigBpjs) SetHeader() (string, string, string, string, string) {
bpjsConfig := BpjsConfig{
ConsID: cfg.Cons_id,
SecretKey: cfg.Secret_key,
UserKey: cfg.User_key,
}
return bpjsConfig.SetHeader()
}
func LoadConfig() *Config {
config := &Config{
Server: ServerConfig{
Port: getEnvAsInt("PORT", 8080),
Mode: getEnv("GIN_MODE", "debug"),
},
Databases: make(map[string]DatabaseConfig),
ReadReplicas: make(map[string][]DatabaseConfig),
Keycloak: KeycloakConfig{
Issuer: getEnv("KEYCLOAK_ISSUER", "https://keycloak.example.com/auth/realms/yourrealm"),
Audience: getEnv("KEYCLOAK_AUDIENCE", "your-client-id"),
JwksURL: getEnv("KEYCLOAK_JWKS_URL", "https://keycloak.example.com/auth/realms/yourrealm/protocol/openid-connect/certs"),
Enabled: getEnvAsBool("KEYCLOAK_ENABLED", true),
},
Bpjs: BpjsConfig{
BaseURL: getEnv("BPJS_BASEURL", "https://apijkn.bpjs-kesehatan.go.id"),
ConsID: getEnv("BPJS_CONSID", ""),
UserKey: getEnv("BPJS_USERKEY", ""),
SecretKey: getEnv("BPJS_SECRETKEY", ""),
Timeout: parseDuration(getEnv("BPJS_TIMEOUT", "30s")),
},
SatuSehat: SatuSehatConfig{
OrgID: getEnv("BRIDGING_SATUSEHAT_ORG_ID", ""),
FasyakesID: getEnv("BRIDGING_SATUSEHAT_FASYAKES_ID", ""),
ClientID: getEnv("BRIDGING_SATUSEHAT_CLIENT_ID", ""),
ClientSecret: getEnv("BRIDGING_SATUSEHAT_CLIENT_SECRET", ""),
AuthURL: getEnv("BRIDGING_SATUSEHAT_AUTH_URL", "https://api-satusehat.kemkes.go.id/oauth2/v1"),
BaseURL: getEnv("BRIDGING_SATUSEHAT_BASE_URL", "https://api-satusehat.kemkes.go.id/fhir-r4/v1"),
ConsentURL: getEnv("BRIDGING_SATUSEHAT_CONSENT_URL", "https://api-satusehat.dto.kemkes.go.id/consent/v1"),
KFAURL: getEnv("BRIDGING_SATUSEHAT_KFA_URL", "https://api-satusehat.kemkes.go.id/kfa-v2"),
Timeout: parseDuration(getEnv("BRIDGING_SATUSEHAT_TIMEOUT", "30s")),
},
Swagger: SwaggerConfig{
Title: getEnv("SWAGGER_TITLE", "SERVICE API"),
Description: getEnv("SWAGGER_DESCRIPTION", "CUSTUM SERVICE API"),
Version: getEnv("SWAGGER_VERSION", "1.0.0"),
TermsOfService: getEnv("SWAGGER_TERMS_OF_SERVICE", "http://swagger.io/terms/"),
ContactName: getEnv("SWAGGER_CONTACT_NAME", "API Support"),
ContactURL: getEnv("SWAGGER_CONTACT_URL", "http://rssa.example.com/support"),
ContactEmail: getEnv("SWAGGER_CONTACT_EMAIL", "support@swagger.io"),
LicenseName: getEnv("SWAGGER_LICENSE_NAME", "Apache 2.0"),
LicenseURL: getEnv("SWAGGER_LICENSE_URL", "http://www.apache.org/licenses/LICENSE-2.0.html"),
Host: getEnv("SWAGGER_HOST", "localhost:8080"),
BasePath: getEnv("SWAGGER_BASE_PATH", "/api/v1"),
Schemes: parseSchemes(getEnv("SWAGGER_SCHEMES", "http,https")),
},
}
// Initialize validator
config.Validator = validator.New()
// Load database configurations
config.loadDatabaseConfigs()
// Load read replica configurations
config.loadReadReplicaConfigs()
return config
}
func (c *Config) loadDatabaseConfigs() {
// Simplified approach: Directly load from environment variables
// This ensures we get the exact values specified in .env
// Primary database configuration
c.Databases["default"] = DatabaseConfig{
Name: "default",
Type: getEnv("DB_CONNECTION", "postgres"),
Host: getEnv("DB_HOST", "localhost"),
Port: getEnvAsInt("DB_PORT", 5432),
Username: getEnv("DB_USERNAME", ""),
Password: getEnv("DB_PASSWORD", ""),
Database: getEnv("DB_DATABASE", "satu_db"),
Schema: getEnv("DB_SCHEMA", "public"),
SSLMode: getEnv("DB_SSLMODE", "disable"),
MaxOpenConns: getEnvAsInt("DB_MAX_OPEN_CONNS", 25),
MaxIdleConns: getEnvAsInt("DB_MAX_IDLE_CONNS", 25),
ConnMaxLifetime: parseDuration(getEnv("DB_CONN_MAX_LIFETIME", "5m")),
}
// SATUDATA database configuration
c.addPostgreSQLConfigs()
// MongoDB database configuration
c.addMongoDBConfigs()
// Legacy support for backward compatibility
envVars := os.Environ()
dbConfigs := make(map[string]map[string]string)
// Parse database configurations from environment variables
for _, envVar := range envVars {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := parts[1]
// Parse specific database configurations
if strings.HasSuffix(key, "_CONNECTION") || strings.HasSuffix(key, "_HOST") ||
strings.HasSuffix(key, "_DATABASE") || strings.HasSuffix(key, "_USERNAME") ||
strings.HasSuffix(key, "_PASSWORD") || strings.HasSuffix(key, "_PORT") ||
strings.HasSuffix(key, "_NAME") {
segments := strings.Split(key, "_")
if len(segments) >= 2 {
dbName := strings.ToLower(strings.Join(segments[:len(segments)-1], "_"))
property := strings.ToLower(segments[len(segments)-1])
if dbConfigs[dbName] == nil {
dbConfigs[dbName] = make(map[string]string)
}
dbConfigs[dbName][property] = value
}
}
}
// Create DatabaseConfig from parsed configurations for additional databases
for name, config := range dbConfigs {
// Skip empty configurations or system configurations
if name == "" || strings.Contains(name, "chrome_crashpad_pipe") || name == "primary" {
continue
}
dbConfig := DatabaseConfig{
Name: name,
Type: getEnvFromMap(config, "connection", getEnvFromMap(config, "type", "postgres")),
Host: getEnvFromMap(config, "host", "localhost"),
Port: getEnvAsIntFromMap(config, "port", 5432),
Username: getEnvFromMap(config, "username", ""),
Password: getEnvFromMap(config, "password", ""),
Database: getEnvFromMap(config, "database", getEnvFromMap(config, "name", name)),
Schema: getEnvFromMap(config, "schema", "public"),
SSLMode: getEnvFromMap(config, "sslmode", "disable"),
Path: getEnvFromMap(config, "path", ""),
Options: getEnvFromMap(config, "options", ""),
MaxOpenConns: getEnvAsIntFromMap(config, "max_open_conns", 25),
MaxIdleConns: getEnvAsIntFromMap(config, "max_idle_conns", 25),
ConnMaxLifetime: parseDuration(getEnvFromMap(config, "conn_max_lifetime", "5m")),
}
// Skip if username is empty and it's not a system config
if dbConfig.Username == "" && !strings.HasPrefix(name, "chrome") {
continue
}
c.Databases[name] = dbConfig
}
}
func (c *Config) loadReadReplicaConfigs() {
envVars := os.Environ()
for _, envVar := range envVars {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := parts[1]
// Parse read replica configurations (format: [DBNAME]_REPLICA_[INDEX]_[PROPERTY])
if strings.Contains(key, "_REPLICA_") {
segments := strings.Split(key, "_")
if len(segments) >= 5 && strings.ToUpper(segments[2]) == "REPLICA" {
dbName := strings.ToLower(segments[1])
replicaIndex := segments[3]
property := strings.ToLower(strings.Join(segments[4:], "_"))
replicaKey := dbName + "_replica_" + replicaIndex
if c.ReadReplicas[dbName] == nil {
c.ReadReplicas[dbName] = []DatabaseConfig{}
}
// Find or create replica config
var replicaConfig *DatabaseConfig
for i := range c.ReadReplicas[dbName] {
if c.ReadReplicas[dbName][i].Name == replicaKey {
replicaConfig = &c.ReadReplicas[dbName][i]
break
}
}
if replicaConfig == nil {
// Create new replica config
newConfig := DatabaseConfig{
Name: replicaKey,
Type: c.Databases[dbName].Type,
Host: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_HOST", c.Databases[dbName].Host),
Port: getEnvAsInt("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_PORT", c.Databases[dbName].Port),
Username: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_USERNAME", c.Databases[dbName].Username),
Password: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_PASSWORD", c.Databases[dbName].Password),
Database: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_DATABASE", c.Databases[dbName].Database),
Schema: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_SCHEMA", c.Databases[dbName].Schema),
SSLMode: getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_SSLMODE", c.Databases[dbName].SSLMode),
MaxOpenConns: getEnvAsInt("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_MAX_OPEN_CONNS", c.Databases[dbName].MaxOpenConns),
MaxIdleConns: getEnvAsInt("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_MAX_IDLE_CONNS", c.Databases[dbName].MaxIdleConns),
ConnMaxLifetime: parseDuration(getEnv("DB_"+strings.ToUpper(dbName)+"_REPLICA_"+replicaIndex+"_CONN_MAX_LIFETIME", "5m")),
}
c.ReadReplicas[dbName] = append(c.ReadReplicas[dbName], newConfig)
replicaConfig = &c.ReadReplicas[dbName][len(c.ReadReplicas[dbName])-1]
}
// Update the specific replica
switch property {
case "host":
replicaConfig.Host = value
case "port":
replicaConfig.Port = getEnvAsInt(key, 5432)
case "username":
replicaConfig.Username = value
case "password":
replicaConfig.Password = value
case "database":
replicaConfig.Database = value
case "schema":
replicaConfig.Schema = value
case "sslmode":
replicaConfig.SSLMode = value
case "max_open_conns":
replicaConfig.MaxOpenConns = getEnvAsInt(key, 25)
case "max_idle_conns":
replicaConfig.MaxIdleConns = getEnvAsInt(key, 25)
case "conn_max_lifetime":
replicaConfig.ConnMaxLifetime = parseDuration(value)
}
}
}
}
}
func (c *Config) addSpecificDatabase(prefix, defaultType string) {
connection := getEnv(strings.ToUpper(prefix)+"_CONNECTION", defaultType)
host := getEnv(strings.ToUpper(prefix)+"_HOST", "")
if host != "" {
dbConfig := DatabaseConfig{
Name: prefix,
Type: connection,
Host: host,
Port: getEnvAsInt(strings.ToUpper(prefix)+"_PORT", 5432),
Username: getEnv(strings.ToUpper(prefix)+"_USERNAME", ""),
Password: getEnv(strings.ToUpper(prefix)+"_PASSWORD", ""),
Database: getEnv(strings.ToUpper(prefix)+"_DATABASE", getEnv(strings.ToUpper(prefix)+"_NAME", prefix)),
Schema: getEnv(strings.ToUpper(prefix)+"_SCHEMA", "public"),
SSLMode: getEnv(strings.ToUpper(prefix)+"_SSLMODE", "disable"),
MaxOpenConns: getEnvAsInt(strings.ToUpper(prefix)+"_MAX_OPEN_CONNS", 25),
MaxIdleConns: getEnvAsInt(strings.ToUpper(prefix)+"_MAX_IDLE_CONNS", 25),
ConnMaxLifetime: parseDuration(getEnv(strings.ToUpper(prefix)+"_CONN_MAX_LIFETIME", "5m")),
}
c.Databases[prefix] = dbConfig
}
}
// PostgreSQL database
func (c *Config) addPostgreSQLConfigs() {
// SATUDATA database configuration
// defaultPOSTGRESHost := getEnv("POSTGRES_HOST", "localhost")
// if defaultPOSTGRESHost != "" {
// c.Databases["postgres"] = DatabaseConfig{
// Name: "postgres",
// Type: getEnv("POSTGRES_CONNECTION", "postgres"),
// Host: defaultPOSTGRESHost,
// Port: getEnvAsInt("POSTGRES_PORT", 5432),
// Username: getEnv("POSTGRES_USERNAME", ""),
// Password: getEnv("POSTGRES_PASSWORD", ""),
// Database: getEnv("POSTGRES_DATABASE", "postgres"),
// Schema: getEnv("POSTGRES_SCHEMA", "public"),
// SSLMode: getEnv("POSTGRES_SSLMODE", "disable"),
// MaxOpenConns: getEnvAsInt("POSTGRES_MAX_OPEN_CONNS", 25),
// MaxIdleConns: getEnvAsInt("POSTGRES_MAX_IDLE_CONNS", 25),
// ConnMaxLifetime: parseDuration(getEnv("POSTGRES_CONN_MAX_LIFETIME", "5m")),
// }
// }
// Support for custom PostgreSQL configurations with POSTGRES_ prefix
envVars := os.Environ()
for _, envVar := range envVars {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
// Parse PostgreSQL configurations (format: POSTGRES_[NAME]_[PROPERTY])
if strings.HasPrefix(key, "POSTGRES_") && strings.Contains(key, "_") {
segments := strings.Split(key, "_")
if len(segments) >= 3 {
dbName := strings.ToLower(strings.Join(segments[1:len(segments)-1], "_"))
// Skip if it's a standard PostgreSQL configuration
if dbName == "connection" || dbName == "dev" || dbName == "default" || dbName == "satudata" {
continue
}
// Create or update PostgreSQL configuration
if _, exists := c.Databases[dbName]; !exists {
c.Databases[dbName] = DatabaseConfig{
Name: dbName,
Type: "postgres",
Host: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_HOST", "localhost"),
Port: getEnvAsInt("POSTGRES_"+strings.ToUpper(dbName)+"_PORT", 5432),
Username: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_USERNAME", ""),
Password: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_PASSWORD", ""),
Database: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_DATABASE", dbName),
Schema: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_SCHEMA", "public"),
SSLMode: getEnv("POSTGRES_"+strings.ToUpper(dbName)+"_SSLMODE", "disable"),
MaxOpenConns: getEnvAsInt("POSTGRES_MAX_OPEN_CONNS", 25),
MaxIdleConns: getEnvAsInt("POSTGRES_MAX_IDLE_CONNS", 25),
ConnMaxLifetime: parseDuration(getEnv("POSTGRES_CONN_MAX_LIFETIME", "5m")),
}
}
}
}
}
}
// addMYSQLConfigs adds MYSQL database
func (c *Config) addMySQLConfigs() {
// Primary MySQL configuration
defaultMySQLHost := getEnv("MYSQL_HOST", "")
if defaultMySQLHost != "" {
c.Databases["mysql"] = DatabaseConfig{
Name: "mysql",
Type: getEnv("MYSQL_CONNECTION", "mysql"),
Host: defaultMySQLHost,
Port: getEnvAsInt("MYSQL_PORT", 3306),
Username: getEnv("MYSQL_USERNAME", ""),
Password: getEnv("MYSQL_PASSWORD", ""),
Database: getEnv("MYSQL_DATABASE", "mysql"),
SSLMode: getEnv("MYSQL_SSLMODE", "disable"),
MaxOpenConns: getEnvAsInt("MYSQL_MAX_OPEN_CONNS", 25),
MaxIdleConns: getEnvAsInt("MYSQL_MAX_IDLE_CONNS", 25),
ConnMaxLifetime: parseDuration(getEnv("MYSQL_CONN_MAX_LIFETIME", "5m")),
}
}
// Support for custom MySQL configurations with MYSQL_ prefix
envVars := os.Environ()
for _, envVar := range envVars {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
// Parse MySQL configurations (format: MYSQL_[NAME]_[PROPERTY])
if strings.HasPrefix(key, "MYSQL_") && strings.Contains(key, "_") {
segments := strings.Split(key, "_")
if len(segments) >= 3 {
dbName := strings.ToLower(strings.Join(segments[1:len(segments)-1], "_"))
// Skip if it's a standard MySQL configuration
if dbName == "connection" || dbName == "dev" || dbName == "max" || dbName == "conn" {
continue
}
// Create or update MySQL configuration
if _, exists := c.Databases[dbName]; !exists {
mysqlHost := getEnv("MYSQL_"+strings.ToUpper(dbName)+"_HOST", "")
if mysqlHost != "" {
c.Databases[dbName] = DatabaseConfig{
Name: dbName,
Type: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_CONNECTION", "mysql"),
Host: mysqlHost,
Port: getEnvAsInt("MYSQL_"+strings.ToUpper(dbName)+"_PORT", 3306),
Username: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_USERNAME", ""),
Password: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_PASSWORD", ""),
Database: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_DATABASE", dbName),
SSLMode: getEnv("MYSQL_"+strings.ToUpper(dbName)+"_SSLMODE", "disable"),
MaxOpenConns: getEnvAsInt("MYSQL_MAX_OPEN_CONNS", 25),
MaxIdleConns: getEnvAsInt("MYSQL_MAX_IDLE_CONNS", 25),
ConnMaxLifetime: parseDuration(getEnv("MYSQL_CONN_MAX_LIFETIME", "5m")),
}
}
}
}
}
}
}
// addMongoDBConfigs adds MongoDB database configurations from environment variables
func (c *Config) addMongoDBConfigs() {
// Primary MongoDB configuration
mongoHost := getEnv("MONGODB_HOST", "")
if mongoHost != "" {
c.Databases["mongodb"] = DatabaseConfig{
Name: "mongodb",
Type: getEnv("MONGODB_CONNECTION", "mongodb"),
Host: mongoHost,
Port: getEnvAsInt("MONGODB_PORT", 27017),
Username: getEnv("MONGODB_USER", ""),
Password: getEnv("MONGODB_PASS", ""),
Database: getEnv("MONGODB_MASTER", "master"),
SSLMode: getEnv("MONGODB_SSLMODE", "disable"),
MaxOpenConns: getEnvAsInt("MONGODB_MAX_OPEN_CONNS", 100),
MaxIdleConns: getEnvAsInt("MONGODB_MAX_IDLE_CONNS", 10),
ConnMaxLifetime: parseDuration(getEnv("MONGODB_CONN_MAX_LIFETIME", "30m")),
}
}
// Additional MongoDB configurations for local database
mongoLocalHost := getEnv("MONGODB_LOCAL_HOST", "")
if mongoLocalHost != "" {
c.Databases["mongodb_local"] = DatabaseConfig{
Name: "mongodb_local",
Type: getEnv("MONGODB_CONNECTION", "mongodb"),
Host: mongoLocalHost,
Port: getEnvAsInt("MONGODB_LOCAL_PORT", 27017),
Username: getEnv("MONGODB_LOCAL_USER", ""),
Password: getEnv("MONGODB_LOCAL_PASS", ""),
Database: getEnv("MONGODB_LOCAL_DB", "local"),
SSLMode: getEnv("MONGOD_SSLMODE", "disable"),
MaxOpenConns: getEnvAsInt("MONGODB_MAX_OPEN_CONNS", 100),
MaxIdleConns: getEnvAsInt("MONGODB_MAX_IDLE_CONNS", 10),
ConnMaxLifetime: parseDuration(getEnv("MONGODB_CONN_MAX_LIFETIME", "30m")),
}
}
// Support for custom MongoDB configurations with MONGODB_ prefix
envVars := os.Environ()
for _, envVar := range envVars {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
// Parse MongoDB configurations (format: MONGODB_[NAME]_[PROPERTY])
if strings.HasPrefix(key, "MONGODB_") && strings.Contains(key, "_") {
segments := strings.Split(key, "_")
if len(segments) >= 3 {
dbName := strings.ToLower(strings.Join(segments[1:len(segments)-1], "_"))
// Skip if it's a standard MongoDB configuration
if dbName == "connection" || dbName == "dev" || dbName == "local" {
continue
}
// Create or update MongoDB configuration
if _, exists := c.Databases[dbName]; !exists {
c.Databases[dbName] = DatabaseConfig{
Name: dbName,
Type: "mongodb",
Host: getEnv("MONGODB_"+strings.ToUpper(dbName)+"_HOST", "localhost"),
Port: getEnvAsInt("MONGODB_"+strings.ToUpper(dbName)+"_PORT", 27017),
Username: getEnv("MONGODB_"+strings.ToUpper(dbName)+"_USER", ""),
Password: getEnv("MONGODB_"+strings.ToUpper(dbName)+"_PASS", ""),
Database: getEnv("MONGODB_"+strings.ToUpper(dbName)+"_DB", dbName),
SSLMode: getEnv("MONGOD_SSLMODE", "disable"),
MaxOpenConns: getEnvAsInt("MONGODB_MAX_OPEN_CONNS", 100),
MaxIdleConns: getEnvAsInt("MONGODB_MAX_IDLE_CONNS", 10),
ConnMaxLifetime: parseDuration(getEnv("MONGODB_CONN_MAX_LIFETIME", "30m")),
}
}
}
}
}
}
func getEnvFromMap(config map[string]string, key, defaultValue string) string {
if value, exists := config[key]; exists {
return value
}
return defaultValue
}
func getEnvAsIntFromMap(config map[string]string, key string, defaultValue int) int {
if value, exists := config[key]; exists {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
func parseDuration(durationStr string) time.Duration {
if duration, err := time.ParseDuration(durationStr); err == nil {
return duration
}
return 5 * time.Minute
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvAsInt(key string, defaultValue int) int {
valueStr := getEnv(key, "")
if value, err := strconv.Atoi(valueStr); err == nil {
return value
}
return defaultValue
}
func getEnvAsBool(key string, defaultValue bool) bool {
valueStr := getEnv(key, "")
if value, err := strconv.ParseBool(valueStr); err == nil {
return value
}
return defaultValue
}
// parseSchemes parses comma-separated schemes string into a slice
func parseSchemes(schemesStr string) []string {
if schemesStr == "" {
return []string{"http"}
}
schemes := strings.Split(schemesStr, ",")
for i, scheme := range schemes {
schemes[i] = strings.TrimSpace(scheme)
}
return schemes
}
func (c *Config) Validate() error {
if len(c.Databases) == 0 {
log.Fatal("At least one database configuration is required")
}
for name, db := range c.Databases {
if db.Host == "" {
log.Fatalf("Database host is required for %s", name)
}
if db.Username == "" {
log.Fatalf("Database username is required for %s", name)
}
if db.Password == "" {
log.Fatalf("Database password is required for %s", name)
}
if db.Database == "" {
log.Fatalf("Database name is required for %s", name)
}
}
if c.Bpjs.BaseURL == "" {
log.Fatal("BPJS Base URL is required")
}
if c.Bpjs.ConsID == "" {
log.Fatal("BPJS Consumer ID is required")
}
if c.Bpjs.UserKey == "" {
log.Fatal("BPJS User Key is required")
}
if c.Bpjs.SecretKey == "" {
log.Fatal("BPJS Secret Key is required")
}
// Validate Keycloak configuration if enabled
if c.Keycloak.Enabled {
if c.Keycloak.Issuer == "" {
log.Fatal("Keycloak issuer is required when Keycloak is enabled")
}
if c.Keycloak.Audience == "" {
log.Fatal("Keycloak audience is required when Keycloak is enabled")
}
if c.Keycloak.JwksURL == "" {
log.Fatal("Keycloak JWKS URL is required when Keycloak is enabled")
}
}
// Validate SatuSehat configuration
if c.SatuSehat.OrgID == "" {
log.Fatal("SatuSehat Organization ID is required")
}
if c.SatuSehat.FasyakesID == "" {
log.Fatal("SatuSehat Fasyankes ID is required")
}
if c.SatuSehat.ClientID == "" {
log.Fatal("SatuSehat Client ID is required")
}
if c.SatuSehat.ClientSecret == "" {
log.Fatal("SatuSehat Client Secret is required")
}
if c.SatuSehat.AuthURL == "" {
log.Fatal("SatuSehat Auth URL is required")
}
if c.SatuSehat.BaseURL == "" {
log.Fatal("SatuSehat Base URL is required")
}
return nil
}

View File

@@ -0,0 +1,699 @@
package database
import (
"context"
"database/sql"
"fmt"
"log" // Import runtime package
// Import debug package
"strconv"
"sync"
"time"
"api-service/internal/config"
_ "github.com/jackc/pgx/v5" // Import pgx driver
"github.com/lib/pq"
_ "gorm.io/driver/postgres" // Import GORM PostgreSQL driver
_ "github.com/go-sql-driver/mysql" // MySQL driver for database/sql
_ "gorm.io/driver/mysql" // GORM MySQL driver
_ "gorm.io/driver/sqlserver" // GORM SQL Server driver
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// DatabaseType represents supported database types
type DatabaseType string
const (
Postgres DatabaseType = "postgres"
MySQL DatabaseType = "mysql"
SQLServer DatabaseType = "sqlserver"
SQLite DatabaseType = "sqlite"
MongoDB DatabaseType = "mongodb"
)
// Service represents a service that interacts with multiple databases
type Service interface {
Health() map[string]map[string]string
GetDB(name string) (*sql.DB, error)
GetMongoClient(name string) (*mongo.Client, error)
GetReadDB(name string) (*sql.DB, error) // For read replicas
Close() error
ListDBs() []string
GetDBType(name string) (DatabaseType, error)
// Tambahkan method untuk WebSocket notifications
ListenForChanges(ctx context.Context, dbName string, channels []string, callback func(string, string)) error
NotifyChange(dbName, channel, payload string) error
GetPrimaryDB(name string) (*sql.DB, error) // Helper untuk get primary DB
}
type service struct {
sqlDatabases map[string]*sql.DB
mongoClients map[string]*mongo.Client
readReplicas map[string][]*sql.DB // Read replicas for load balancing
configs map[string]config.DatabaseConfig
readConfigs map[string][]config.DatabaseConfig
mu sync.RWMutex
readBalancer map[string]int // Round-robin counter for read replicas
listeners map[string]*pq.Listener // Tambahkan untuk tracking listeners
listenersMu sync.RWMutex
}
var (
dbManager *service
once sync.Once
)
// New creates a new database service with multiple connections
func New(cfg *config.Config) Service {
once.Do(func() {
dbManager = &service{
sqlDatabases: make(map[string]*sql.DB),
mongoClients: make(map[string]*mongo.Client),
readReplicas: make(map[string][]*sql.DB),
configs: make(map[string]config.DatabaseConfig),
readConfigs: make(map[string][]config.DatabaseConfig),
readBalancer: make(map[string]int),
listeners: make(map[string]*pq.Listener),
}
log.Println("Initializing database service...") // Log when the initialization starts
// log.Printf("Current Goroutine ID: %d", runtime.NumGoroutine()) // Log the number of goroutines
// log.Printf("Stack Trace: %s", debug.Stack()) // Log the stack trace
dbManager.loadFromConfig(cfg)
// Initialize all databases
for name, dbConfig := range dbManager.configs {
if err := dbManager.addDatabase(name, dbConfig); err != nil {
log.Printf("Failed to connect to database %s: %v", name, err)
}
}
// Initialize read replicas
for name, replicaConfigs := range dbManager.readConfigs {
for i, replicaConfig := range replicaConfigs {
if err := dbManager.addReadReplica(name, i, replicaConfig); err != nil {
log.Printf("Failed to connect to read replica %s[%d]: %v", name, i, err)
}
}
}
})
return dbManager
}
func (s *service) loadFromConfig(cfg *config.Config) {
s.mu.Lock()
defer s.mu.Unlock()
// Load primary databases
for name, dbConfig := range cfg.Databases {
s.configs[name] = dbConfig
}
// Load read replicas
for name, replicaConfigs := range cfg.ReadReplicas {
s.readConfigs[name] = replicaConfigs
}
}
func (s *service) addDatabase(name string, config config.DatabaseConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
log.Printf("=== Database Connection Debug ===")
// log.Printf("Database: %s", name)
// log.Printf("Type: %s", config.Type)
// log.Printf("Host: %s", config.Host)
// log.Printf("Port: %d", config.Port)
// log.Printf("Database: %s", config.Database)
// log.Printf("Username: %s", config.Username)
// log.Printf("SSLMode: %s", config.SSLMode)
var db *sql.DB
var err error
dbType := DatabaseType(config.Type)
switch dbType {
case Postgres:
db, err = s.openPostgresConnection(config)
case MySQL:
db, err = s.openMySQLConnection(config)
case SQLServer:
db, err = s.openSQLServerConnection(config)
case SQLite:
db, err = s.openSQLiteConnection(config)
case MongoDB:
return s.addMongoDB(name, config)
default:
return fmt.Errorf("unsupported database type: %s", config.Type)
}
if err != nil {
log.Printf("❌ Error connecting to database %s: %v", name, err)
log.Printf(" Database: %s@%s:%d/%s", config.Username, config.Host, config.Port, config.Database)
return err
}
log.Printf("✅ Successfully connected to database: %s", name)
return s.configureSQLDB(name, db, config.MaxOpenConns, config.MaxIdleConns, config.ConnMaxLifetime)
}
func (s *service) addReadReplica(name string, index int, config config.DatabaseConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
var db *sql.DB
var err error
dbType := DatabaseType(config.Type)
switch dbType {
case Postgres:
db, err = s.openPostgresConnection(config)
case MySQL:
db, err = s.openMySQLConnection(config)
case SQLServer:
db, err = s.openSQLServerConnection(config)
case SQLite:
db, err = s.openSQLiteConnection(config)
default:
return fmt.Errorf("unsupported database type for read replica: %s", config.Type)
}
if err != nil {
return err
}
if s.readReplicas[name] == nil {
s.readReplicas[name] = make([]*sql.DB, 0)
}
// Ensure we have enough slots
for len(s.readReplicas[name]) <= index {
s.readReplicas[name] = append(s.readReplicas[name], nil)
}
s.readReplicas[name][index] = db
log.Printf("Successfully connected to read replica %s[%d]", name, index)
return nil
}
func (s *service) openPostgresConnection(config config.DatabaseConfig) (*sql.DB, error) {
connStr := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
config.SSLMode,
)
if config.Schema != "" {
connStr += "&search_path=" + config.Schema
}
db, err := sql.Open("pgx", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open PostgreSQL connection: %w", err)
}
return db, nil
}
func (s *service) openMySQLConnection(config config.DatabaseConfig) (*sql.DB, error) {
connStr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
)
db, err := sql.Open("mysql", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open MySQL connection: %w", err)
}
return db, nil
}
func (s *service) openSQLServerConnection(config config.DatabaseConfig) (*sql.DB, error) {
connStr := fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
)
db, err := sql.Open("sqlserver", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open SQL Server connection: %w", err)
}
return db, nil
}
func (s *service) openSQLiteConnection(config config.DatabaseConfig) (*sql.DB, error) {
dbPath := config.Path
if dbPath == "" {
dbPath = fmt.Sprintf("./data/%s.db", config.Database)
}
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open SQLite connection: %w", err)
}
return db, nil
}
func (s *service) addMongoDB(name string, config config.DatabaseConfig) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
uri := fmt.Sprintf("mongodb://%s:%s@%s:%d/%s",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
)
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
return fmt.Errorf("failed to connect to MongoDB: %w", err)
}
s.mongoClients[name] = client
log.Printf("Successfully connected to MongoDB: %s", name)
return nil
}
func (s *service) configureSQLDB(name string, db *sql.DB, maxOpenConns, maxIdleConns int, connMaxLifetime time.Duration) error {
db.SetMaxOpenConns(maxOpenConns)
db.SetMaxIdleConns(maxIdleConns)
db.SetConnMaxLifetime(connMaxLifetime)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return fmt.Errorf("failed to ping database: %w", err)
}
s.sqlDatabases[name] = db
log.Printf("Successfully connected to SQL database: %s", name)
return nil
}
// Health checks the health of all database connections by pinging each database.
func (s *service) Health() map[string]map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]map[string]string)
// Check SQL databases
for name, db := range s.sqlDatabases {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
err := db.PingContext(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("db down: %v", err)
stats["type"] = "sql"
stats["role"] = "primary"
result[name] = stats
continue
}
stats["status"] = "up"
stats["message"] = "It's healthy"
stats["type"] = "sql"
stats["role"] = "primary"
dbStats := db.Stats()
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
stats["in_use"] = strconv.Itoa(dbStats.InUse)
stats["idle"] = strconv.Itoa(dbStats.Idle)
stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
stats["wait_duration"] = dbStats.WaitDuration.String()
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
if dbStats.OpenConnections > 40 {
stats["message"] = "The database is experiencing heavy load."
}
if dbStats.WaitCount > 1000 {
stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks."
}
if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 {
stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings."
}
if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 {
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern."
}
result[name] = stats
}
// Check read replicas
for name, replicas := range s.readReplicas {
for i, db := range replicas {
if db == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
replicaName := fmt.Sprintf("%s_replica_%d", name, i)
stats := make(map[string]string)
err := db.PingContext(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("read replica down: %v", err)
stats["type"] = "sql"
stats["role"] = "replica"
result[replicaName] = stats
continue
}
stats["status"] = "up"
stats["message"] = "Read replica healthy"
stats["type"] = "sql"
stats["role"] = "replica"
dbStats := db.Stats()
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
stats["in_use"] = strconv.Itoa(dbStats.InUse)
stats["idle"] = strconv.Itoa(dbStats.Idle)
result[replicaName] = stats
}
}
// Check MongoDB connections
for name, client := range s.mongoClients {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
err := client.Ping(ctx, nil)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("mongodb down: %v", err)
stats["type"] = "mongodb"
result[name] = stats
continue
}
stats["status"] = "up"
stats["message"] = "It's healthy"
stats["type"] = "mongodb"
result[name] = stats
}
return result
}
// GetDB returns a specific SQL database connection by name
func (s *service) GetDB(name string) (*sql.DB, error) {
log.Printf("Attempting to get database connection for: %s", name)
s.mu.RLock()
defer s.mu.RUnlock()
db, exists := s.sqlDatabases[name]
if !exists {
log.Printf("Error: database %s not found", name) // Log the error
return nil, fmt.Errorf("database %s not found", name)
}
log.Printf("Current connection pool state for %s: Open: %d, In Use: %d, Idle: %d",
name, db.Stats().OpenConnections, db.Stats().InUse, db.Stats().Idle)
s.mu.RLock()
defer s.mu.RUnlock()
// db, exists := s.sqlDatabases[name]
// if !exists {
// log.Printf("Error: database %s not found", name) // Log the error
// return nil, fmt.Errorf("database %s not found", name)
// }
return db, nil
}
// GetReadDB returns a read replica connection using round-robin load balancing
func (s *service) GetReadDB(name string) (*sql.DB, error) {
s.mu.RLock()
defer s.mu.RUnlock()
replicas, exists := s.readReplicas[name]
if !exists || len(replicas) == 0 {
// Fallback to primary if no replicas available
return s.GetDB(name)
}
// Round-robin load balancing
s.readBalancer[name] = (s.readBalancer[name] + 1) % len(replicas)
selected := replicas[s.readBalancer[name]]
if selected == nil {
// Fallback to primary if replica is nil
return s.GetDB(name)
}
return selected, nil
}
// GetMongoClient returns a specific MongoDB client by name
func (s *service) GetMongoClient(name string) (*mongo.Client, error) {
s.mu.RLock()
defer s.mu.RUnlock()
client, exists := s.mongoClients[name]
if !exists {
return nil, fmt.Errorf("MongoDB client %s not found", name)
}
return client, nil
}
// ListDBs returns list of available database names
func (s *service) ListDBs() []string {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.sqlDatabases)+len(s.mongoClients))
for name := range s.sqlDatabases {
names = append(names, name)
}
for name := range s.mongoClients {
names = append(names, name)
}
return names
}
// GetDBType returns the type of a specific database
func (s *service) GetDBType(name string) (DatabaseType, error) {
s.mu.RLock()
defer s.mu.RUnlock()
config, exists := s.configs[name]
if !exists {
return "", fmt.Errorf("database %s not found", name)
}
return DatabaseType(config.Type), nil
}
// Close closes all database connections
func (s *service) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
var errs []error
for name, db := range s.sqlDatabases {
if err := db.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close database %s: %w", name, err))
} else {
log.Printf("Disconnected from SQL database: %s", name)
}
}
for name, replicas := range s.readReplicas {
for i, db := range replicas {
if db != nil {
if err := db.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close read replica %s[%d]: %w", name, i, err))
} else {
log.Printf("Disconnected from read replica: %s[%d]", name, i)
}
}
}
}
for name, client := range s.mongoClients {
if err := client.Disconnect(context.Background()); err != nil {
errs = append(errs, fmt.Errorf("failed to disconnect MongoDB client %s: %w", name, err))
} else {
log.Printf("Disconnected from MongoDB: %s", name)
}
}
s.sqlDatabases = make(map[string]*sql.DB)
s.mongoClients = make(map[string]*mongo.Client)
s.readReplicas = make(map[string][]*sql.DB)
s.configs = make(map[string]config.DatabaseConfig)
s.readConfigs = make(map[string][]config.DatabaseConfig)
if len(errs) > 0 {
return fmt.Errorf("errors closing databases: %v", errs)
}
return nil
}
// GetPrimaryDB returns primary database connection
func (s *service) GetPrimaryDB(name string) (*sql.DB, error) {
return s.GetDB(name)
}
// ListenForChanges implements PostgreSQL LISTEN/NOTIFY for real-time updates
func (s *service) ListenForChanges(ctx context.Context, dbName string, channels []string, callback func(string, string)) error {
s.mu.RLock()
config, exists := s.configs[dbName]
s.mu.RUnlock()
if !exists {
return fmt.Errorf("database %s not found", dbName)
}
// Only support PostgreSQL for LISTEN/NOTIFY
if DatabaseType(config.Type) != Postgres {
return fmt.Errorf("LISTEN/NOTIFY only supported for PostgreSQL databases")
}
// Create connection string for listener
connStr := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
config.SSLMode,
)
// Create listener
listener := pq.NewListener(
connStr,
10*time.Second,
time.Minute,
func(ev pq.ListenerEventType, err error) {
if err != nil {
log.Printf("Database listener (%s) error: %v", dbName, err)
}
},
)
// Store listener for cleanup
s.listenersMu.Lock()
s.listeners[dbName] = listener
s.listenersMu.Unlock()
// Listen to specified channels
for _, channel := range channels {
err := listener.Listen(channel)
if err != nil {
listener.Close()
return fmt.Errorf("failed to listen to channel %s: %w", channel, err)
}
log.Printf("Listening to database channel: %s on %s", channel, dbName)
}
// Start listening loop
go func() {
defer func() {
listener.Close()
s.listenersMu.Lock()
delete(s.listeners, dbName)
s.listenersMu.Unlock()
log.Printf("Database listener for %s stopped", dbName)
}()
for {
select {
case n := <-listener.Notify:
if n != nil {
callback(n.Channel, n.Extra)
}
case <-ctx.Done():
return
case <-time.After(90 * time.Second):
// Send ping to keep connection alive
go func() {
if err := listener.Ping(); err != nil {
log.Printf("Listener ping failed for %s: %v", dbName, err)
}
}()
}
}
}()
return nil
}
// NotifyChange sends a notification to a PostgreSQL channel
func (s *service) NotifyChange(dbName, channel, payload string) error {
db, err := s.GetDB(dbName)
if err != nil {
return fmt.Errorf("failed to get database %s: %w", dbName, err)
}
// Check if it's PostgreSQL
s.mu.RLock()
config, exists := s.configs[dbName]
s.mu.RUnlock()
if !exists {
return fmt.Errorf("database %s configuration not found", dbName)
}
if DatabaseType(config.Type) != Postgres {
return fmt.Errorf("NOTIFY only supported for PostgreSQL databases")
}
// Execute NOTIFY
query := "SELECT pg_notify($1, $2)"
_, err = db.Exec(query, channel, payload)
if err != nil {
return fmt.Errorf("failed to send notification: %w", err)
}
log.Printf("Sent notification to channel %s on %s: %s", channel, dbName, payload)
return nil
}

View File

@@ -0,0 +1,132 @@
package handlers
import (
models "api-service/internal/models/auth"
services "api-service/internal/services/auth"
"net/http"
"github.com/gin-gonic/gin"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *services.AuthService
}
// NewAuthHandler creates a new authentication handler
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Login godoc
// @Summary Login user and get JWT token
// @Description Authenticate user with username and password to receive JWT token
// @Tags Authentication
// @Accept json
// @Produce json
// @Param login body models.LoginRequest true "Login credentials"
// @Success 200 {object} models.TokenResponse
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Router /api/v1/auth/login [post]
func (h *AuthHandler) Login(c *gin.Context) {
var loginReq models.LoginRequest
// Bind JSON request
if err := c.ShouldBindJSON(&loginReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Authenticate user
tokenResponse, err := h.authService.Login(loginReq.Username, loginReq.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tokenResponse)
}
// RefreshToken godoc
// @Summary Refresh JWT token
// @Description Refresh the JWT token using a valid refresh token
// @Tags Authentication
// @Accept json
// @Produce json
// @Param refresh body map[string]string true "Refresh token"
// @Success 200 {object} models.TokenResponse
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Router /api/v1/auth/refresh [post]
func (h *AuthHandler) RefreshToken(c *gin.Context) {
// For now, this is a placeholder for refresh token functionality
// In a real implementation, you would handle refresh tokens here
c.JSON(http.StatusNotImplemented, gin.H{"error": "refresh token not implemented"})
}
// Register godoc
// @Summary Register new user
// @Description Register a new user account
// @Tags Authentication
// @Accept json
// @Produce json
// @Param register body map[string]string true "Registration data"
// @Success 201 {object} map[string]string
// @Failure 400 {object} map[string]string "Bad request"
// @Router /api/v1/auth/register [post]
func (h *AuthHandler) Register(c *gin.Context) {
var registerReq struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&registerReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.authService.RegisterUser(
registerReq.Username,
registerReq.Email,
registerReq.Password,
registerReq.Role,
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "user registered successfully"})
}
// Me godoc
// @Summary Get current user info
// @Description Get information about the currently authenticated user
// @Tags Authentication
// @Produce json
// @Security Bearer
// @Success 200 {object} models.User
// @Failure 401 {object} map[string]string "Unauthorized"
// @Router /api/v1/auth/me [get]
func (h *AuthHandler) Me(c *gin.Context) {
// Get user info from context (set by middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
// In a real implementation, you would fetch user details from database
c.JSON(http.StatusOK, gin.H{
"id": userID,
"username": c.GetString("username"),
"email": c.GetString("email"),
"role": c.GetString("role"),
})
}

View File

@@ -0,0 +1,95 @@
package handlers
import (
models "api-service/internal/models/auth"
services "api-service/internal/services/auth"
"net/http"
"github.com/gin-gonic/gin"
)
// TokenHandler handles token generation endpoints
type TokenHandler struct {
authService *services.AuthService
}
// NewTokenHandler creates a new token handler
func NewTokenHandler(authService *services.AuthService) *TokenHandler {
return &TokenHandler{
authService: authService,
}
}
// GenerateToken godoc
// @Summary Generate JWT token
// @Description Generate a JWT token for a user
// @Tags Token
// @Accept json
// @Produce json
// @Param token body models.LoginRequest true "User credentials"
// @Success 200 {object} models.TokenResponse
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Router /api/v1/token/generate [post]
func (h *TokenHandler) GenerateToken(c *gin.Context) {
var loginReq models.LoginRequest
// Bind JSON request
if err := c.ShouldBindJSON(&loginReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate token
tokenResponse, err := h.authService.Login(loginReq.Username, loginReq.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tokenResponse)
}
// GenerateTokenDirect godoc
// @Summary Generate token directly
// @Description Generate a JWT token directly without password verification (for testing)
// @Tags Token
// @Accept json
// @Produce json
// @Param user body map[string]string true "User info"
// @Success 200 {object} models.TokenResponse
// @Failure 400 {object} map[string]string "Bad request"
// @Router /api/v1/token/generate-direct [post]
func (h *TokenHandler) GenerateTokenDirect(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"`
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create a temporary user for token generation
user := &models.User{
ID: "temp-" + req.Username,
Username: req.Username,
Email: req.Email,
Role: req.Role,
}
// Generate token directly
token, err := h.authService.GenerateTokenForUser(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, models.TokenResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: 3600,
})
}

View File

@@ -0,0 +1,24 @@
package healthcheck
import (
"api-service/internal/database"
"net/http"
"github.com/gin-gonic/gin"
)
// HealthCheckHandler handles health check requests
type HealthCheckHandler struct {
dbService database.Service
}
// NewHealthCheckHandler creates a new HealthCheckHandler
func NewHealthCheckHandler(dbService database.Service) *HealthCheckHandler {
return &HealthCheckHandler{dbService: dbService}
}
// CheckHealth checks the health of the application
func (h *HealthCheckHandler) CheckHealth(c *gin.Context) {
healthStatus := h.dbService.Health() // Call the health check function from the database service
c.JSON(http.StatusOK, healthStatus)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
package websocket
import (
"sync"
"time"
)
// WebSocketBroadcaster defines the interface for broadcasting messages
type WebSocketBroadcaster interface {
BroadcastMessage(messageType string, data interface{})
}
// Broadcaster handles server-initiated broadcasts to WebSocket clients
type Broadcaster struct {
handler WebSocketBroadcaster
tickers []*time.Ticker
quit chan struct{}
mu sync.Mutex
}
// NewBroadcaster creates a new Broadcaster instance
func NewBroadcaster(handler WebSocketBroadcaster) *Broadcaster {
return &Broadcaster{
handler: handler,
tickers: make([]*time.Ticker, 0),
quit: make(chan struct{}),
}
}
// StartHeartbeat starts sending periodic heartbeat messages to all clients
func (b *Broadcaster) StartHeartbeat(interval time.Duration) {
ticker := time.NewTicker(interval)
b.tickers = append(b.tickers, ticker)
go func() {
defer func() {
// Remove ticker from slice when done
for i, t := range b.tickers {
if t == ticker {
b.tickers = append(b.tickers[:i], b.tickers[i+1:]...)
break
}
}
}()
for {
select {
case <-ticker.C:
b.handler.BroadcastMessage("heartbeat", map[string]interface{}{
"message": "Server heartbeat",
"timestamp": time.Now().Format(time.RFC3339),
})
case <-b.quit:
ticker.Stop()
return
}
}
}()
}
// Stop stops the broadcaster
func (b *Broadcaster) Stop() {
close(b.quit)
for _, ticker := range b.tickers {
if ticker != nil {
ticker.Stop()
}
}
b.tickers = nil
}
// BroadcastNotification sends a notification message to all clients
func (b *Broadcaster) BroadcastNotification(title, message, level string) {
b.handler.BroadcastMessage("notification", map[string]interface{}{
"title": title,
"message": message,
"level": level,
"time": time.Now().Format(time.RFC3339),
})
}
// SimulateDataStream simulates streaming data to clients (useful for demos)
func (b *Broadcaster) SimulateDataStream() {
ticker := time.NewTicker(100 * time.Millisecond)
b.tickers = append(b.tickers, ticker)
go func() {
defer func() {
// Remove ticker from slice when done
for i, t := range b.tickers {
if t == ticker {
b.tickers = append(b.tickers[:i], b.tickers[i+1:]...)
break
}
}
}()
counter := 0
for {
select {
case <-ticker.C:
counter++
b.handler.BroadcastMessage("data_stream", map[string]interface{}{
"id": counter,
"value": counter * 10,
"timestamp": time.Now().Format(time.RFC3339),
"type": "simulated_data",
})
case <-b.quit:
ticker.Stop()
return
}
}
}()
}

View File

@@ -0,0 +1,251 @@
package websocket
import (
"sync"
"testing"
"time"
)
// MockWebSocketHandler is a mock implementation for testing
type MockWebSocketHandler struct {
mu sync.Mutex
messages []map[string]interface{}
broadcasts []string
}
func (m *MockWebSocketHandler) BroadcastMessage(messageType string, data interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.broadcasts = append(m.broadcasts, messageType)
m.messages = append(m.messages, map[string]interface{}{
"type": messageType,
"data": data,
})
}
func (m *MockWebSocketHandler) GetMessages() []map[string]interface{} {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]map[string]interface{}, len(m.messages))
copy(result, m.messages)
return result
}
func (m *MockWebSocketHandler) GetBroadcasts() []string {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]string, len(m.broadcasts))
copy(result, m.broadcasts)
return result
}
func (m *MockWebSocketHandler) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.messages = make([]map[string]interface{}, 0)
m.broadcasts = make([]string, 0)
}
func NewMockWebSocketHandler() *MockWebSocketHandler {
return &MockWebSocketHandler{
messages: make([]map[string]interface{}, 0),
broadcasts: make([]string, 0),
}
}
func TestBroadcaster_StartHeartbeat(t *testing.T) {
mockHandler := NewMockWebSocketHandler()
broadcaster := NewBroadcaster(mockHandler)
// Start heartbeat with short interval for testing
broadcaster.StartHeartbeat(100 * time.Millisecond)
// Wait for a few heartbeats
time.Sleep(350 * time.Millisecond)
// Stop the broadcaster
broadcaster.Stop()
// Check if heartbeats were sent
messages := mockHandler.GetMessages()
if len(messages) == 0 {
t.Error("Expected heartbeat messages, but got none")
}
// Check that all messages are heartbeat type
broadcasts := mockHandler.GetBroadcasts()
for _, msgType := range broadcasts {
if msgType != "heartbeat" {
t.Errorf("Expected heartbeat message type, got %s", msgType)
}
}
t.Logf("Received %d heartbeat messages", len(messages))
}
func TestBroadcaster_BroadcastNotification(t *testing.T) {
mockHandler := NewMockWebSocketHandler()
broadcaster := NewBroadcaster(mockHandler)
// Send a notification
broadcaster.BroadcastNotification("Test Title", "Test Message", "info")
// Check if notification was sent
messages := mockHandler.GetMessages()
if len(messages) != 1 {
t.Errorf("Expected 1 message, got %d", len(messages))
return
}
msg := messages[0]
if msg["type"] != "notification" {
t.Errorf("Expected message type 'notification', got %s", msg["type"])
}
data := msg["data"].(map[string]interface{})
if data["title"] != "Test Title" {
t.Errorf("Expected title 'Test Title', got %s", data["title"])
}
if data["message"] != "Test Message" {
t.Errorf("Expected message 'Test Message', got %s", data["message"])
}
if data["level"] != "info" {
t.Errorf("Expected level 'info', got %s", data["level"])
}
t.Logf("Notification sent successfully: %+v", data)
}
func TestBroadcaster_SimulateDataStream(t *testing.T) {
mockHandler := NewMockWebSocketHandler()
broadcaster := NewBroadcaster(mockHandler)
// Start data stream with short interval for testing
broadcaster.SimulateDataStream()
// Wait for a few data points
time.Sleep(550 * time.Millisecond)
// Stop the broadcaster
broadcaster.Stop()
// Check if data stream messages were sent
messages := mockHandler.GetMessages()
if len(messages) == 0 {
t.Error("Expected data stream messages, but got none")
}
// Check that all messages are data_stream type
broadcasts := mockHandler.GetBroadcasts()
for _, msgType := range broadcasts {
if msgType != "data_stream" {
t.Errorf("Expected data_stream message type, got %s", msgType)
}
}
// Check data structure
for i, msg := range messages {
data := msg["data"].(map[string]interface{})
if data["type"] != "simulated_data" {
t.Errorf("Expected data type 'simulated_data', got %s", data["type"])
}
if id, ok := data["id"].(int); ok {
if id != i+1 {
t.Errorf("Expected id %d, got %d", i+1, id)
}
}
if value, ok := data["value"].(int); ok {
expectedValue := (i + 1) * 10
if value != expectedValue {
t.Errorf("Expected value %d, got %d", expectedValue, value)
}
}
}
t.Logf("Received %d data stream messages", len(messages))
}
func TestBroadcaster_Stop(t *testing.T) {
mockHandler := NewMockWebSocketHandler()
broadcaster := NewBroadcaster(mockHandler)
// Start heartbeat
broadcaster.StartHeartbeat(50 * time.Millisecond)
// Wait a bit
time.Sleep(100 * time.Millisecond)
// Stop the broadcaster
broadcaster.Stop()
// Clear previous messages
mockHandler.Clear()
// Wait a bit more to ensure no new messages are sent
time.Sleep(200 * time.Millisecond)
// Check that no new messages were sent after stopping
messages := mockHandler.GetMessages()
if len(messages) > 0 {
t.Errorf("Expected no messages after stopping, but got %d", len(messages))
}
// Clear quit channel to allow reuse in tests
broadcaster.quit = make(chan struct{})
t.Log("Broadcaster stopped successfully")
}
func TestBroadcaster_MultipleOperations(t *testing.T) {
mockHandler := NewMockWebSocketHandler()
broadcaster := NewBroadcaster(mockHandler)
// Start heartbeat
broadcaster.StartHeartbeat(100 * time.Millisecond)
// Send notification
broadcaster.BroadcastNotification("Test", "Message", "warning")
// Start data stream
broadcaster.SimulateDataStream()
// Wait for some activity
time.Sleep(350 * time.Millisecond)
// Stop everything
broadcaster.Stop()
// Check results
messages := mockHandler.GetMessages()
if len(messages) == 0 {
t.Error("Expected messages from multiple operations, but got none")
}
broadcasts := mockHandler.GetBroadcasts()
hasHeartbeat := false
hasNotification := false
hasDataStream := false
for _, msgType := range broadcasts {
switch msgType {
case "heartbeat":
hasHeartbeat = true
case "notification":
hasNotification = true
case "data_stream":
hasDataStream = true
}
}
if !hasHeartbeat {
t.Error("Expected heartbeat messages")
}
if !hasNotification {
t.Error("Expected notification message")
}
if !hasDataStream {
t.Error("Expected data stream messages")
}
t.Logf("Multiple operations test passed: %d total messages", len(messages))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
package middleware
import (
"fmt"
"net/http"
"api-service/internal/config"
"github.com/gin-gonic/gin"
)
// ConfigurableAuthMiddleware provides flexible authentication based on configuration
func ConfigurableAuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip authentication for development/testing if explicitly disabled
if !cfg.Keycloak.Enabled {
fmt.Println("Authentication is disabled - allowing all requests")
c.Next()
return
}
// Use Keycloak authentication when enabled
AuthMiddleware()(c)
}
}
// StrictAuthMiddleware enforces authentication regardless of Keycloak.Enabled setting
func StrictAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if appConfig == nil {
fmt.Println("AuthMiddleware: Config not initialized")
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
return
}
// Always enforce authentication
AuthMiddleware()(c)
}
}
// OptionalKeycloakAuthMiddleware allows requests but adds authentication info if available
func OptionalKeycloakAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if appConfig == nil || !appConfig.Keycloak.Enabled {
c.Next()
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
// No token provided, but continue
c.Next()
return
}
// Try to validate token, but don't fail if invalid
AuthMiddleware()(c)
}
}

View File

@@ -0,0 +1,54 @@
package middleware
import (
models "api-service/internal/models"
"net/http"
"github.com/gin-gonic/gin"
)
// ErrorHandler handles errors globally
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) > 0 {
err := c.Errors.Last()
status := http.StatusInternalServerError
// Determine status code based on error type
switch err.Type {
case gin.ErrorTypeBind:
status = http.StatusBadRequest
case gin.ErrorTypeRender:
status = http.StatusUnprocessableEntity
case gin.ErrorTypePrivate:
status = http.StatusInternalServerError
}
response := models.ErrorResponse{
Error: "internal_error",
Message: err.Error(),
Code: status,
}
c.JSON(status, response)
}
}
}
// CORS middleware configuration
func CORSConfig() gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
}

View File

@@ -0,0 +1,77 @@
package middleware
import (
services "api-service/internal/services/auth"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// JWTAuthMiddleware validates JWT tokens generated by our auth service
func JWTAuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
return
}
tokenString := parts[1]
// Validate token
claims, err := authService.ValidateToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// Set user info in context
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Next()
}
}
// OptionalAuthMiddleware allows both authenticated and unauthenticated requests
func OptionalAuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
// No token provided, but continue
c.Next()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.Next()
return
}
tokenString := parts[1]
claims, err := authService.ValidateToken(tokenString)
if err != nil {
// Invalid token, but continue (don't abort)
c.Next()
return
}
// Set user info in context
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Next()
}
}

View File

@@ -0,0 +1,254 @@
package middleware
/** Keycloak Auth Middleware **/
import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
"api-service/internal/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/sync/singleflight"
)
var (
ErrInvalidToken = errors.New("invalid token")
)
// JwksCache caches JWKS keys with expiration
type JwksCache struct {
mu sync.RWMutex
keys map[string]*rsa.PublicKey
expiresAt time.Time
sfGroup singleflight.Group
config *config.Config
}
func NewJwksCache(cfg *config.Config) *JwksCache {
return &JwksCache{
keys: make(map[string]*rsa.PublicKey),
config: cfg,
}
}
func (c *JwksCache) GetKey(kid string) (*rsa.PublicKey, error) {
c.mu.RLock()
if key, ok := c.keys[kid]; ok && time.Now().Before(c.expiresAt) {
c.mu.RUnlock()
return key, nil
}
c.mu.RUnlock()
// Fetch keys with singleflight to avoid concurrent fetches
v, err, _ := c.sfGroup.Do("fetch_jwks", func() (interface{}, error) {
return c.fetchKeys()
})
if err != nil {
return nil, err
}
keys := v.(map[string]*rsa.PublicKey)
c.mu.Lock()
c.keys = keys
c.expiresAt = time.Now().Add(1 * time.Hour) // cache for 1 hour
c.mu.Unlock()
key, ok := keys[kid]
if !ok {
return nil, fmt.Errorf("key with kid %s not found", kid)
}
return key, nil
}
func (c *JwksCache) fetchKeys() (map[string]*rsa.PublicKey, error) {
if !c.config.Keycloak.Enabled {
return nil, fmt.Errorf("keycloak authentication is disabled")
}
jwksURL := c.config.Keycloak.JwksURL
if jwksURL == "" {
// Construct JWKS URL from issuer if not explicitly provided
jwksURL = c.config.Keycloak.Issuer + "/protocol/openid-connect/certs"
}
resp, err := http.Get(jwksURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var jwksData struct {
Keys []struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwksData); err != nil {
return nil, err
}
keys := make(map[string]*rsa.PublicKey)
for _, key := range jwksData.Keys {
if key.Kty != "RSA" {
continue
}
pubKey, err := parseRSAPublicKey(key.N, key.E)
if err != nil {
continue
}
keys[key.Kid] = pubKey
}
return keys, nil
}
// parseRSAPublicKey parses RSA public key components from base64url strings
func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
nBytes, err := base64UrlDecode(nStr)
if err != nil {
return nil, err
}
eBytes, err := base64UrlDecode(eStr)
if err != nil {
return nil, err
}
var eInt int
for _, b := range eBytes {
eInt = eInt<<8 + int(b)
}
pubKey := &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: eInt,
}
return pubKey, nil
}
func base64UrlDecode(s string) ([]byte, error) {
// Add padding if missing
if m := len(s) % 4; m != 0 {
s += strings.Repeat("=", 4-m)
}
return base64.URLEncoding.DecodeString(s)
}
// Global config instance
var appConfig *config.Config
var jwksCacheInstance *JwksCache
// InitializeAuth initializes the auth middleware with config
func InitializeAuth(cfg *config.Config) {
appConfig = cfg
jwksCacheInstance = NewJwksCache(cfg)
}
// AuthMiddleware validates Bearer token as Keycloak JWT token
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if appConfig == nil {
fmt.Println("AuthMiddleware: Config not initialized")
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
return
}
if !appConfig.Keycloak.Enabled {
// Skip authentication if Keycloak is disabled but log for debugging
fmt.Println("AuthMiddleware: Keycloak authentication is disabled - allowing all requests")
c.Next()
return
}
fmt.Println("AuthMiddleware: Checking Authorization header") // Debug log
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
fmt.Println("AuthMiddleware: Authorization header missing") // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
fmt.Println("AuthMiddleware: Invalid Authorization header format") // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
return
}
tokenString := parts[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
fmt.Printf("AuthMiddleware: Unexpected signing method: %v\n", token.Header["alg"]) // Debug log
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid, ok := token.Header["kid"].(string)
if !ok {
fmt.Println("AuthMiddleware: kid header not found") // Debug log
return nil, errors.New("kid header not found")
}
return jwksCacheInstance.GetKey(kid)
}, jwt.WithIssuer(appConfig.Keycloak.Issuer), jwt.WithAudience(appConfig.Keycloak.Audience))
if err != nil || !token.Valid {
fmt.Printf("AuthMiddleware: Invalid or expired token: %v\n", err) // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
fmt.Println("AuthMiddleware: Token valid, proceeding") // Debug log
// Token is valid, proceed
c.Next()
}
}
/** JWT Bearer authentication middleware */
// import (
// "net/http"
// "strings"
// "github.com/gin-gonic/gin"
// )
// AuthMiddleware validates Bearer token in Authorization header
func AuthJWTMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
return
}
token := parts[1]
// For now, use a static token for validation. Replace with your logic.
const validToken = "your-static-token"
if token != validToken {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,31 @@
package models
// LoginRequest represents the login request payload
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// TokenResponse represents the token response
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}
// JWTClaims represents the JWT claims
type JWTClaims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
}
// User represents a user for authentication
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"-"`
Role string `json:"role"`
}

221
internal/models/models.go Normal file
View File

@@ -0,0 +1,221 @@
package models
import (
"database/sql"
"database/sql/driver"
"net/http"
"strconv"
"time"
)
// NullableInt32 - your existing implementation
type NullableInt32 struct {
Int32 int32 `json:"int32,omitempty"`
Valid bool `json:"valid"`
}
// Scan implements the sql.Scanner interface for NullableInt32
func (n *NullableInt32) Scan(value interface{}) error {
var ni sql.NullInt32
if err := ni.Scan(value); err != nil {
return err
}
n.Int32 = ni.Int32
n.Valid = ni.Valid
return nil
}
// Value implements the driver.Valuer interface for NullableInt32
func (n NullableInt32) Value() (driver.Value, error) {
if !n.Valid {
return nil, nil
}
return n.Int32, nil
}
// NullableString provides consistent nullable string handling
type NullableString struct {
String string `json:"string,omitempty"`
Valid bool `json:"valid"`
}
// Scan implements the sql.Scanner interface for NullableString
func (n *NullableString) Scan(value interface{}) error {
var ns sql.NullString
if err := ns.Scan(value); err != nil {
return err
}
n.String = ns.String
n.Valid = ns.Valid
return nil
}
// Value implements the driver.Valuer interface for NullableString
func (n NullableString) Value() (driver.Value, error) {
if !n.Valid {
return nil, nil
}
return n.String, nil
}
// NullableTime provides consistent nullable time handling
type NullableTime struct {
Time time.Time `json:"time,omitempty"`
Valid bool `json:"valid"`
}
// Scan implements the sql.Scanner interface for NullableTime
func (n *NullableTime) Scan(value interface{}) error {
var nt sql.NullTime
if err := nt.Scan(value); err != nil {
return err
}
n.Time = nt.Time
n.Valid = nt.Valid
return nil
}
// Value implements the driver.Valuer interface for NullableTime
func (n NullableTime) Value() (driver.Value, error) {
if !n.Valid {
return nil, nil
}
return n.Time, nil
}
// Metadata untuk pagination - dioptimalkan
type MetaResponse struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
CurrentPage int `json:"current_page"`
HasNext bool `json:"has_next"`
HasPrev bool `json:"has_prev"`
}
// Aggregate data untuk summary
type AggregateData struct {
TotalActive int `json:"total_active"`
TotalDraft int `json:"total_draft"`
TotalInactive int `json:"total_inactive"`
ByStatus map[string]int `json:"by_status"`
ByDinas map[string]int `json:"by_dinas,omitempty"`
ByJenis map[string]int `json:"by_jenis,omitempty"`
LastUpdated *time.Time `json:"last_updated,omitempty"`
CreatedToday int `json:"created_today"`
UpdatedToday int `json:"updated_today"`
}
// Error response yang konsisten
type ErrorResponse struct {
Error string `json:"error"`
Code int `json:"code"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}
// BaseRequest contains common fields for all BPJS requests
type BaseRequest struct {
RequestID string `json:"request_id,omitempty"`
Timestamp time.Time `json:"timestamp,omitempty"`
}
// BaseResponse contains common response fields
type BaseResponse struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
RequestID string `json:"request_id,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
// ErrorResponse represents error response structure
type ErrorResponseBpjs struct {
Status string `json:"status"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
Errors map[string]interface{} `json:"errors,omitempty"`
Code string `json:"code,omitempty"`
}
// PaginationRequest contains pagination parameters
type PaginationRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
SortBy string `json:"sort_by,omitempty"`
SortDir string `json:"sort_dir,omitempty" validate:"omitempty,oneof=asc desc"`
}
// PaginationResponse contains pagination metadata
type PaginationResponse struct {
CurrentPage int `json:"current_page"`
TotalPages int `json:"total_pages"`
TotalItems int64 `json:"total_items"`
ItemsPerPage int `json:"items_per_page"`
HasNext bool `json:"has_next"`
HasPrev bool `json:"has_previous"`
}
// MetaInfo contains additional metadata
type MetaInfo struct {
Version string `json:"version"`
Environment string `json:"environment"`
ServerTime string `json:"server_time"`
}
func GetStatusCodeFromMeta(metaCode interface{}) int {
statusCode := http.StatusOK
if metaCode != nil {
switch v := metaCode.(type) {
case string:
if code, err := strconv.Atoi(v); err == nil {
if code >= 100 && code <= 599 {
statusCode = code
} else {
statusCode = http.StatusInternalServerError
}
} else {
statusCode = http.StatusInternalServerError
}
case int:
if v >= 100 && v <= 599 {
statusCode = v
} else {
statusCode = http.StatusInternalServerError
}
case float64:
code := int(v)
if code >= 100 && code <= 599 {
statusCode = code
} else {
statusCode = http.StatusInternalServerError
}
default:
statusCode = http.StatusInternalServerError
}
}
return statusCode
}
// Validation constants
const (
StatusDraft = "draft"
StatusActive = "active"
StatusInactive = "inactive"
StatusDeleted = "deleted"
)
// ValidStatuses untuk validasi
var ValidStatuses = []string{StatusDraft, StatusActive, StatusInactive}
// IsValidStatus helper function
func IsValidStatus(status string) bool {
for _, validStatus := range ValidStatuses {
if status == validStatus {
return true
}
}
return false
}

View File

@@ -0,0 +1,228 @@
package retribusi
import (
"api-service/internal/models"
"encoding/json"
"time"
)
// Retribusi represents the data structure for the retribusi table
// with proper null handling and optimized JSON marshaling
type Retribusi struct {
ID string `json:"id" db:"id"`
Status string `json:"status" db:"status"`
Sort models.NullableInt32 `json:"sort,omitempty" db:"sort"`
UserCreated models.NullableString `json:"user_created,omitempty" db:"user_created"`
DateCreated models.NullableTime `json:"date_created,omitempty" db:"date_created"`
UserUpdated models.NullableString `json:"user_updated,omitempty" db:"user_updated"`
DateUpdated models.NullableTime `json:"date_updated,omitempty" db:"date_updated"`
Jenis models.NullableString `json:"jenis,omitempty" db:"Jenis"`
Pelayanan models.NullableString `json:"pelayanan,omitempty" db:"Pelayanan"`
Dinas models.NullableString `json:"dinas,omitempty" db:"Dinas"`
KelompokObyek models.NullableString `json:"kelompok_obyek,omitempty" db:"Kelompok_obyek"`
KodeTarif models.NullableString `json:"kode_tarif,omitempty" db:"Kode_tarif"`
Tarif models.NullableString `json:"tarif,omitempty" db:"Tarif"`
Satuan models.NullableString `json:"satuan,omitempty" db:"Satuan"`
TarifOvertime models.NullableString `json:"tarif_overtime,omitempty" db:"Tarif_overtime"`
SatuanOvertime models.NullableString `json:"satuan_overtime,omitempty" db:"Satuan_overtime"`
RekeningPokok models.NullableString `json:"rekening_pokok,omitempty" db:"Rekening_pokok"`
RekeningDenda models.NullableString `json:"rekening_denda,omitempty" db:"Rekening_denda"`
Uraian1 models.NullableString `json:"uraian_1,omitempty" db:"Uraian_1"`
Uraian2 models.NullableString `json:"uraian_2,omitempty" db:"Uraian_2"`
Uraian3 models.NullableString `json:"uraian_3,omitempty" db:"Uraian_3"`
}
// Custom JSON marshaling untuk Retribusi agar NULL values tidak muncul di response
func (r Retribusi) MarshalJSON() ([]byte, error) {
type Alias Retribusi
aux := &struct {
Sort *int `json:"sort,omitempty"`
UserCreated *string `json:"user_created,omitempty"`
DateCreated *time.Time `json:"date_created,omitempty"`
UserUpdated *string `json:"user_updated,omitempty"`
DateUpdated *time.Time `json:"date_updated,omitempty"`
Jenis *string `json:"jenis,omitempty"`
Pelayanan *string `json:"pelayanan,omitempty"`
Dinas *string `json:"dinas,omitempty"`
KelompokObyek *string `json:"kelompok_obyek,omitempty"`
KodeTarif *string `json:"kode_tarif,omitempty"`
Tarif *string `json:"tarif,omitempty"`
Satuan *string `json:"satuan,omitempty"`
TarifOvertime *string `json:"tarif_overtime,omitempty"`
SatuanOvertime *string `json:"satuan_overtime,omitempty"`
RekeningPokok *string `json:"rekening_pokok,omitempty"`
RekeningDenda *string `json:"rekening_denda,omitempty"`
Uraian1 *string `json:"uraian_1,omitempty"`
Uraian2 *string `json:"uraian_2,omitempty"`
Uraian3 *string `json:"uraian_3,omitempty"`
*Alias
}{
Alias: (*Alias)(&r),
}
// Convert NullableInt32 to pointer
if r.Sort.Valid {
sort := int(r.Sort.Int32)
aux.Sort = &sort
}
if r.UserCreated.Valid {
aux.UserCreated = &r.UserCreated.String
}
if r.DateCreated.Valid {
aux.DateCreated = &r.DateCreated.Time
}
if r.UserUpdated.Valid {
aux.UserUpdated = &r.UserUpdated.String
}
if r.DateUpdated.Valid {
aux.DateUpdated = &r.DateUpdated.Time
}
if r.Jenis.Valid {
aux.Jenis = &r.Jenis.String
}
if r.Pelayanan.Valid {
aux.Pelayanan = &r.Pelayanan.String
}
if r.Dinas.Valid {
aux.Dinas = &r.Dinas.String
}
if r.KelompokObyek.Valid {
aux.KelompokObyek = &r.KelompokObyek.String
}
if r.KodeTarif.Valid {
aux.KodeTarif = &r.KodeTarif.String
}
if r.Tarif.Valid {
aux.Tarif = &r.Tarif.String
}
if r.Satuan.Valid {
aux.Satuan = &r.Satuan.String
}
if r.TarifOvertime.Valid {
aux.TarifOvertime = &r.TarifOvertime.String
}
if r.SatuanOvertime.Valid {
aux.SatuanOvertime = &r.SatuanOvertime.String
}
if r.RekeningPokok.Valid {
aux.RekeningPokok = &r.RekeningPokok.String
}
if r.RekeningDenda.Valid {
aux.RekeningDenda = &r.RekeningDenda.String
}
if r.Uraian1.Valid {
aux.Uraian1 = &r.Uraian1.String
}
if r.Uraian2.Valid {
aux.Uraian2 = &r.Uraian2.String
}
if r.Uraian3.Valid {
aux.Uraian3 = &r.Uraian3.String
}
return json.Marshal(aux)
}
// Helper methods untuk mendapatkan nilai yang aman
func (r *Retribusi) GetJenis() string {
if r.Jenis.Valid {
return r.Jenis.String
}
return ""
}
func (r *Retribusi) GetDinas() string {
if r.Dinas.Valid {
return r.Dinas.String
}
return ""
}
func (r *Retribusi) GetTarif() string {
if r.Tarif.Valid {
return r.Tarif.String
}
return ""
}
// Response struct untuk GET by ID - diperbaiki struktur
type RetribusiGetByIDResponse struct {
Message string `json:"message"`
Data *Retribusi `json:"data"`
}
// Request struct untuk create - dioptimalkan dengan validasi
type RetribusiCreateRequest struct {
Status string `json:"status" validate:"required,oneof=draft active inactive"`
Jenis *string `json:"jenis,omitempty" validate:"omitempty,min=1,max=255"`
Pelayanan *string `json:"pelayanan,omitempty" validate:"omitempty,min=1,max=255"`
Dinas *string `json:"dinas,omitempty" validate:"omitempty,min=1,max=255"`
KelompokObyek *string `json:"kelompok_obyek,omitempty" validate:"omitempty,min=1,max=255"`
KodeTarif *string `json:"kode_tarif,omitempty" validate:"omitempty,min=1,max=255"`
Uraian1 *string `json:"uraian_1,omitempty"`
Uraian2 *string `json:"uraian_2,omitempty"`
Uraian3 *string `json:"uraian_3,omitempty"`
Tarif *string `json:"tarif,omitempty" validate:"omitempty,numeric"`
Satuan *string `json:"satuan,omitempty" validate:"omitempty,min=1,max=255"`
TarifOvertime *string `json:"tarif_overtime,omitempty" validate:"omitempty,numeric"`
SatuanOvertime *string `json:"satuan_overtime,omitempty" validate:"omitempty,min=1,max=255"`
RekeningPokok *string `json:"rekening_pokok,omitempty" validate:"omitempty,min=1,max=255"`
RekeningDenda *string `json:"rekening_denda,omitempty" validate:"omitempty,min=1,max=255"`
}
// Response struct untuk create
type RetribusiCreateResponse struct {
Message string `json:"message"`
Data *Retribusi `json:"data"`
}
// Update request - sama seperti create tapi dengan ID
type RetribusiUpdateRequest struct {
ID string `json:"-" validate:"required,uuid4"` // ID dari URL path
Status string `json:"status" validate:"required,oneof=draft active inactive"`
Jenis *string `json:"jenis,omitempty" validate:"omitempty,min=1,max=255"`
Pelayanan *string `json:"pelayanan,omitempty" validate:"omitempty,min=1,max=255"`
Dinas *string `json:"dinas,omitempty" validate:"omitempty,min=1,max=255"`
KelompokObyek *string `json:"kelompok_obyek,omitempty" validate:"omitempty,min=1,max=255"`
KodeTarif *string `json:"kode_tarif,omitempty" validate:"omitempty,min=1,max=255"`
Uraian1 *string `json:"uraian_1,omitempty"`
Uraian2 *string `json:"uraian_2,omitempty"`
Uraian3 *string `json:"uraian_3,omitempty"`
Tarif *string `json:"tarif,omitempty" validate:"omitempty,numeric"`
Satuan *string `json:"satuan,omitempty" validate:"omitempty,min=1,max=255"`
TarifOvertime *string `json:"tarif_overtime,omitempty" validate:"omitempty,numeric"`
SatuanOvertime *string `json:"satuan_overtime,omitempty" validate:"omitempty,min=1,max=255"`
RekeningPokok *string `json:"rekening_pokok,omitempty" validate:"omitempty,min=1,max=255"`
RekeningDenda *string `json:"rekening_denda,omitempty" validate:"omitempty,min=1,max=255"`
}
// Response struct untuk update
type RetribusiUpdateResponse struct {
Message string `json:"message"`
Data *Retribusi `json:"data"`
}
// Response struct untuk delete
type RetribusiDeleteResponse struct {
Message string `json:"message"`
ID string `json:"id"`
}
// Enhanced GET response dengan pagination dan aggregation
type RetribusiGetResponse struct {
Message string `json:"message"`
Data []Retribusi `json:"data"`
Meta models.MetaResponse `json:"meta"`
Summary *models.AggregateData `json:"summary,omitempty"`
}
// Filter struct untuk query parameters
type RetribusiFilter struct {
Status *string `json:"status,omitempty" form:"status"`
Jenis *string `json:"jenis,omitempty" form:"jenis"`
Dinas *string `json:"dinas,omitempty" form:"dinas"`
KelompokObyek *string `json:"kelompok_obyek,omitempty" form:"kelompok_obyek"`
Search *string `json:"search,omitempty" form:"search"`
DateFrom *time.Time `json:"date_from,omitempty" form:"date_from"`
DateTo *time.Time `json:"date_to,omitempty" form:"date_to"`
}

View File

@@ -0,0 +1,106 @@
package models
import (
"regexp"
"strings"
"time"
"github.com/go-playground/validator/v10"
)
// CustomValidator wraps the validator
type CustomValidator struct {
Validator *validator.Validate
}
// Validate validates struct
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.Validator.Struct(i)
}
// RegisterCustomValidations registers custom validation rules
func RegisterCustomValidations(v *validator.Validate) {
// Validate Indonesian phone number
v.RegisterValidation("indonesian_phone", validateIndonesianPhone)
// Validate BPJS card number format
v.RegisterValidation("bpjs_card", validateBPJSCard)
// Validate Indonesian NIK
v.RegisterValidation("indonesian_nik", validateIndonesianNIK)
// Validate date format YYYY-MM-DD
v.RegisterValidation("date_format", validateDateFormat)
// Validate ICD-10 code format
v.RegisterValidation("icd10", validateICD10)
// Validate ICD-9-CM procedure code
v.RegisterValidation("icd9cm", validateICD9CM)
}
func validateIndonesianPhone(fl validator.FieldLevel) bool {
phone := fl.Field().String()
if phone == "" {
return true // Optional field
}
// Indonesian phone number pattern: +62, 62, 08, or 8
pattern := `^(\+?62|0?8)[1-9][0-9]{7,11}$`
matched, _ := regexp.MatchString(pattern, phone)
return matched
}
func validateBPJSCard(fl validator.FieldLevel) bool {
card := fl.Field().String()
if len(card) != 13 {
return false
}
// BPJS card should be numeric
pattern := `^\d{13}$`
matched, _ := regexp.MatchString(pattern, card)
return matched
}
func validateIndonesianNIK(fl validator.FieldLevel) bool {
nik := fl.Field().String()
if len(nik) != 16 {
return false
}
// NIK should be numeric
pattern := `^\d{16}$`
matched, _ := regexp.MatchString(pattern, nik)
return matched
}
func validateDateFormat(fl validator.FieldLevel) bool {
dateStr := fl.Field().String()
_, err := time.Parse("2006-01-02", dateStr)
return err == nil
}
func validateICD10(fl validator.FieldLevel) bool {
code := fl.Field().String()
if code == "" {
return true
}
// Basic ICD-10 pattern: Letter followed by 2 digits, optional dot and more digits
pattern := `^[A-Z]\d{2}(\.\d+)?$`
matched, _ := regexp.MatchString(pattern, strings.ToUpper(code))
return matched
}
func validateICD9CM(fl validator.FieldLevel) bool {
code := fl.Field().String()
if code == "" {
return true
}
// Basic ICD-9-CM procedure pattern: 2-4 digits with optional decimal
pattern := `^\d{2,4}(\.\d+)?$`
matched, _ := regexp.MatchString(pattern, code)
return matched
}

View File

@@ -0,0 +1,774 @@
package v1
import (
"api-service/internal/config"
"api-service/internal/database"
authHandlers "api-service/internal/handlers/auth"
healthcheckHandlers "api-service/internal/handlers/healthcheck"
retribusiHandlers "api-service/internal/handlers/retribusi"
"api-service/internal/handlers/websocket"
websocketHandlers "api-service/internal/handlers/websocket"
"api-service/internal/middleware"
services "api-service/internal/services/auth"
"api-service/pkg/logger"
"encoding/json"
"strconv"
"time"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
func RegisterRoutes(cfg *config.Config) *gin.Engine {
router := gin.New()
// Initialize auth middleware configuration
middleware.InitializeAuth(cfg)
// Add global middleware
router.Use(middleware.CORSConfig())
router.Use(middleware.ErrorHandler())
router.Use(logger.RequestLoggerMiddleware(logger.Default()))
router.Use(gin.Recovery())
// Initialize services with error handling
authService := services.NewAuthService(cfg)
if authService == nil {
logger.Fatal("Failed to initialize auth service")
}
// Initialize database service
dbService := database.New(cfg)
// Initialize WebSocket handler with enhanced features
websocketHandler := websocketHandlers.NewWebSocketHandler(cfg, dbService)
// =============================================================================
// HEALTH CHECK & SYSTEM ROUTES
// =============================================================================
healthCheckHandler := healthcheckHandlers.NewHealthCheckHandler(dbService)
sistem := router.Group("/api/sistem")
{
sistem.GET("/health", healthCheckHandler.CheckHealth)
sistem.GET("/databases", func(c *gin.Context) {
c.JSON(200, gin.H{
"databases": dbService.ListDBs(),
"health": dbService.Health(),
"timestamp": time.Now().Unix(),
})
})
sistem.GET("/info", func(c *gin.Context) {
c.JSON(200, gin.H{
"service": "API Service v1.0.0",
"websocket_active": true,
"connected_clients": websocketHandler.GetConnectedClients(),
"databases": dbService.ListDBs(),
"timestamp": time.Now().Unix(),
})
})
}
// =============================================================================
// SWAGGER DOCUMENTATION
// =============================================================================
router.GET("/swagger/*any", ginSwagger.WrapHandler(
swaggerFiles.Handler,
ginSwagger.DefaultModelsExpandDepth(-1),
ginSwagger.DeepLinking(true),
))
// =============================================================================
// WEBSOCKET TEST CLIENT
// =============================================================================
// router.GET("/websocket-test", func(c *gin.Context) {
// c.Header("Content-Type", "text/html")
// c.String(http.StatusOK, getWebSocketTestHTML())
// })
// =============================================================================
// API v1 GROUP
// =============================================================================
v1 := router.Group("/api/v1")
// =============================================================================
// PUBLIC ROUTES (No Authentication Required)
// =============================================================================
// Authentication routes
authHandler := authHandlers.NewAuthHandler(authService)
tokenHandler := authHandlers.NewTokenHandler(authService)
// Basic auth routes
v1.POST("/auth/login", authHandler.Login)
v1.POST("/auth/register", authHandler.Register)
v1.POST("/auth/refresh", authHandler.RefreshToken)
// Token generation routes
v1.POST("/token/generate", tokenHandler.GenerateToken)
v1.POST("/token/generate-direct", tokenHandler.GenerateTokenDirect)
// =============================================================================
// WEBSOCKET ROUTES
// =============================================================================
// Main WebSocket endpoint with enhanced features
v1.GET("/ws", websocketHandler.HandleWebSocket)
// WebSocket management API
wsAPI := router.Group("/api/websocket")
{
// =============================================================================
// BASIC BROADCASTING
// =============================================================================
wsAPI.POST("/broadcast", func(c *gin.Context) {
var req struct {
Type string `json:"type"`
Message interface{} `json:"message"`
Database string `json:"database,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
websocketHandler.BroadcastMessage(req.Type, req.Message)
c.JSON(200, gin.H{
"status": "broadcast sent",
"clients_count": websocketHandler.GetConnectedClients(),
"timestamp": time.Now().Unix(),
})
})
wsAPI.POST("/broadcast/room/:room", func(c *gin.Context) {
room := c.Param("room")
var req struct {
Type string `json:"type"`
Message interface{} `json:"message"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
websocketHandler.BroadcastToRoom(room, req.Type, req.Message)
c.JSON(200, gin.H{
"status": "room broadcast sent",
"room": room,
"clients_count": websocketHandler.GetRoomClientCount(room), // Fix: gunakan GetRoomClientCount
"timestamp": time.Now().Unix(),
})
})
// =============================================================================
// ENHANCED CLIENT TARGETING
// =============================================================================
wsAPI.POST("/send/:clientId", func(c *gin.Context) {
clientID := c.Param("clientId")
var req struct {
Type string `json:"type"`
Message interface{} `json:"message"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
websocketHandler.SendToClient(clientID, req.Type, req.Message)
c.JSON(200, gin.H{
"status": "message sent",
"client_id": clientID,
"timestamp": time.Now().Unix(),
})
})
// Send to client by static ID
wsAPI.POST("/send/static/:staticId", func(c *gin.Context) {
staticID := c.Param("staticId")
logger.Infof("Sending message to static client: %s", staticID)
var req struct {
Type string `json:"type"`
Message interface{} `json:"message"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
success := websocketHandler.SendToClientByStaticID(staticID, req.Type, req.Message)
if success {
c.JSON(200, gin.H{
"status": "message sent to static client",
"static_id": staticID,
"timestamp": time.Now().Unix(),
})
} else {
c.JSON(404, gin.H{
"error": "static client not found",
"static_id": staticID,
"timestamp": time.Now().Unix(),
})
}
})
// Broadcast to all clients from specific IP
wsAPI.POST("/broadcast/ip/:ipAddress", func(c *gin.Context) {
ipAddress := c.Param("ipAddress")
var req struct {
Type string `json:"type"`
Message interface{} `json:"message"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
count := websocketHandler.BroadcastToIP(ipAddress, req.Type, req.Message)
c.JSON(200, gin.H{
"status": "ip broadcast sent",
"ip_address": ipAddress,
"clients_count": count,
"timestamp": time.Now().Unix(),
})
})
// =============================================================================
// CLIENT INFORMATION & STATISTICS
// =============================================================================
wsAPI.GET("/stats", func(c *gin.Context) {
c.JSON(200, gin.H{
"connected_clients": websocketHandler.GetConnectedClients(),
"databases": dbService.ListDBs(),
"database_health": dbService.Health(),
"timestamp": time.Now().Unix(),
})
})
wsAPI.GET("/stats/detailed", func(c *gin.Context) {
stats := websocketHandler.GetDetailedStats()
c.JSON(200, gin.H{
"stats": stats,
"timestamp": time.Now().Unix(),
})
})
wsAPI.GET("/clients", func(c *gin.Context) {
clients := websocketHandler.GetAllClients()
c.JSON(200, gin.H{
"clients": clients,
"count": len(clients),
"timestamp": time.Now().Unix(),
})
})
// Fix: Perbaiki GetClientsByIP untuk menggunakan ClientInfo
wsAPI.GET("/clients/by-ip/:ipAddress", func(c *gin.Context) {
ipAddress := c.Param("ipAddress")
client := websocketHandler.GetClientsByIP(ipAddress)
if client == nil {
c.JSON(404, gin.H{
"error": "client not found",
"ip_address": ipAddress,
"timestamp": time.Now().Unix(),
})
return
}
// Use ClientInfo struct instead of direct field access
clientInfo := websocketHandler.GetAllClients()
var targetClientInfo *websocket.ClientInfo
for i := range clientInfo {
if clientInfo[i].ID == ipAddress {
targetClientInfo = &clientInfo[i]
break
}
}
if targetClientInfo == nil {
c.JSON(404, gin.H{
"error": "ipAddress not found",
"client_id": ipAddress,
"timestamp": time.Now().Unix(),
})
return
}
c.JSON(200, gin.H{
"client": map[string]interface{}{
"id": targetClientInfo.ID,
"static_id": targetClientInfo.StaticID,
"ip_address": targetClientInfo.IPAddress,
"user_id": targetClientInfo.UserID,
"room": targetClientInfo.Room,
"connected_at": targetClientInfo.ConnectedAt.Unix(), // Fixed: use exported field
"last_ping": targetClientInfo.LastPing.Unix(), // Fixed: use exported field
},
"timestamp": time.Now().Unix(),
})
})
// Fix: Perbaiki GetClientByID response
wsAPI.GET("/client/:clientId", func(c *gin.Context) {
clientID := c.Param("clientId")
client := websocketHandler.GetClientByID(clientID)
if client == nil {
c.JSON(404, gin.H{
"error": "client not found",
"client_id": clientID,
"timestamp": time.Now().Unix(),
})
return
}
// Use ClientInfo struct instead of direct field access
clientInfo := websocketHandler.GetAllClients()
var targetClientInfo *websocket.ClientInfo
for i := range clientInfo {
if clientInfo[i].ID == clientID {
targetClientInfo = &clientInfo[i]
break
}
}
if targetClientInfo == nil {
c.JSON(404, gin.H{
"error": "client not found",
"client_id": clientID,
"timestamp": time.Now().Unix(),
})
return
}
c.JSON(200, gin.H{
"client": map[string]interface{}{
"id": targetClientInfo.ID,
"static_id": targetClientInfo.StaticID,
"ip_address": targetClientInfo.IPAddress,
"user_id": targetClientInfo.UserID,
"room": targetClientInfo.Room,
"connected_at": targetClientInfo.ConnectedAt.Unix(), // Fixed: use exported field
"last_ping": targetClientInfo.LastPing.Unix(), // Fixed: use exported field
},
"timestamp": time.Now().Unix(),
})
})
// Fix: Perbaiki GetClientByStaticID response
wsAPI.GET("/client/static/:staticId", func(c *gin.Context) {
staticID := c.Param("staticId")
client := websocketHandler.GetClientByStaticID(staticID)
if client == nil {
c.JSON(404, gin.H{
"error": "static client not found",
"static_id": staticID,
"timestamp": time.Now().Unix(),
})
return
}
// Use ClientInfo struct instead of direct field access
clientInfo := websocketHandler.GetAllClients()
var targetClientInfo *websocket.ClientInfo
for i := range clientInfo {
if clientInfo[i].StaticID == staticID {
targetClientInfo = &clientInfo[i]
break
}
}
if targetClientInfo == nil {
c.JSON(404, gin.H{
"error": "static client not found",
"static_id": staticID,
"timestamp": time.Now().Unix(),
})
return
}
c.JSON(200, gin.H{
"client": map[string]interface{}{
"id": targetClientInfo.ID,
"static_id": targetClientInfo.StaticID,
"ip_address": targetClientInfo.IPAddress,
"user_id": targetClientInfo.UserID,
"room": targetClientInfo.Room,
"connected_at": targetClientInfo.ConnectedAt.Unix(), // Fixed: use exported field
"last_ping": targetClientInfo.LastPing.Unix(), // Fixed: use exported field
},
"timestamp": time.Now().Unix(),
})
})
// =============================================================================
// ACTIVE CLIENTS & CLEANUP
// =============================================================================
// Tambahkan endpoint untuk active clients
wsAPI.GET("/clients/active", func(c *gin.Context) {
// Default: clients active dalam 5 menit terakhir
minutes := c.DefaultQuery("minutes", "5")
minutesInt, err := strconv.Atoi(minutes)
if err != nil {
minutesInt = 5
}
activeClients := websocketHandler.GetActiveClients(time.Duration(minutesInt) * time.Minute)
c.JSON(200, gin.H{
"active_clients": activeClients,
"count": len(activeClients),
"threshold_minutes": minutesInt,
"timestamp": time.Now().Unix(),
})
})
// Tambahkan endpoint untuk cleanup inactive clients
wsAPI.POST("/cleanup/inactive", func(c *gin.Context) {
var req struct {
InactiveMinutes int `json:"inactive_minutes"`
}
if err := c.ShouldBindJSON(&req); err != nil {
req.InactiveMinutes = 30 // Default 30 minutes
}
if req.InactiveMinutes <= 0 {
req.InactiveMinutes = 30
}
cleanedCount := websocketHandler.CleanupInactiveClients(time.Duration(req.InactiveMinutes) * time.Minute)
c.JSON(200, gin.H{
"status": "cleanup completed",
"cleaned_clients": cleanedCount,
"inactive_minutes": req.InactiveMinutes,
"timestamp": time.Now().Unix(),
})
})
// =============================================================================
// DATABASE NOTIFICATIONS
// =============================================================================
wsAPI.POST("/notify/:database/:channel", func(c *gin.Context) {
database := c.Param("database")
channel := c.Param("channel")
var req struct {
Payload interface{} `json:"payload"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
payloadJSON, _ := json.Marshal(req.Payload)
err := dbService.NotifyChange(database, channel, string(payloadJSON))
if err != nil {
c.JSON(500, gin.H{
"error": err.Error(),
"database": database,
"channel": channel,
"timestamp": time.Now().Unix(),
})
return
}
c.JSON(200, gin.H{
"status": "notification sent",
"database": database,
"channel": channel,
"timestamp": time.Now().Unix(),
})
})
// Test database notification
wsAPI.POST("/test-notification", func(c *gin.Context) {
var req struct {
Database string `json:"database"`
Channel string `json:"channel"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Default values
if req.Database == "" {
req.Database = "default"
}
if req.Channel == "" {
req.Channel = "system_changes"
}
if req.Message == "" {
req.Message = "Test notification from API"
}
payload := map[string]interface{}{
"operation": "API_TEST",
"table": "manual_test",
"data": map[string]interface{}{
"message": req.Message,
"test_data": req.Data,
"timestamp": time.Now().Unix(),
},
}
payloadJSON, _ := json.Marshal(payload)
err := dbService.NotifyChange(req.Database, req.Channel, string(payloadJSON))
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"status": "test notification sent",
"database": req.Database,
"channel": req.Channel,
"payload": payload,
"timestamp": time.Now().Unix(),
})
})
// =============================================================================
// ROOM MANAGEMENT
// =============================================================================
wsAPI.GET("/rooms", func(c *gin.Context) {
rooms := websocketHandler.GetAllRooms()
c.JSON(200, gin.H{
"rooms": rooms,
"count": len(rooms),
"timestamp": time.Now().Unix(),
})
})
wsAPI.GET("/room/:room/clients", func(c *gin.Context) {
room := c.Param("room")
clientCount := websocketHandler.GetRoomClientCount(room)
// Get detailed room info
allRooms := websocketHandler.GetAllRooms()
roomClients := allRooms[room]
c.JSON(200, gin.H{
"room": room,
"client_count": clientCount,
"clients": roomClients,
"timestamp": time.Now().Unix(),
})
})
// =============================================================================
// MONITORING & DEBUGGING
// =============================================================================
wsAPI.GET("/monitor", func(c *gin.Context) {
monitor := websocketHandler.GetMonitoringData()
c.JSON(200, monitor)
})
wsAPI.POST("/ping-client/:clientId", func(c *gin.Context) {
clientID := c.Param("clientId")
websocketHandler.SendToClient(clientID, "server_ping", map[string]interface{}{
"message": "Ping from server",
"timestamp": time.Now().Unix(),
})
c.JSON(200, gin.H{
"status": "ping sent",
"client_id": clientID,
"timestamp": time.Now().Unix(),
})
})
// Disconnect specific client
wsAPI.POST("/disconnect/:clientId", func(c *gin.Context) {
clientID := c.Param("clientId")
success := websocketHandler.DisconnectClient(clientID)
if success {
c.JSON(200, gin.H{
"status": "client disconnected",
"client_id": clientID,
"timestamp": time.Now().Unix(),
})
} else {
c.JSON(404, gin.H{
"error": "client not found",
"client_id": clientID,
"timestamp": time.Now().Unix(),
})
}
})
// =============================================================================
// BULK OPERATIONS
// =============================================================================
// Broadcast to multiple clients
wsAPI.POST("/broadcast/bulk", func(c *gin.Context) {
var req struct {
ClientIDs []string `json:"client_ids"`
Type string `json:"type"`
Message interface{} `json:"message"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
successCount := 0
for _, clientID := range req.ClientIDs {
websocketHandler.SendToClient(clientID, req.Type, req.Message)
successCount++
}
c.JSON(200, gin.H{
"status": "bulk broadcast sent",
"total_clients": len(req.ClientIDs),
"success_count": successCount,
"timestamp": time.Now().Unix(),
})
})
// Disconnect multiple clients
wsAPI.POST("/disconnect/bulk", func(c *gin.Context) {
var req struct {
ClientIDs []string `json:"client_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
successCount := 0
for _, clientID := range req.ClientIDs {
if websocketHandler.DisconnectClient(clientID) {
successCount++
}
}
c.JSON(200, gin.H{
"status": "bulk disconnect completed",
"total_clients": len(req.ClientIDs),
"success_count": successCount,
"timestamp": time.Now().Unix(),
})
})
}
// =============================================================================
// PUBLISHED ROUTES
// =============================================================================
// Retribusi endpoints with WebSocket notifications
retribusiHandler := retribusiHandlers.NewRetribusiHandler()
retribusiGroup := v1.Group("/retribusi")
{
retribusiGroup.GET("", retribusiHandler.GetRetribusi)
retribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic)
retribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced)
retribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID)
// POST/PUT/DELETE with automatic WebSocket notifications
retribusiGroup.POST("", func(c *gin.Context) {
retribusiHandler.CreateRetribusi(c)
// Trigger WebSocket notification after successful creation
if c.Writer.Status() == 200 || c.Writer.Status() == 201 {
websocketHandler.BroadcastMessage("retribusi_created", map[string]interface{}{
"message": "New retribusi record created",
"timestamp": time.Now().Unix(),
})
}
})
retribusiGroup.PUT("/id/:id", func(c *gin.Context) {
id := c.Param("id")
retribusiHandler.UpdateRetribusi(c)
// Trigger WebSocket notification after successful update
if c.Writer.Status() == 200 {
websocketHandler.BroadcastMessage("retribusi_updated", map[string]interface{}{
"message": "Retribusi record updated",
"id": id,
"timestamp": time.Now().Unix(),
})
}
})
retribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
id := c.Param("id")
retribusiHandler.DeleteRetribusi(c)
// Trigger WebSocket notification after successful deletion
if c.Writer.Status() == 200 {
websocketHandler.BroadcastMessage("retribusi_deleted", map[string]interface{}{
"message": "Retribusi record deleted",
"id": id,
"timestamp": time.Now().Unix(),
})
}
})
}
// =============================================================================
// PROTECTED ROUTES (Authentication Required)
// =============================================================================
protected := v1.Group("/")
protected.Use(middleware.ConfigurableAuthMiddleware(cfg))
// Protected WebSocket management (optional)
protectedWS := protected.Group("/ws-admin")
{
protectedWS.GET("/stats", func(c *gin.Context) {
detailedStats := websocketHandler.GetDetailedStats()
c.JSON(200, gin.H{
"admin_stats": detailedStats,
"timestamp": time.Now().Unix(),
})
})
protectedWS.POST("/force-disconnect/:clientId", func(c *gin.Context) {
clientID := c.Param("clientId")
success := websocketHandler.DisconnectClient(clientID)
c.JSON(200, gin.H{
"status": "force disconnect attempted",
"client_id": clientID,
"success": success,
"timestamp": time.Now().Unix(),
})
})
protectedWS.POST("/cleanup/force", func(c *gin.Context) {
var req struct {
InactiveMinutes int `json:"inactive_minutes"`
Force bool `json:"force"`
}
if err := c.ShouldBindJSON(&req); err != nil {
req.InactiveMinutes = 10
req.Force = false
}
cleanedCount := websocketHandler.CleanupInactiveClients(time.Duration(req.InactiveMinutes) * time.Minute)
c.JSON(200, gin.H{
"status": "admin cleanup completed",
"cleaned_clients": cleanedCount,
"inactive_minutes": req.InactiveMinutes,
"force": req.Force,
"timestamp": time.Now().Unix(),
})
})
}
return router
}

53
internal/server/server.go Normal file
View File

@@ -0,0 +1,53 @@
package server
import (
"fmt"
"net/http"
"os"
"strconv"
"time"
_ "github.com/joho/godotenv/autoload"
"api-service/internal/config"
"api-service/internal/database"
v1 "api-service/internal/routes/v1"
)
var dbService database.Service // Global variable to hold the database service instance
type Server struct {
port int
db database.Service
}
func NewServer() *http.Server {
// Load configuration
cfg := config.LoadConfig()
cfg.Validate()
port, _ := strconv.Atoi(os.Getenv("PORT"))
if port == 0 {
port = cfg.Server.Port
}
if dbService == nil { // Check if the database service is already initialized
dbService = database.New(cfg) // Initialize only once
}
NewServer := &Server{
port: port,
db: dbService, // Use the global database service instance
}
// Declare Server config
server := &http.Server{
Addr: fmt.Sprintf(":%d", NewServer.port),
Handler: v1.RegisterRoutes(cfg),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
return server
}

View File

@@ -0,0 +1,169 @@
package services
import (
"api-service/internal/config"
models "api-service/internal/models/auth"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// AuthService handles authentication logic
type AuthService struct {
config *config.Config
users map[string]*models.User // In-memory user store for demo
}
// NewAuthService creates a new authentication service
func NewAuthService(cfg *config.Config) *AuthService {
// Initialize with demo users
users := make(map[string]*models.User)
// Add demo users
users["admin"] = &models.User{
ID: "1",
Username: "admin",
Email: "admin@example.com",
Password: "$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", // password
Role: "admin",
}
users["user"] = &models.User{
ID: "2",
Username: "user",
Email: "user@example.com",
Password: "$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", // password
Role: "user",
}
return &AuthService{
config: cfg,
users: users,
}
}
// Login authenticates user and generates JWT token
func (s *AuthService) Login(username, password string) (*models.TokenResponse, error) {
user, exists := s.users[username]
if !exists {
return nil, errors.New("invalid credentials")
}
// Verify password
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
if err != nil {
return nil, errors.New("invalid credentials")
}
// Generate JWT token
token, err := s.generateToken(user)
if err != nil {
return nil, err
}
return &models.TokenResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: 3600, // 1 hour
}, nil
}
// generateToken creates a new JWT token for the user
func (s *AuthService) generateToken(user *models.User) (string, error) {
// Create claims
claims := jwt.MapClaims{
"user_id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
"exp": time.Now().Add(time.Hour * 1).Unix(),
"iat": time.Now().Unix(),
}
// Create token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign token with secret key
secretKey := []byte(s.getJWTSecret())
return token.SignedString(secretKey)
}
// GenerateTokenForUser generates a JWT token for a specific user
func (s *AuthService) GenerateTokenForUser(user *models.User) (string, error) {
// Create claims
claims := jwt.MapClaims{
"user_id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
"exp": time.Now().Add(time.Hour * 1).Unix(),
"iat": time.Now().Unix(),
}
// Create token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign token with secret key
secretKey := []byte(s.getJWTSecret())
return token.SignedString(secretKey)
}
// ValidateToken validates the JWT token
func (s *AuthService) ValidateToken(tokenString string) (*models.JWTClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(s.getJWTSecret()), nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("invalid claims")
}
return &models.JWTClaims{
UserID: claims["user_id"].(string),
Username: claims["username"].(string),
Email: claims["email"].(string),
Role: claims["role"].(string),
}, nil
}
// getJWTSecret returns the JWT secret key
func (s *AuthService) getJWTSecret() string {
// In production, this should come from environment variables
return "your-secret-key-change-this-in-production"
}
// RegisterUser registers a new user (for demo purposes)
func (s *AuthService) RegisterUser(username, email, password, role string) error {
if _, exists := s.users[username]; exists {
return errors.New("username already exists")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
s.users[username] = &models.User{
ID: string(rune(len(s.users) + 1)),
Username: username,
Email: email,
Password: string(hashedPassword),
Role: role,
}
return nil
}

View File

@@ -0,0 +1,593 @@
package utils
import (
"fmt"
"reflect"
"strings"
"sync"
)
// FilterOperator represents supported filter operators
type FilterOperator string
const (
OpEqual FilterOperator = "_eq"
OpNotEqual FilterOperator = "_neq"
OpLike FilterOperator = "_like"
OpILike FilterOperator = "_ilike"
OpIn FilterOperator = "_in"
OpNotIn FilterOperator = "_nin"
OpGreaterThan FilterOperator = "_gt"
OpGreaterThanEqual FilterOperator = "_gte"
OpLessThan FilterOperator = "_lt"
OpLessThanEqual FilterOperator = "_lte"
OpBetween FilterOperator = "_between"
OpNotBetween FilterOperator = "_nbetween"
OpNull FilterOperator = "_null"
OpNotNull FilterOperator = "_nnull"
OpContains FilterOperator = "_contains"
OpNotContains FilterOperator = "_ncontains"
OpStartsWith FilterOperator = "_starts_with"
OpEndsWith FilterOperator = "_ends_with"
)
// DynamicFilter represents a single filter condition
type DynamicFilter struct {
Column string `json:"column"`
Operator FilterOperator `json:"operator"`
Value interface{} `json:"value"`
LogicOp string `json:"logic_op,omitempty"` // AND, OR
}
// FilterGroup represents a group of filters
type FilterGroup struct {
Filters []DynamicFilter `json:"filters"`
LogicOp string `json:"logic_op"` // AND, OR
}
// DynamicQuery represents the complete query structure
type DynamicQuery struct {
Fields []string `json:"fields,omitempty"`
Filters []FilterGroup `json:"filters,omitempty"`
Sort []SortField `json:"sort,omitempty"`
Limit int `json:"limit"`
Offset int `json:"offset"`
GroupBy []string `json:"group_by,omitempty"`
Having []FilterGroup `json:"having,omitempty"`
}
// SortField represents sorting configuration
type SortField struct {
Column string `json:"column"`
Order string `json:"order"` // ASC, DESC
}
// QueryBuilder builds SQL queries from dynamic filters
type QueryBuilder struct {
tableName string
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
func NewQueryBuilder(tableName string) *QueryBuilder {
return &QueryBuilder{
tableName: tableName,
columnMapping: make(map[string]string),
allowedColumns: make(map[string]bool),
paramCounter: 0,
}
}
// SetColumnMapping sets the mapping between API field names and database column names
func (qb *QueryBuilder) SetColumnMapping(mapping map[string]string) *QueryBuilder {
qb.columnMapping = mapping
return qb
}
// SetAllowedColumns sets the list of allowed columns for security
func (qb *QueryBuilder) SetAllowedColumns(columns []string) *QueryBuilder {
qb.allowedColumns = make(map[string]bool)
for _, col := range columns {
qb.allowedColumns[col] = true
}
return qb
}
// BuildQuery builds the complete SQL query
func (qb *QueryBuilder) BuildQuery(query DynamicQuery) (string, []interface{}, error) {
qb.paramCounter = 0
// Build SELECT clause
selectClause := qb.buildSelectClause(query.Fields)
// Build FROM clause
fromClause := fmt.Sprintf("FROM %s", qb.tableName)
// Build WHERE clause
whereClause, whereArgs, err := qb.buildWhereClause(query.Filters)
if err != nil {
return "", nil, err
}
// Build ORDER BY clause
orderClause := qb.buildOrderClause(query.Sort)
// Build GROUP BY clause
groupClause := qb.buildGroupByClause(query.GroupBy)
// Build HAVING clause
havingClause, havingArgs, err := qb.buildHavingClause(query.Having)
if err != nil {
return "", nil, err
}
// Combine all parts
sqlParts := []string{selectClause, fromClause}
args := []interface{}{}
if whereClause != "" {
sqlParts = append(sqlParts, "WHERE "+whereClause)
args = append(args, whereArgs...)
}
if groupClause != "" {
sqlParts = append(sqlParts, groupClause)
}
if havingClause != "" {
sqlParts = append(sqlParts, "HAVING "+havingClause)
args = append(args, havingArgs...)
}
if orderClause != "" {
sqlParts = append(sqlParts, orderClause)
}
// Add pagination
if query.Limit > 0 {
qb.paramCounter++
sqlParts = append(sqlParts, fmt.Sprintf("LIMIT $%d", qb.paramCounter))
args = append(args, query.Limit)
}
if query.Offset > 0 {
qb.paramCounter++
sqlParts = append(sqlParts, fmt.Sprintf("OFFSET $%d", qb.paramCounter))
args = append(args, query.Offset)
}
sql := strings.Join(sqlParts, " ")
return sql, args, nil
}
// buildSelectClause builds the SELECT part of the query
func (qb *QueryBuilder) buildSelectClause(fields []string) string {
if len(fields) == 0 || (len(fields) == 1 && fields[0] == "*") {
return "SELECT *"
}
var selectedFields []string
for _, field := range fields {
if field == "*.*" || field == "*" {
selectedFields = append(selectedFields, "*")
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
}
selectedFields = append(selectedFields, fmt.Sprintf(`"%s"`, field))
}
if len(selectedFields) == 0 {
return "SELECT *"
}
return "SELECT " + strings.Join(selectedFields, ", ")
}
// buildWhereClause builds the WHERE part of the query
func (qb *QueryBuilder) buildWhereClause(filterGroups []FilterGroup) (string, []interface{}, error) {
if len(filterGroups) == 0 {
return "", nil, nil
}
var conditions []string
var args []interface{}
for i, group := range filterGroups {
groupCondition, groupArgs, err := qb.buildFilterGroup(group)
if err != nil {
return "", nil, err
}
if groupCondition != "" {
if i > 0 {
logicOp := "AND"
if group.LogicOp != "" {
logicOp = strings.ToUpper(group.LogicOp)
}
conditions = append(conditions, logicOp)
}
conditions = append(conditions, groupCondition)
args = append(args, groupArgs...)
}
}
return strings.Join(conditions, " "), args, nil
}
// buildFilterGroup builds conditions for a filter group
func (qb *QueryBuilder) buildFilterGroup(group FilterGroup) (string, []interface{}, error) {
if len(group.Filters) == 0 {
return "", nil, nil
}
var conditions []string
var args []interface{}
for i, filter := range group.Filters {
condition, filterArgs, err := qb.buildFilterCondition(filter)
if err != nil {
return "", nil, err
}
if condition != "" {
if i > 0 {
logicOp := "AND"
if filter.LogicOp != "" {
logicOp = strings.ToUpper(filter.LogicOp)
} else if group.LogicOp != "" {
logicOp = strings.ToUpper(group.LogicOp)
}
conditions = append(conditions, logicOp)
}
conditions = append(conditions, condition)
args = append(args, filterArgs...)
}
}
return strings.Join(conditions, " "), args, nil
}
// 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
}
// 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
case OpIn:
values := qb.parseArrayValue(filter.Value)
if len(values) == 0 {
return "", nil, nil
}
var placeholders []string
var args []interface{}
for _, val := range values {
qb.paramCounter++
placeholders = append(placeholders, fmt.Sprintf("$%d", qb.paramCounter))
args = append(args, val)
}
return fmt.Sprintf("%s IN (%s)", column, strings.Join(placeholders, ", ")), args, nil
case OpNotIn:
values := qb.parseArrayValue(filter.Value)
if len(values) == 0 {
return "", nil, nil
}
var placeholders []string
var args []interface{}
for _, val := range values {
qb.paramCounter++
placeholders = append(placeholders, fmt.Sprintf("$%d", qb.paramCounter))
args = append(args, val)
}
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")
}
qb.paramCounter++
param1 := qb.paramCounter
qb.paramCounter++
param2 := qb.paramCounter
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")
}
qb.paramCounter++
param1 := qb.paramCounter
qb.paramCounter++
param2 := qb.paramCounter
return fmt.Sprintf("%s NOT BETWEEN $%d AND $%d", column, param1, param2), []interface{}{values[0], values[1]}, nil
case OpNull:
return fmt.Sprintf("%s IS NULL", column), nil, nil
case OpNotNull:
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
default:
return "", nil, fmt.Errorf("unsupported operator: %s", filter.Operator)
}
}
// parseArrayValue parses array values from various formats
func (qb *QueryBuilder) parseArrayValue(value interface{}) []interface{} {
if value == nil {
return nil
}
// If it's already a slice
if reflect.TypeOf(value).Kind() == reflect.Slice {
v := reflect.ValueOf(value)
result := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
result[i] = v.Index(i).Interface()
}
return result
}
// If it's a string, try to split by comma
if str, ok := value.(string); ok {
if strings.Contains(str, ",") {
parts := strings.Split(str, ",")
result := make([]interface{}, len(parts))
for i, part := range parts {
result[i] = strings.TrimSpace(part)
}
return result
}
return []interface{}{str}
}
return []interface{}{value}
}
// buildOrderClause builds the ORDER BY clause
func (qb *QueryBuilder) buildOrderClause(sortFields []SortField) string {
if len(sortFields) == 0 {
return ""
}
var orderParts []string
for _, sort := range sortFields {
column := sort.Column
// 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)
}
orderParts = append(orderParts, fmt.Sprintf(`"%s" %s`, column, order))
}
if len(orderParts) == 0 {
return ""
}
return "ORDER BY " + strings.Join(orderParts, ", ")
}
// buildGroupByClause builds the GROUP BY clause
func (qb *QueryBuilder) buildGroupByClause(groupFields []string) string {
if len(groupFields) == 0 {
return ""
}
var groupParts []string
for _, field := range groupFields {
column := field
if mappedCol, exists := qb.columnMapping[column]; exists {
column = mappedCol
}
// Security check
if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] {
continue
}
groupParts = append(groupParts, fmt.Sprintf(`"%s"`, column))
}
if len(groupParts) == 0 {
return ""
}
return "GROUP BY " + strings.Join(groupParts, ", ")
}
// buildHavingClause builds the HAVING clause
func (qb *QueryBuilder) buildHavingClause(havingGroups []FilterGroup) (string, []interface{}, error) {
if len(havingGroups) == 0 {
return "", nil, nil
}
return qb.buildWhereClause(havingGroups)
}
// BuildCountQuery builds a count query
func (qb *QueryBuilder) BuildCountQuery(query DynamicQuery) (string, []interface{}, error) {
qb.paramCounter = 0
// Build FROM clause
fromClause := fmt.Sprintf("FROM %s", qb.tableName)
// Build WHERE clause
whereClause, whereArgs, err := qb.buildWhereClause(query.Filters)
if err != nil {
return "", nil, err
}
// Build GROUP BY clause
groupClause := qb.buildGroupByClause(query.GroupBy)
// Build HAVING clause
havingClause, havingArgs, err := qb.buildHavingClause(query.Having)
if err != nil {
return "", nil, err
}
// Combine parts
sqlParts := []string{"SELECT COUNT(*)", fromClause}
args := []interface{}{}
if whereClause != "" {
sqlParts = append(sqlParts, "WHERE "+whereClause)
args = append(args, whereArgs...)
}
if groupClause != "" {
sqlParts = append(sqlParts, groupClause)
}
if havingClause != "" {
sqlParts = append(sqlParts, "HAVING "+havingClause)
args = append(args, havingArgs...)
}
sql := strings.Join(sqlParts, " ")
return sql, args, nil
}

View File

@@ -0,0 +1,241 @@
package utils
import (
"net/url"
"strconv"
"strings"
"time"
)
// QueryParser parses HTTP query parameters into DynamicQuery
type QueryParser struct {
defaultLimit int
maxLimit int
}
// NewQueryParser creates a new query parser
func NewQueryParser() *QueryParser {
return &QueryParser{
defaultLimit: 10,
maxLimit: 100,
}
}
// SetLimits sets default and maximum limits
func (qp *QueryParser) SetLimits(defaultLimit, maxLimit int) *QueryParser {
qp.defaultLimit = defaultLimit
qp.maxLimit = maxLimit
return qp
}
// ParseQuery parses URL query parameters into DynamicQuery
func (qp *QueryParser) ParseQuery(values url.Values) (DynamicQuery, error) {
query := DynamicQuery{
Limit: qp.defaultLimit,
Offset: 0,
}
// Parse fields
if fields := values.Get("fields"); fields != "" {
if fields == "*.*" || fields == "*" {
query.Fields = []string{"*"}
} else {
query.Fields = strings.Split(fields, ",")
for i, field := range query.Fields {
query.Fields[i] = strings.TrimSpace(field)
}
}
}
// Parse pagination
if limit := values.Get("limit"); limit != "" {
if l, err := strconv.Atoi(limit); err == nil {
if l > 0 && l <= qp.maxLimit {
query.Limit = l
}
}
}
if offset := values.Get("offset"); offset != "" {
if o, err := strconv.Atoi(offset); err == nil && o >= 0 {
query.Offset = o
}
}
// Parse filters
filters, err := qp.parseFilters(values)
if err != nil {
return query, err
}
query.Filters = filters
// Parse sorting
sorts, err := qp.parseSorting(values)
if err != nil {
return query, err
}
query.Sort = sorts
// Parse group by
if groupBy := values.Get("group"); groupBy != "" {
query.GroupBy = strings.Split(groupBy, ",")
for i, field := range query.GroupBy {
query.GroupBy[i] = strings.TrimSpace(field)
}
}
return query, nil
}
// parseFilters parses filter parameters
// Supports format: filter[column][operator]=value
func (qp *QueryParser) parseFilters(values url.Values) ([]FilterGroup, error) {
filterMap := make(map[string]map[string]string)
// Group filters by column
for key, vals := range values {
if strings.HasPrefix(key, "filter[") && strings.HasSuffix(key, "]") {
// Parse filter[column][operator] format
parts := strings.Split(key[7:len(key)-1], "][")
if len(parts) == 2 {
column := parts[0]
operator := parts[1]
if filterMap[column] == nil {
filterMap[column] = make(map[string]string)
}
if len(vals) > 0 {
filterMap[column][operator] = vals[0]
}
}
}
}
if len(filterMap) == 0 {
return nil, nil
}
// Convert to FilterGroup
var filters []DynamicFilter
for column, operators := range filterMap {
for opStr, value := range operators {
operator := FilterOperator(opStr)
// Parse value based on operator
var parsedValue interface{}
switch operator {
case OpIn, OpNotIn:
if value != "" {
parsedValue = strings.Split(value, ",")
}
case OpBetween, OpNotBetween:
if value != "" {
parts := strings.Split(value, ",")
if len(parts) == 2 {
parsedValue = []interface{}{strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])}
}
}
case OpNull, OpNotNull:
parsedValue = nil
default:
parsedValue = value
}
filters = append(filters, DynamicFilter{
Column: column,
Operator: operator,
Value: parsedValue,
})
}
}
if len(filters) == 0 {
return nil, nil
}
return []FilterGroup{{
Filters: filters,
LogicOp: "AND",
}}, nil
}
// parseSorting parses sort parameters
// Supports format: sort=column1,-column2 (- for DESC)
func (qp *QueryParser) parseSorting(values url.Values) ([]SortField, error) {
sortParam := values.Get("sort")
if sortParam == "" {
return nil, nil
}
var sorts []SortField
fields := strings.Split(sortParam, ",")
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
order := "ASC"
column := field
if strings.HasPrefix(field, "-") {
order = "DESC"
column = field[1:]
} else if strings.HasPrefix(field, "+") {
column = field[1:]
}
sorts = append(sorts, SortField{
Column: column,
Order: order,
})
}
return sorts, nil
}
// ParseAdvancedFilters parses complex filter structures
// Supports nested filters and logic operators
func (qp *QueryParser) ParseAdvancedFilters(filterParam string) ([]FilterGroup, error) {
// This would be for more complex JSON-based filters
// Implementation depends on your specific needs
return nil, nil
}
// Helper function to parse date values
func parseDate(value string) (interface{}, error) {
// Try different date formats
formats := []string{
"2006-01-02",
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05.000Z",
"2006-01-02 15:04:05",
}
for _, format := range formats {
if t, err := time.Parse(format, value); err == nil {
return t, nil
}
}
return value, nil
}
// Helper function to parse numeric values
func parseNumeric(value string) interface{} {
// Try integer first
if i, err := strconv.Atoi(value); err == nil {
return i
}
// Try float
if f, err := strconv.ParseFloat(value, 64); err == nil {
return f
}
// Return as string
return value
}

View File

@@ -0,0 +1,141 @@
package validation
import (
"context"
"database/sql"
"fmt"
"time"
)
// ValidationConfig holds configuration for duplicate validation
type ValidationConfig struct {
TableName string
IDColumn string
StatusColumn string
DateColumn string
ActiveStatuses []string
AdditionalFields map[string]interface{}
}
// DuplicateValidator provides methods for validating duplicate entries
type DuplicateValidator struct {
db *sql.DB
}
// NewDuplicateValidator creates a new instance of DuplicateValidator
func NewDuplicateValidator(db *sql.DB) *DuplicateValidator {
return &DuplicateValidator{db: db}
}
// ValidateDuplicate checks for duplicate entries based on the provided configuration
func (dv *DuplicateValidator) ValidateDuplicate(ctx context.Context, config ValidationConfig, identifier interface{}) error {
query := fmt.Sprintf(`
SELECT COUNT(*)
FROM %s
WHERE %s = $1
AND %s = ANY($2)
AND DATE(%s) = CURRENT_DATE
`, config.TableName, config.IDColumn, config.StatusColumn, config.DateColumn)
var count int
err := dv.db.QueryRowContext(ctx, query, identifier, config.ActiveStatuses).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check duplicate: %w", err)
}
if count > 0 {
return fmt.Errorf("data with ID %v already exists with active status today", identifier)
}
return nil
}
// ValidateDuplicateWithCustomFields checks for duplicates with additional custom fields
func (dv *DuplicateValidator) ValidateDuplicateWithCustomFields(ctx context.Context, config ValidationConfig, fields map[string]interface{}) error {
whereClause := fmt.Sprintf("%s = ANY($1) AND DATE(%s) = CURRENT_DATE", config.StatusColumn, config.DateColumn)
args := []interface{}{config.ActiveStatuses}
argIndex := 2
// Add additional field conditions
for fieldName, fieldValue := range config.AdditionalFields {
whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex)
args = append(args, fieldValue)
argIndex++
}
// Add dynamic fields
for fieldName, fieldValue := range fields {
whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex)
args = append(args, fieldValue)
argIndex++
}
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", config.TableName, whereClause)
var count int
err := dv.db.QueryRowContext(ctx, query, args...).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check duplicate with custom fields: %w", err)
}
if count > 0 {
return fmt.Errorf("duplicate entry found with the specified criteria")
}
return nil
}
// ValidateOncePerDay ensures only one submission per day for a given identifier
func (dv *DuplicateValidator) ValidateOncePerDay(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) error {
query := fmt.Sprintf(`
SELECT COUNT(*)
FROM %s
WHERE %s = $1
AND DATE(%s) = CURRENT_DATE
`, tableName, idColumn, dateColumn)
var count int
err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check daily submission: %w", err)
}
if count > 0 {
return fmt.Errorf("only one submission allowed per day for ID %v", identifier)
}
return nil
}
// GetLastSubmissionTime returns the last submission time for a given identifier
func (dv *DuplicateValidator) GetLastSubmissionTime(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) (*time.Time, error) {
query := fmt.Sprintf(`
SELECT %s
FROM %s
WHERE %s = $1
ORDER BY %s DESC
LIMIT 1
`, dateColumn, tableName, idColumn, dateColumn)
var lastTime time.Time
err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&lastTime)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // No previous submission
}
return nil, fmt.Errorf("failed to get last submission time: %w", err)
}
return &lastTime, nil
}
// DefaultRetribusiConfig returns default configuration for retribusi validation
func DefaultRetribusiConfig() ValidationConfig {
return ValidationConfig{
TableName: "data_retribusi",
IDColumn: "id",
StatusColumn: "status",
DateColumn: "date_created",
ActiveStatuses: []string{"active", "draft"},
}
}

356
pkg/logger/README.md Normal file
View File

@@ -0,0 +1,356 @@
# Structured Logger Package
A comprehensive structured logging package for Go applications with support for different log levels, service-specific logging, request context, and JSON output formatting.
## Features
- **Structured Logging**: JSON and text format output with rich metadata
- **Multiple Log Levels**: DEBUG, INFO, WARN, ERROR, FATAL
- **Service-Specific Logging**: Dedicated loggers for different services
- **Request Context**: Request ID and correlation ID tracking
- **Performance Timing**: Built-in duration logging for operations
- **Gin Middleware**: Request logging middleware for HTTP requests
- **Environment Configuration**: Configurable via environment variables
## Installation
The logger is already integrated into the project. Import it using:
```go
import "api-service/pkg/logger"
```
## Quick Start
### Basic Usage
```go
// Global functions (use default logger)
logger.Info("Application starting")
logger.Error("Something went wrong", map[string]interface{}{
"error": err.Error(),
"code": "DB_CONNECTION_FAILED",
})
// Create a service-specific logger
authLogger := logger.ServiceLogger("auth-service")
authLogger.Info("User authenticated", map[string]interface{}{
"user_id": "123",
"method": "oauth2",
})
```
### Service-Specific Loggers
```go
// Pre-defined service loggers
authLogger := logger.AuthServiceLogger()
bpjsLogger := logger.BPJSServiceLogger()
retribusiLogger := logger.RetribusiServiceLogger()
databaseLogger := logger.DatabaseServiceLogger()
authLogger.Info("Authentication successful")
databaseLogger.Debug("Query executed", map[string]interface{}{
"query": "SELECT * FROM users",
"time": "150ms",
})
```
### Request Context Logging
```go
// Add request context to logs
requestLogger := logger.Default().
WithRequestID("req-123456").
WithCorrelationID("corr-789012").
WithField("user_id", "user-123")
requestLogger.Info("Request processing started", map[string]interface{}{
"endpoint": "/api/v1/data",
"method": "POST",
})
```
### Performance Timing
```go
// Time operations and log duration
start := time.Now()
// ... perform operation ...
logger.LogDuration(start, "Database query completed", map[string]interface{}{
"query": "SELECT * FROM large_table",
"rows": 1000,
"database": "postgres",
})
```
## Gin Middleware Integration
### Add Request Logger Middleware
In your routes setup:
```go
import "api-service/pkg/logger"
func RegisterRoutes(cfg *config.Config) *gin.Engine {
router := gin.New()
// Add request logging middleware
router.Use(logger.RequestLoggerMiddleware(logger.Default()))
// ... other middleware and routes
return router
}
```
### Access Logger in Handlers
```go
func (h *MyHandler) MyEndpoint(c *gin.Context) {
// Get logger from context
logger := logger.GetLoggerFromContext(c)
logger.Info("Endpoint called", map[string]interface{}{
"user_agent": c.Request.UserAgent(),
"client_ip": c.ClientIP(),
})
// Get request IDs
requestID := logger.GetRequestIDFromContext(c)
correlationID := logger.GetCorrelationIDFromContext(c)
}
```
## Configuration
### Environment Variables
Set these environment variables to configure the logger:
```bash
# Log level (DEBUG, INFO, WARN, ERROR, FATAL)
LOG_LEVEL=INFO
# Output format (text or json)
LOG_FORMAT=text
# Service name for logs
LOG_SERVICE=api-service
# Enable JSON format
LOG_JSON=false
```
### Programmatic Configuration
```go
// Create custom logger with specific configuration
cfg := logger.Config{
Level: "DEBUG",
JSONFormat: true,
Service: "my-custom-service",
}
customLogger := logger.NewFromConfig(cfg)
// Or create manually
logger := logger.New("service-name", logger.DEBUG, true)
```
## Log Levels
| Level | Description | Usage |
|-------|-------------|-------|
| DEBUG | Detailed debug information | Development and troubleshooting |
| INFO | General operational messages | Normal application behavior |
| WARN | Warning conditions | Something unexpected but not an error |
| ERROR | Error conditions | Operation failed but application continues |
| FATAL | Critical conditions | Application cannot continue |
## Output Formats
### Text Format (Default)
```
2025-08-22T04:33:12+07:00 [INFO] auth-service: User authentication successful (handler/auth.go:45) [user_id=12345 method=oauth2]
```
### JSON Format
```json
{
"timestamp": "2025-08-22T04:33:12+07:00",
"level": "INFO",
"service": "auth-service",
"message": "User authentication successful",
"file": "handler/auth.go",
"line": 45,
"request_id": "req-123456",
"correlation_id": "corr-789012",
"fields": {
"user_id": "12345",
"method": "oauth2"
}
}
```
## Best Practices
### 1. Use Appropriate Log Levels
```go
// Good
logger.Debug("Detailed debug info")
logger.Info("User action completed")
logger.Warn("Rate limit approaching")
logger.Error("Database connection failed")
// Avoid
logger.Info("Error connecting to database") // Use ERROR instead
```
### 2. Add Context to Logs
```go
// Instead of this:
logger.Error("Login failed")
// Do this:
logger.Error("Login failed", map[string]interface{}{
"username": username,
"reason": "invalid_credentials",
"attempts": loginAttempts,
"client_ip": clientIP,
})
```
### 3. Use Service-Specific Loggers
```go
// Create once per service
var authLogger = logger.AuthServiceLogger()
func LoginHandler(c *gin.Context) {
authLogger.Info("Login attempt", map[string]interface{}{
"username": c.PostForm("username"),
})
}
```
### 4. Measure Performance
```go
func ProcessData(data []byte) error {
start := time.Now()
defer func() {
logger.LogDuration(start, "Data processing completed", map[string]interface{}{
"data_size": len(data),
"items": countItems(data),
})
}()
// ... processing logic ...
}
```
## Migration from Standard Log Package
### Before (standard log)
```go
import "log"
log.Printf("Error: %v", err)
log.Printf("User %s logged in", username)
```
### After (structured logger)
```go
import "api-service/pkg/logger"
logger.Error("Operation failed", map[string]interface{}{
"error": err.Error(),
"context": "user_login",
})
logger.Info("User logged in", map[string]interface{}{
"username": username,
"method": "password",
})
```
## Examples
### Database Operations
```go
func (h *UserHandler) GetUser(c *gin.Context) {
logger := logger.GetLoggerFromContext(c)
start := time.Now()
user, err := h.db.GetUser(c.Param("id"))
if err != nil {
logger.Error("Failed to get user", map[string]interface{}{
"user_id": c.Param("id"),
"error": err.Error(),
})
c.JSON(500, gin.H{"error": "Internal server error"})
return
}
logger.LogDuration(start, "User retrieved successfully", map[string]interface{}{
"user_id": user.ID,
"query_time": time.Since(start).String(),
})
c.JSON(200, user)
}
```
### Authentication Service
```go
var authLogger = logger.AuthServiceLogger()
func Authenticate(username, password string) (bool, error) {
authLogger.Debug("Authentication attempt", map[string]interface{}{
"username": username,
})
// Authentication logic...
if authenticated {
authLogger.Info("Authentication successful", map[string]interface{}{
"username": username,
"method": "password",
})
return true, nil
}
authLogger.Warn("Authentication failed", map[string]interface{}{
"username": username,
"reason": "invalid_credentials",
})
return false, nil
}
```
## Troubleshooting
### Common Issues
1. **No logs appearing**: Check that log level is not set too high (e.g., ERROR when logging INFO)
2. **JSON format not working**: Ensure `LOG_JSON=true` or logger is created with `jsonFormat: true`
3. **Missing context**: Use `WithRequestID()` and `WithCorrelationID()` for request context
### Debug Mode
Enable debug logging for development:
```bash
export LOG_LEVEL=DEBUG
export LOG_FORMAT=text
```
## Performance Considerations
- Logger is designed to be lightweight and fast
- Context fields are only evaluated when the log level is enabled
- JSON marshaling only occurs when JSON format is enabled
- Consider log volume in production environments
## License
This logger package is part of the API Service project.

137
pkg/logger/config.go Normal file
View File

@@ -0,0 +1,137 @@
package logger
import (
"os"
"strconv"
"strings"
)
// Config holds the configuration for the logger
type Config struct {
Level string `json:"level" default:"INFO"`
JSONFormat bool `json:"json_format" default:"false"`
Service string `json:"service" default:"api-service"`
}
// DefaultConfig returns the default logger configuration
func DefaultConfig() Config {
return Config{
Level: "INFO",
JSONFormat: false,
Service: "api-service",
}
}
// LoadConfigFromEnv loads logger configuration from environment variables
func LoadConfigFromEnv() Config {
config := DefaultConfig()
// Load log level from environment
if level := os.Getenv("LOG_LEVEL"); level != "" {
config.Level = strings.ToUpper(level)
}
// Load JSON format from environment
if jsonFormat := os.Getenv("LOG_JSON_FORMAT"); jsonFormat != "" {
if parsed, err := strconv.ParseBool(jsonFormat); err == nil {
config.JSONFormat = parsed
}
}
// Load service name from environment
if service := os.Getenv("LOG_SERVICE_NAME"); service != "" {
config.Service = service
}
return config
}
// Validate validates the logger configuration
func (c *Config) Validate() error {
// Validate log level
validLevels := map[string]bool{
"DEBUG": true,
"INFO": true,
"WARN": true,
"ERROR": true,
"FATAL": true,
}
if !validLevels[c.Level] {
c.Level = "INFO" // Default to INFO if invalid
}
return nil
}
// GetLogLevel returns the LogLevel from the configuration
func (c *Config) GetLogLevel() LogLevel {
switch strings.ToUpper(c.Level) {
case "DEBUG":
return DEBUG
case "WARN":
return WARN
case "ERROR":
return ERROR
case "FATAL":
return FATAL
default:
return INFO
}
}
// CreateLoggerFromConfig creates a new logger instance from configuration
func CreateLoggerFromConfig(cfg Config) *Logger {
cfg.Validate()
return NewFromConfig(cfg)
}
// CreateLoggerFromEnv creates a new logger instance from environment variables
func CreateLoggerFromEnv() *Logger {
cfg := LoadConfigFromEnv()
return CreateLoggerFromConfig(cfg)
}
// Environment variable constants
const (
EnvLogLevel = "LOG_LEVEL"
EnvLogJSONFormat = "LOG_JSON_FORMAT"
EnvLogService = "LOG_SERVICE_NAME"
)
// Service-specific configuration helpers
// AuthServiceConfig returns configuration for auth service
func AuthServiceConfig() Config {
cfg := LoadConfigFromEnv()
cfg.Service = "auth-service"
return cfg
}
// BPJSServiceConfig returns configuration for BPJS service
func BPJSServiceConfig() Config {
cfg := LoadConfigFromEnv()
cfg.Service = "bpjs-service"
return cfg
}
// RetribusiServiceConfig returns configuration for retribusi service
func RetribusiServiceConfig() Config {
cfg := LoadConfigFromEnv()
cfg.Service = "retribusi-service"
return cfg
}
// DatabaseServiceConfig returns configuration for database service
func DatabaseServiceConfig() Config {
cfg := LoadConfigFromEnv()
cfg.Service = "database-service"
return cfg
}
// MiddlewareServiceConfig returns configuration for middleware service
func MiddlewareServiceConfig() Config {
cfg := LoadConfigFromEnv()
cfg.Service = "middleware-service"
return cfg
}

142
pkg/logger/context.go Normal file
View File

@@ -0,0 +1,142 @@
package logger
import (
"context"
"time"
)
// contextKey is a custom type for context keys to avoid collisions
type contextKey string
const (
loggerKey contextKey = "logger"
requestIDKey contextKey = "request_id"
correlationIDKey contextKey = "correlation_id"
serviceNameKey contextKey = "service_name"
)
// ContextWithLogger creates a new context with the logger
func ContextWithLogger(ctx context.Context, logger *Logger) context.Context {
return context.WithValue(ctx, loggerKey, logger)
}
// LoggerFromContext retrieves the logger from context
func LoggerFromContext(ctx context.Context) *Logger {
if logger, ok := ctx.Value(loggerKey).(*Logger); ok {
return logger
}
return globalLogger
}
// ContextWithRequestID creates a new context with the request ID
func ContextWithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey, requestID)
}
// RequestIDFromContext retrieves the request ID from context
func RequestIDFromContext(ctx context.Context) string {
if requestID, ok := ctx.Value(requestIDKey).(string); ok {
return requestID
}
return ""
}
// ContextWithCorrelationID creates a new context with the correlation ID
func ContextWithCorrelationID(ctx context.Context, correlationID string) context.Context {
return context.WithValue(ctx, correlationIDKey, correlationID)
}
// CorrelationIDFromContext retrieves the correlation ID from context
func CorrelationIDFromContext(ctx context.Context) string {
if correlationID, ok := ctx.Value(correlationIDKey).(string); ok {
return correlationID
}
return ""
}
// ContextWithServiceName creates a new context with the service name
func ContextWithServiceName(ctx context.Context, serviceName string) context.Context {
return context.WithValue(ctx, serviceNameKey, serviceName)
}
// ServiceNameFromContext retrieves the service name from context
func ServiceNameFromContext(ctx context.Context) string {
if serviceName, ok := ctx.Value(serviceNameKey).(string); ok {
return serviceName
}
return ""
}
// WithContext returns a new logger with context values
func (l *Logger) WithContext(ctx context.Context) *Logger {
logger := l
if requestID := RequestIDFromContext(ctx); requestID != "" {
logger = logger.WithRequestID(requestID)
}
if correlationID := CorrelationIDFromContext(ctx); correlationID != "" {
logger = logger.WithCorrelationID(correlationID)
}
if serviceName := ServiceNameFromContext(ctx); serviceName != "" {
logger = logger.WithService(serviceName)
}
return logger
}
// DebugCtx logs a debug message with context
func DebugCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Debug(msg, fields...)
}
// DebugfCtx logs a formatted debug message with context
func DebugfCtx(ctx context.Context, format string, args ...interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Debugf(format, args...)
}
// InfoCtx logs an info message with context
func InfoCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Info(msg, fields...)
}
// InfofCtx logs a formatted info message with context
func InfofCtx(ctx context.Context, format string, args ...interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Infof(format, args...)
}
// WarnCtx logs a warning message with context
func WarnCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Warn(msg, fields...)
}
// WarnfCtx logs a formatted warning message with context
func WarnfCtx(ctx context.Context, format string, args ...interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Warnf(format, args...)
}
// ErrorCtx logs an error message with context
func ErrorCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Error(msg, fields...)
}
// ErrorfCtx logs a formatted error message with context
func ErrorfCtx(ctx context.Context, format string, args ...interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Errorf(format, args...)
}
// FatalCtx logs a fatal message with context and exits the program
func FatalCtx(ctx context.Context, msg string, fields ...map[string]interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Fatal(msg, fields...)
}
// FatalfCtx logs a formatted fatal message with context and exits the program
func FatalfCtx(ctx context.Context, format string, args ...interface{}) {
LoggerFromContext(ctx).WithContext(ctx).Fatalf(format, args...)
}
// LogDurationCtx logs the duration of an operation with context
func LogDurationCtx(ctx context.Context, start time.Time, operation string, fields ...map[string]interface{}) {
LoggerFromContext(ctx).WithContext(ctx).LogDuration(start, operation, fields...)
}

616
pkg/logger/logger.go Normal file
View File

@@ -0,0 +1,616 @@
package logger
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// LogLevel represents the severity level of a log message
type LogLevel int
const (
DEBUG LogLevel = iota
INFO
WARN
ERROR
FATAL
)
var (
levelStrings = map[LogLevel]string{
DEBUG: "DEBUG",
INFO: "INFO",
WARN: "WARN",
ERROR: "ERROR",
FATAL: "FATAL",
}
stringLevels = map[string]LogLevel{
"DEBUG": DEBUG,
"INFO": INFO,
"WARN": WARN,
"ERROR": ERROR,
"FATAL": FATAL,
}
)
// Logger represents a structured logger instance
type Logger struct {
serviceName string
level LogLevel
output *log.Logger
mu sync.Mutex
jsonFormat bool
logDir string
}
// LogEntry represents a structured log entry
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Service string `json:"service"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Duration string `json:"duration,omitempty"`
Fields map[string]interface{} `json:"fields,omitempty"`
}
// New creates a new logger instance
func New(serviceName string, level LogLevel, jsonFormat bool, logDir ...string) *Logger {
// Tentukan direktori log berdasarkan prioritas:
// 1. Parameter logDir (jika disediakan)
// 2. Environment variable LOG_DIR (jika ada)
// 3. Default ke pkg/logger/data relatif terhadap root proyek
var finalLogDir string
// Cek apakah logDir disediakan sebagai parameter
if len(logDir) > 0 && logDir[0] != "" {
finalLogDir = logDir[0]
} else {
// Cek environment variable
if envLogDir := os.Getenv("LOG_DIR"); envLogDir != "" {
finalLogDir = envLogDir
} else {
// Default: dapatkan path relatif terhadap root proyek
// Dapatkan path executable
exePath, err := os.Executable()
if err != nil {
// Fallback ke current working directory jika gagal
finalLogDir = filepath.Join(".", "pkg", "logger", "data")
} else {
// Dapatkan direktori executable
exeDir := filepath.Dir(exePath)
// Jika berjalan dengan go run, executable ada di temp directory
// Coba dapatkan path source code
if strings.Contains(exeDir, "go-build") || strings.Contains(exeDir, "tmp") {
// Gunakan runtime.Caller untuk mendapatkan path source
_, file, _, ok := runtime.Caller(0)
if ok {
// Dapatkan direktori source (2 level up dari pkg/logger)
sourceDir := filepath.Dir(file)
for i := 0; i < 3; i++ { // Naik 3 level ke root proyek
sourceDir = filepath.Dir(sourceDir)
}
finalLogDir = filepath.Join(sourceDir, "pkg", "logger", "data")
} else {
// Fallback
finalLogDir = filepath.Join(".", "pkg", "logger", "data")
}
} else {
// Untuk binary yang sudah dikompilasi, asumsikan struktur proyek
finalLogDir = filepath.Join(exeDir, "pkg", "logger", "data")
}
}
}
}
// Konversi ke path absolut
absPath, err := filepath.Abs(finalLogDir)
if err == nil {
finalLogDir = absPath
}
// Buat direktori jika belum ada
if err := os.MkdirAll(finalLogDir, 0755); err != nil {
// Fallback ke stdout jika gagal membuat direktori
fmt.Printf("Warning: Failed to create log directory %s: %v\n", finalLogDir, err)
return &Logger{
serviceName: serviceName,
level: level,
output: log.New(os.Stdout, "", 0),
jsonFormat: jsonFormat,
logDir: "", // Kosongkan karena gagal
}
}
return &Logger{
serviceName: serviceName,
level: level,
output: log.New(os.Stdout, "", 0),
jsonFormat: jsonFormat,
logDir: finalLogDir,
}
}
// NewFromConfig creates a new logger from configuration
func NewFromConfig(cfg Config) *Logger {
level := INFO
if l, exists := stringLevels[strings.ToUpper(cfg.Level)]; exists {
level = l
}
return New(cfg.Service, level, cfg.JSONFormat)
}
// Default creates a default logger instance
func Default() *Logger {
return New("api-service", INFO, false)
}
// WithService returns a new logger with the specified service name
func (l *Logger) WithService(serviceName string) *Logger {
return &Logger{
serviceName: serviceName,
level: l.level,
output: l.output,
jsonFormat: l.jsonFormat,
logDir: l.logDir,
}
}
// SetLevel sets the log level for the logger
func (l *Logger) SetLevel(level LogLevel) {
l.mu.Lock()
defer l.mu.Unlock()
l.level = level
}
// SetJSONFormat sets whether to output logs in JSON format
func (l *Logger) SetJSONFormat(jsonFormat bool) {
l.mu.Lock()
defer l.mu.Unlock()
l.jsonFormat = jsonFormat
}
// Debug logs a debug message
func (l *Logger) Debug(msg string, fields ...map[string]interface{}) {
l.log(DEBUG, msg, nil, fields...)
}
// Debugf logs a formatted debug message
func (l *Logger) Debugf(format string, args ...interface{}) {
l.log(DEBUG, fmt.Sprintf(format, args...), nil)
}
// Info logs an info message
func (l *Logger) Info(msg string, fields ...map[string]interface{}) {
l.log(INFO, msg, nil, fields...)
}
// Infof logs a formatted info message
func (l *Logger) Infof(format string, args ...interface{}) {
l.log(INFO, fmt.Sprintf(format, args...), nil)
}
// Warn logs a warning message
func (l *Logger) Warn(msg string, fields ...map[string]interface{}) {
l.log(WARN, msg, nil, fields...)
}
// Warnf logs a formatted warning message
func (l *Logger) Warnf(format string, args ...interface{}) {
l.log(WARN, fmt.Sprintf(format, args...), nil)
}
// Error logs an error message
func (l *Logger) Error(msg string, fields ...map[string]interface{}) {
l.log(ERROR, msg, nil, fields...)
}
// Errorf logs a formatted error message
func (l *Logger) Errorf(format string, args ...interface{}) {
l.log(ERROR, fmt.Sprintf(format, args...), nil)
}
// Fatal logs a fatal message and exits the program
func (l *Logger) Fatal(msg string, fields ...map[string]interface{}) {
l.log(FATAL, msg, nil, fields...)
os.Exit(1)
}
// Fatalf logs a formatted fatal message and exits the program
func (l *Logger) Fatalf(format string, args ...interface{}) {
l.log(FATAL, fmt.Sprintf(format, args...), nil)
os.Exit(1)
}
// WithRequestID returns a new logger with the specified request ID
func (l *Logger) WithRequestID(requestID string) *Logger {
return l.withField("request_id", requestID)
}
// WithCorrelationID returns a new logger with the specified correlation ID
func (l *Logger) WithCorrelationID(correlationID string) *Logger {
return l.withField("correlation_id", correlationID)
}
// WithField returns a new logger with an additional field
func (l *Logger) WithField(key string, value interface{}) *Logger {
return l.withField(key, value)
}
// WithFields returns a new logger with additional fields
func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
return &Logger{
serviceName: l.serviceName,
level: l.level,
output: l.output,
jsonFormat: l.jsonFormat,
logDir: l.logDir,
}
}
// LogDuration logs the duration of an operation
func (l *Logger) LogDuration(start time.Time, operation string, fields ...map[string]interface{}) {
duration := time.Since(start)
l.Info(fmt.Sprintf("%s completed", operation), append(fields, map[string]interface{}{
"duration": duration.String(),
"duration_ms": duration.Milliseconds(),
})...)
}
// log is the internal logging method
func (l *Logger) log(level LogLevel, msg string, duration *time.Duration, fields ...map[string]interface{}) {
if level < l.level {
return
}
// Get caller information
_, file, line, ok := runtime.Caller(3) // Adjust caller depth
var callerFile string
var callerLine int
if ok {
// Shorten file path
parts := strings.Split(file, "/")
if len(parts) > 2 {
callerFile = strings.Join(parts[len(parts)-2:], "/")
} else {
callerFile = file
}
callerLine = line
}
// Merge all fields
mergedFields := make(map[string]interface{})
for _, f := range fields {
for k, v := range f {
mergedFields[k] = v
}
}
entry := LogEntry{
Timestamp: time.Now().Format(time.RFC3339),
Level: levelStrings[level],
Service: l.serviceName,
Message: msg,
File: callerFile,
Line: callerLine,
Fields: mergedFields,
}
if duration != nil {
entry.Duration = duration.String()
}
if l.jsonFormat {
l.outputJSON(entry)
} else {
l.outputText(entry)
}
if level == FATAL {
os.Exit(1)
}
}
// outputJSON outputs the log entry in JSON format
func (l *Logger) outputJSON(entry LogEntry) {
jsonData, err := json.Marshal(entry)
if err != nil {
// Fallback to text output if JSON marshaling fails
l.outputText(entry)
return
}
l.output.Println(string(jsonData))
}
// outputText outputs the log entry in text format
func (l *Logger) outputText(entry LogEntry) {
timestamp := entry.Timestamp
level := entry.Level
service := entry.Service
message := entry.Message
// Base log line
logLine := fmt.Sprintf("%s [%s] %s: %s", timestamp, level, service, message)
// Add file and line if available
if entry.File != "" && entry.Line > 0 {
logLine += fmt.Sprintf(" (%s:%d)", entry.File, entry.Line)
}
// Add request ID if available
if entry.RequestID != "" {
logLine += fmt.Sprintf(" [req:%s]", entry.RequestID)
}
// Add correlation ID if available
if entry.CorrelationID != "" {
logLine += fmt.Sprintf(" [corr:%s]", entry.CorrelationID)
}
// Add duration if available
if entry.Duration != "" {
logLine += fmt.Sprintf(" [dur:%s]", entry.Duration)
}
// Add additional fields
if len(entry.Fields) > 0 {
fields := make([]string, 0, len(entry.Fields))
for k, v := range entry.Fields {
fields = append(fields, fmt.Sprintf("%s=%v", k, v))
}
logLine += " [" + strings.Join(fields, " ") + "]"
}
l.output.Println(logLine)
}
// withField creates a new logger with an additional field
func (l *Logger) withField(key string, value interface{}) *Logger {
return &Logger{
serviceName: l.serviceName,
level: l.level,
output: l.output,
jsonFormat: l.jsonFormat,
logDir: l.logDir,
}
}
// String returns the string representation of a log level
func (l LogLevel) String() string {
return levelStrings[l]
}
// ParseLevel parses a string into a LogLevel
func ParseLevel(level string) (LogLevel, error) {
if l, exists := stringLevels[strings.ToUpper(level)]; exists {
return l, nil
}
return INFO, fmt.Errorf("invalid log level: %s", level)
}
// Global logger instance
var globalLogger = Default()
// SetGlobalLogger sets the global logger instance
func SetGlobalLogger(logger *Logger) {
globalLogger = logger
}
// Global logging functions
func Debug(msg string, fields ...map[string]interface{}) {
globalLogger.Debug(msg, fields...)
}
func Debugf(format string, args ...interface{}) {
globalLogger.Debugf(format, args...)
}
func Info(msg string, fields ...map[string]interface{}) {
globalLogger.Info(msg, fields...)
}
func Infof(format string, args ...interface{}) {
globalLogger.Infof(format, args...)
}
func Warn(msg string, fields ...map[string]interface{}) {
globalLogger.Warn(msg, fields...)
}
func Warnf(format string, args ...interface{}) {
globalLogger.Warnf(format, args...)
}
func Error(msg string, fields ...map[string]interface{}) {
globalLogger.Error(msg, fields...)
}
func Errorf(format string, args ...interface{}) {
globalLogger.Errorf(format, args...)
}
func Fatal(msg string, fields ...map[string]interface{}) {
globalLogger.Fatal(msg, fields...)
}
func Fatalf(format string, args ...interface{}) {
globalLogger.Fatalf(format, args...)
}
// SaveLogText menyimpan log dalam format teks dengan pemisah |
func (l *Logger) SaveLogText(entry LogEntry) error {
// Format log dengan pemisah |
logLine := fmt.Sprintf("%s|%s|%s|%s|%s|%s|%s|%s:%d",
entry.Timestamp,
entry.Level,
entry.Service,
entry.Message,
entry.RequestID,
entry.CorrelationID,
entry.Duration,
entry.File,
entry.Line)
// Tambahkan fields jika ada
if len(entry.Fields) > 0 {
fieldsStr := ""
for k, v := range entry.Fields {
fieldsStr += fmt.Sprintf("|%s=%v", k, v)
}
logLine += fieldsStr
}
logLine += "\n"
// Buat direktori jika belum ada
if err := os.MkdirAll(l.logDir, 0755); err != nil {
return err
}
// Tulis ke file dengan mutex lock untuk concurrency safety
l.mu.Lock()
defer l.mu.Unlock()
filePath := filepath.Join(l.logDir, "logs.txt")
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString(logLine); err != nil {
return err
}
return nil
}
// SaveLogJSON menyimpan log dalam format JSON
func (l *Logger) SaveLogJSON(entry LogEntry) error {
jsonData, err := json.Marshal(entry)
if err != nil {
return err
}
// Buat direktori jika belum ada
if err := os.MkdirAll(l.logDir, 0755); err != nil {
return err
}
// Tulis ke file dengan mutex lock for concurrency safety
l.mu.Lock()
defer l.mu.Unlock()
filePath := filepath.Join(l.logDir, "logs.json")
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString(string(jsonData) + "\n"); err != nil {
return err
}
return nil
}
// SaveLogToDatabase menyimpan log ke database
func (l *Logger) SaveLogToDatabase(entry LogEntry) error {
// Implementasi penyimpanan ke database
// Ini adalah contoh implementasi, sesuaikan dengan struktur database Anda
// Untuk saat ini, kita akan simpan ke file sebagai placeholder
// Anda dapat mengganti ini dengan koneksi database yang sesuai
dbLogLine := fmt.Sprintf("DB_LOG: %s|%s|%s|%s\n",
entry.Timestamp, entry.Level, entry.Service, entry.Message)
if err := os.MkdirAll(l.logDir, 0755); err != nil {
return err
}
// Tulis ke file dengan mutex lock for concurrency safety
l.mu.Lock()
defer l.mu.Unlock()
filePath := filepath.Join(l.logDir, "database_logs.txt")
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString(dbLogLine); err != nil {
return err
}
return nil
}
// LogAndSave melakukan logging dan menyimpan ke semua format
func (l *Logger) LogAndSave(level LogLevel, msg string, fields ...map[string]interface{}) {
// Panggil fungsi log biasa
l.log(level, msg, nil, fields...)
// Dapatkan entry log yang baru dibuat
_, file, line, ok := runtime.Caller(2)
var callerFile string
var callerLine int
if ok {
parts := strings.Split(file, "/")
if len(parts) > 2 {
callerFile = strings.Join(parts[len(parts)-2:], "/")
} else {
callerFile = file
}
callerLine = line
}
mergedFields := make(map[string]interface{})
for _, f := range fields {
for k, v := range f {
mergedFields[k] = v
}
}
entry := LogEntry{
Timestamp: time.Now().Format(time.RFC3339),
Level: levelStrings[level],
Service: l.serviceName,
Message: msg,
File: callerFile,
Line: callerLine,
Fields: mergedFields,
}
// Simpan ke semua format
go func() {
l.SaveLogText(entry)
l.SaveLogJSON(entry)
l.SaveLogToDatabase(entry)
}()
}
// Global fungsi untuk menyimpan log
func SaveLogText(entry LogEntry) error {
return globalLogger.SaveLogText(entry)
}
func SaveLogJSON(entry LogEntry) error {
return globalLogger.SaveLogJSON(entry)
}
func SaveLogToDatabase(entry LogEntry) error {
return globalLogger.SaveLogToDatabase(entry)
}

191
pkg/logger/middleware.go Normal file
View File

@@ -0,0 +1,191 @@
package logger
import (
"bytes"
"io"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// RequestLoggerMiddleware creates a Gin middleware for request logging
func RequestLoggerMiddleware(logger *Logger) gin.HandlerFunc {
return func(c *gin.Context) {
// 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 correlation ID
correlationID := c.GetHeader("X-Correlation-ID")
if correlationID == "" {
correlationID = uuid.New().String()
c.Header("X-Correlation-ID", correlationID)
}
// Create request-scoped logger
reqLogger := logger.
WithRequestID(requestID).
WithCorrelationID(correlationID)
// Store logger in context
c.Set("logger", reqLogger)
c.Set("request_id", requestID)
c.Set("correlation_id", correlationID)
// Capture request body for logging if needed
var requestBody []byte
if c.Request.Body != nil && strings.HasPrefix(c.ContentType(), "application/json") {
requestBody, _ = io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
}
// Start timer
start := time.Now()
// Log request start
reqLogger.Info("Request started", map[string]interface{}{
"method": c.Request.Method,
"path": c.Request.URL.Path,
"query": c.Request.URL.RawQuery,
"remote_addr": c.Request.RemoteAddr,
"user_agent": c.Request.UserAgent(),
"content_type": c.ContentType(),
"body_size": len(requestBody),
})
// Process request
c.Next()
// Calculate duration
duration := time.Since(start)
// Get response status
status := c.Writer.Status()
responseSize := c.Writer.Size()
// Log level based on status code
var logLevel LogLevel
switch {
case status >= 500:
logLevel = ERROR
case status >= 400:
logLevel = WARN
default:
logLevel = INFO
}
// Log request completion
fields := map[string]interface{}{
"method": c.Request.Method,
"path": c.Request.URL.Path,
"status": status,
"duration": duration.String(),
"duration_ms": duration.Milliseconds(),
"response_size": responseSize,
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
"content_type": c.ContentType(),
"content_length": c.Request.ContentLength,
}
// Add query parameters if present
if c.Request.URL.RawQuery != "" {
fields["query"] = c.Request.URL.RawQuery
}
// Add error information if present
if len(c.Errors) > 0 {
errors := make([]string, len(c.Errors))
for i, err := range c.Errors {
errors[i] = err.Error()
}
fields["errors"] = errors
}
reqLogger.log(logLevel, "Request completed", &duration, fields)
}
}
// GetLoggerFromContext retrieves the logger from Gin context
func GetLoggerFromContext(c *gin.Context) *Logger {
if logger, exists := c.Get("logger"); exists {
if l, ok := logger.(*Logger); ok {
return l
}
}
return globalLogger
}
// GetRequestIDFromContext retrieves the request ID from Gin context
func GetRequestIDFromContext(c *gin.Context) string {
if requestID, exists := c.Get("request_id"); exists {
if id, ok := requestID.(string); ok {
return id
}
}
return ""
}
// GetCorrelationIDFromContext retrieves the correlation ID from Gin context
func GetCorrelationIDFromContext(c *gin.Context) string {
if correlationID, exists := c.Get("correlation_id"); exists {
if id, ok := correlationID.(string); ok {
return id
}
}
return ""
}
// DatabaseLoggerMiddleware creates middleware for database operation logging
func DatabaseLoggerMiddleware(logger *Logger, serviceName string) gin.HandlerFunc {
return func(c *gin.Context) {
reqLogger := GetLoggerFromContext(c).WithService(serviceName)
c.Set("db_logger", reqLogger)
c.Next()
}
}
// GetDBLoggerFromContext retrieves the database logger from Gin context
func GetDBLoggerFromContext(c *gin.Context) *Logger {
if logger, exists := c.Get("db_logger"); exists {
if l, ok := logger.(*Logger); ok {
return l
}
}
return GetLoggerFromContext(c)
}
// ServiceLogger creates a service-specific logger
func ServiceLogger(serviceName string) *Logger {
return globalLogger.WithService(serviceName)
}
// AuthServiceLogger returns a logger for auth service
func AuthServiceLogger() *Logger {
return ServiceLogger("auth-service")
}
// BPJSServiceLogger returns a logger for BPJS service
func BPJSServiceLogger() *Logger {
return ServiceLogger("bpjs-service")
}
// RetribusiServiceLogger returns a logger for retribusi service
func RetribusiServiceLogger() *Logger {
return ServiceLogger("retribusi-service")
}
// DatabaseServiceLogger returns a logger for database operations
func DatabaseServiceLogger() *Logger {
return ServiceLogger("database-service")
}
// MiddlewareServiceLogger returns a logger for middleware operations
func MiddlewareServiceLogger() *Logger {
return ServiceLogger("middleware-service")
}

54
pkg/utils/etag.go Normal file
View File

@@ -0,0 +1,54 @@
package utils
import (
"fmt"
"strings"
)
// ParseETag extracts the ETag value from HTTP ETag header
// Handles both strong ETags ("123") and weak ETags (W/"123")
func ParseETag(etag string) string {
if etag == "" {
return ""
}
// Remove W/ prefix for weak ETags
if strings.HasPrefix(etag, "W/") {
etag = etag[2:]
}
// Remove surrounding quotes
if len(etag) >= 2 && strings.HasPrefix(etag, "\"") && strings.HasSuffix(etag, "\"") {
etag = etag[1 : len(etag)-1]
}
return etag
}
// FormatETag formats a version ID into a proper HTTP ETag header value
func FormatETag(versionId string, weak bool) string {
if versionId == "" {
return ""
}
if weak {
return fmt.Sprintf(`W/"%s"`, versionId)
}
return fmt.Sprintf(`"%s"`, versionId)
}
// IsValidETag validates if the given string is a valid ETag format
func IsValidETag(etag string) bool {
if etag == "" {
return false
}
// Check for weak ETag format
if strings.HasPrefix(etag, "W/") {
etag = etag[2:]
}
// Must be quoted
return len(etag) >= 2 && strings.HasPrefix(etag, "\"") && strings.HasSuffix(etag, "\"")
}

1
pkg/validator/validator Normal file
View File

@@ -0,0 +1 @@

0
scripts/scripts Normal file
View File

File diff suppressed because it is too large Load Diff