update ignore

This commit is contained in:
meninjar
2026-08-05 06:47:59 +00:00
parent 0add33f888
commit d0f0ee168b
273 changed files with 1234069 additions and 0 deletions

No files matched your search

@@ -0,0 +1,53 @@
package servers
import (
"fmt"
"net"
"service/internal/infrastructure/config"
// Import generated proto files. Asumsi path ini benar.
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
type ServiceRegistry struct {
// PermissionHandler *handlers.PermissionHandler
}
// GRPCServer membungkus instance grpc.Server.
type GRPCServer struct {
server *grpc.Server
config *config.ServerGRPCConfig
registry *ServiceRegistry
}
// NewGRPCServer membuat instance server gRPC baru dan mendaftarkan semua service dari registry.
func NewGRPCServer(config *config.ServerGRPCConfig, registry *ServiceRegistry) *GRPCServer {
srv := grpc.NewServer()
// Daftarkan semua service yang ada di registry
// if registry.PermissionHandler != nil {
// permissionV1.RegisterRolPermissionServiceServer(srv, registry.PermissionHandler)
// logger.Default().Info("Registered RolPermission gRPC service")
// }
// Aktifkan reflection agar bisa di-debug dengan tools seperti grpcurl
reflection.Register(srv)
return &GRPCServer{server: srv, config: config, registry: registry}
}
// Start menjalankan gRPC server.
func (s *GRPCServer) Start() error {
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.config.Port))
if err != nil {
return fmt.Errorf("failed to listen on port %d: %w", s.config.Port, err)
}
return s.server.Serve(lis)
}
// Stop menghentikan gRPC server secara graceful.
func (s *GRPCServer) Stop() {
s.server.GracefulStop()
}
@@ -0,0 +1,181 @@
package auth
import (
"net/http"
authService "service/internal/auth"
"service/pkg/errors"
"service/pkg/response"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
service authService.Service
}
func NewAuthHandler(service authService.Service) *AuthHandler {
return &AuthHandler{
service: service,
}
}
func (h *AuthHandler) RegisterRoutes(router *gin.RouterGroup) {
auth := router.Group("/auth")
{
auth.POST("/login", h.Login)
auth.POST("/register", h.Register)
auth.POST("/refresh", h.RefreshToken)
}
}
// RegisterProtectedRoutes mendaftarkan endpoint auth yang membutuhkan token
func (h *AuthHandler) RegisterProtectedRoutes(router *gin.RouterGroup) {
auth := router.Group("/auth")
{
auth.POST("/logout", h.Logout)
auth.GET("/info", h.TokenInfo)
}
}
// Login godoc
//
// @Summary Login user
// @Description Authenticate user and return JWT & Refresh tokens
// @Tags auth
// @Accept json
// @Produce json
// @Param request body authService.LoginRequest true "Login credentials"
// @Success 200 {object} response.Response
// @Router /auth/login [post]
func (h *AuthHandler) Login(c *gin.Context) {
var req authService.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// Pemanggilan service secara by-value
resp, err := h.service.Login(c.Request.Context(), req)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Login successful", resp)
}
// Register godoc
//
// @Summary Register user
// @Description Register a new user
// @Tags auth
// @Accept json
// @Produce json
// @Param request body authService.RegisterRequest true "Registration details"
// @Success 201 {object} response.Response
// @Router /auth/register [post]
func (h *AuthHandler) Register(c *gin.Context) {
var req authService.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
resp, err := h.service.Register(c.Request.Context(), req)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusCreated, "User registered successfully", resp)
}
// RefreshToken godoc
//
// @Summary Refresh Token
// @Description Refresh expired access token using refresh token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body authService.RefreshTokenRequest true "Refresh token payload"
// @Success 200 {object} response.Response
// @Router /auth/refresh [post]
func (h *AuthHandler) RefreshToken(c *gin.Context) {
var req authService.RefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Invalid request payload", err.Error())
return
}
resp, err := h.service.RefreshToken(c.Request.Context(), req)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Token refreshed successfully", resp)
}
// Logout godoc
//
// @Summary Logout user
// @Description Logout current user and blacklist the token
// @Tags auth
// @Produce json
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /auth/logout [post]
func (h *AuthHandler) Logout(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
response.Error(c, http.StatusBadRequest, "Token required", nil)
return
}
if len(token) > 7 && token[:7] == "Bearer " {
token = token[7:]
}
err := h.service.Logout(c.Request.Context(), token)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Logged out successfully", nil)
}
// TokenInfo godoc
//
// @Summary Get Token Info
// @Description Retrieve information about the currently active token/user
// @Tags auth
// @Produce json
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /auth/info [get]
func (h *AuthHandler) TokenInfo(c *gin.Context) {
// Data ini otomatis di-set oleh provider.go (UnifiedAuthMiddleware)
authProvider := c.GetString("auth_provider")
userID := c.GetString("user_id")
username := c.GetString("username")
email := c.GetString("email")
role := c.GetString("role")
name := c.GetString("name")
data := gin.H{
"auth_provider": authProvider, // Akan bernilai: "jwt", "keycloak", atau "static"
"user_id": userID,
"username": username,
"email": email,
"name": name,
"role": role,
}
response.Success(c, http.StatusOK, "Current active token information", data)
}
@@ -0,0 +1,470 @@
package health
import (
"context"
"fmt"
"net/http"
"time"
"service/internal/infrastructure/cache"
"service/internal/infrastructure/config"
"service/internal/infrastructure/database"
"service/internal/interfaces/minio"
"github.com/gin-gonic/gin"
miniogo "github.com/minio/minio-go/v7"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// HealthHandler handles health check endpoints
type HealthHandler struct {
db *gorm.DB
redisClient *redis.Client
config *config.Config
cacheManager *cache.Manager // Alternatif untuk Redis client
dbManager database.Service
}
// NewHealthHandlerWithCache creates a new health handler with cache manager
func NewHealthHandlerWithCache(db *gorm.DB, cacheManager *cache.Manager, config *config.Config, dbManager database.Service) *HealthHandler {
handler := &HealthHandler{
db: db,
config: config,
cacheManager: cacheManager,
dbManager: dbManager,
}
// Coba dapatkan Redis client dari cache manager
if cacheManager != nil {
if redisClientInterface := cacheManager.GetRedisClient(); redisClientInterface != nil {
if client, ok := redisClientInterface.(*redis.Client); ok {
handler.redisClient = client
}
}
}
return handler
}
// HealthCheckComplete performs comprehensive health check
func (h *HealthHandler) HealthCheckComplete(c *gin.Context) {
startTime := time.Now()
health := gin.H{
"status": "healthy",
"timestamp": time.Now().UTC(),
"uptime": time.Since(startTime).Milliseconds(),
"version": "1.0.0",
"service": "service",
}
// Check database
dbStatus := h.checkDatabase()
health["database"] = dbStatus
// Check cache
cacheStatus := h.checkCache()
health["cache"] = cacheStatus
// Check external services
externalStatus := h.checkExternalServices()
health["external_services"] = externalStatus
// Check Minio
minioStatus := h.checkMinio()
health["minio"] = minioStatus
// Determine overall status
if dbStatus["status"] != "UP" || cacheStatus["status"] != "UP" || (minioStatus["status"] != "UP" && minioStatus["status"] != "DISABLED") {
health["status"] = "DOWN"
c.JSON(http.StatusServiceUnavailable, health)
return
}
c.JSON(http.StatusOK, health)
}
// HealthCheckDatabase checks database connectivity
func (h *HealthHandler) HealthCheckDatabase(c *gin.Context) {
status := h.checkDatabase()
if status["status"] != "UP" {
c.JSON(http.StatusServiceUnavailable, status)
return
}
c.JSON(http.StatusOK, status)
}
// HealthCheckCache checks cache connectivity
func (h *HealthHandler) HealthCheckCache(c *gin.Context) {
status := h.checkCache()
if status["status"] != "UP" {
c.JSON(http.StatusServiceUnavailable, status)
return
}
c.JSON(http.StatusOK, status)
}
// HealthCheckExternal checks external service connectivity
func (h *HealthHandler) HealthCheckExternal(c *gin.Context) {
status := h.checkExternalServices()
// Hanya return 503 (Unavailable) jika status benar-benar DOWN, bukan saat sekadar DEGRADED.
if status["status"] == "DOWN" {
c.JSON(http.StatusServiceUnavailable, status)
return
}
c.JSON(http.StatusOK, status)
}
// HealthCheckMinio checks Minio Object Storage connectivity
func (h *HealthHandler) HealthCheckMinio(c *gin.Context) {
status := h.checkMinio()
if status["status"] == "DOWN" {
c.JSON(http.StatusServiceUnavailable, status)
return
}
c.JSON(http.StatusOK, status)
}
// TestUploadMinio is a testing endpoint to upload a file to Minio
func (h *HealthHandler) TestUploadMinio(c *gin.Context) {
if minio.I == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Minio client is not initialized or disconnected"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "File is required (form-data key: 'file')"})
return
}
// Gunakan bucket dari request body (jika ada), atau default ke config/nama statis
bucketName := c.DefaultPostForm("bucket", "dev-test")
ctx := c.Request.Context()
// Ensure bucket exists
exists, err := minio.I.BucketExists(ctx, bucketName)
if err == nil && !exists {
err = minio.I.MakeBucket(ctx, bucketName, miniogo.MakeBucketOptions{Region: h.config.Minio.Region})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bucket: " + err.Error()})
return
}
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check bucket status: " + err.Error()})
return
}
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open file: " + err.Error()})
return
}
defer src.Close()
objectName := fmt.Sprintf("%d-%s", time.Now().Unix(), file.Filename)
info, err := minio.I.PutObject(ctx, bucketName, objectName, src, file.Size, miniogo.PutObjectOptions{
ContentType: file.Header.Get("Content-Type"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload to Minio: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "File uploaded successfully",
"data": info,
})
}
// checkDatabase checks database health
func (h *HealthHandler) checkDatabase() gin.H {
if h.dbManager == nil {
return gin.H{
"status": "UNKNOWN",
"error": "Database manager not initialized",
}
}
allDbInfo := h.dbManager.GetAllDatabasesInfo()
overallStatus := "UP"
// Iterate through the map and check each database
for name, info := range allDbInfo {
dbInfo, ok := info.(gin.H)
if !ok {
continue
}
// Standardize the status to "UP" if it's "connected" or "healthy"
if status, ok := dbInfo["status"].(string); ok {
if status == "connected" || status == "healthy" || status == "UP" {
dbInfo["status"] = "UP"
} else {
overallStatus = "DOWN"
}
}
allDbInfo[name] = dbInfo // Update the map with the checked info
}
return gin.H{
"status": overallStatus,
"components": allDbInfo,
}
}
// checkCache checks Redis/cache health
func (h *HealthHandler) checkCache() gin.H {
if h.cacheManager == nil {
return gin.H{"status": "UNKNOWN", "error": "Cache manager not initialized"}
}
if !h.config.Cache.Enabled {
return gin.H{"status": "UP", "details": gin.H{"provider": "noop", "message": "Cache is disabled"}}
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
start := time.Now()
err := h.cacheManager.Health(ctx)
latency := time.Since(start)
details := gin.H{
"provider": "redis",
"latency": latency.String(),
"latency_ms": latency.Milliseconds(),
}
if err != nil {
details["error"] = err.Error()
return gin.H{"status": "DOWN", "details": details}
}
return gin.H{
"status": "UP",
"details": details,
}
}
// checkMinio checks Minio Object Storage health
func (h *HealthHandler) checkMinio() gin.H {
if h.config == nil || h.config.Minio.Endpoint == "" {
return gin.H{"status": "DISABLED", "message": "Minio is not configured in .env"}
}
if minio.I == nil {
return gin.H{"status": "DOWN", "message": "Minio global client is nil"}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
start := time.Now()
// Panggilan ringan API minio untuk memastikan server hidup & kredensial benar
_, err := minio.I.ListBuckets(ctx)
latency := time.Since(start)
details := gin.H{
"endpoint": h.config.Minio.Endpoint,
"latency_ms": latency.Milliseconds(),
}
if err != nil {
details["error"] = err.Error()
return gin.H{"status": "DOWN", "details": details}
}
return gin.H{"status": "UP", "details": details}
}
// checkExternalServices checks external service health
func (h *HealthHandler) checkExternalServices() gin.H {
status := gin.H{
"status": "UP",
"message": "All configured external services are reachable",
}
hasDegraded := false
hasDown := false
downServices := []string{}
degradedServices := []string{}
activeServices := 0
// Check BPJS service if configured
if h.config != nil {
bpjsStatus := h.checkBPJSService()
status["bpjs"] = bpjsStatus
if s, ok := bpjsStatus["status"].(string); ok && s != "DISABLED" {
activeServices++
if s == "DOWN" {
hasDown = true
downServices = append(downServices, "BPJS")
} else if s == "DEGRADED" {
hasDegraded = true
degradedServices = append(degradedServices, "BPJS")
}
}
}
// Check SatuSehat service if configured
if h.config != nil {
satuSehatStatus := h.checkSatuSehatService()
status["satu_sehat"] = satuSehatStatus
if s, ok := satuSehatStatus["status"].(string); ok && s != "DISABLED" {
activeServices++
if s == "DOWN" {
hasDown = true
downServices = append(downServices, "SatuSehat")
} else if s == "DEGRADED" {
hasDegraded = true
degradedServices = append(degradedServices, "SatuSehat")
}
}
}
if activeServices == 0 {
status["status"] = "UP"
status["message"] = "All external services are disabled"
} else if hasDown {
status["status"] = "DOWN"
status["message"] = fmt.Sprintf("Some external services are unreachable: %v", downServices)
} else if hasDegraded {
status["status"] = "DEGRADED"
status["message"] = fmt.Sprintf("Some external services are degraded: %v", degradedServices)
}
return status
}
// checkBPJSService checks BPJS VClaim service
func (h *HealthHandler) checkBPJSService() gin.H {
if !h.config.Bpjs.Enabled {
return gin.H{
"status": "DISABLED",
"message": "BPJS integration is disabled in configuration",
}
}
client := &http.Client{
Timeout: 5 * time.Second,
}
req, err := http.NewRequest("GET", h.config.Bpjs.BaseURL, nil)
if err != nil {
return gin.H{
"status": "DOWN",
"message": "Failed to create BPJS request: " + err.Error(),
}
}
resp, err := client.Do(req)
if err != nil {
return gin.H{
"status": "DOWN",
"message": "BPJS service unreachable: " + err.Error(),
}
}
defer resp.Body.Close()
status := "UP"
message := "BPJS service is reachable"
switch resp.StatusCode {
case http.StatusOK:
message = "BPJS service is reachable and responding normally"
case http.StatusUnauthorized:
message = "BPJS service is reachable (Authentication required/Invalid Signature)"
case http.StatusForbidden:
status = "DEGRADED"
message = "BPJS service is reachable, but access is forbidden (Check IP Whitelisting or Credentials)"
case http.StatusNotFound:
message = "BPJS service is reachable (Base URL responded with 404, server is UP)"
default:
if resp.StatusCode >= 500 {
status = "DOWN"
message = fmt.Sprintf("BPJS service returned server error: %s", resp.Status)
} else {
message = fmt.Sprintf("BPJS service is reachable (Status: %s)", resp.Status)
}
}
return gin.H{
"status": status,
"message": message,
"status_code": resp.StatusCode,
}
}
// checkSatuSehatService checks SatuSehat FHIR service
func (h *HealthHandler) checkSatuSehatService() gin.H {
if !h.config.SatuSehat.Enabled {
return gin.H{
"status": "DISABLED",
"message": "SatuSehat integration is disabled in configuration",
}
}
client := &http.Client{
Timeout: 2 * time.Second, // Dipercepat agar health check tidak blocking terlalu lama
}
checkURL := func(url string) (string, string, int) {
if url == "" {
return "DISABLED", "URL not configured", 0
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "DOWN", "Request creation failed: " + err.Error(), 0
}
resp, err := client.Do(req)
if err != nil {
return "DOWN", "Unreachable: " + err.Error(), 0
}
defer resp.Body.Close()
return "UP", fmt.Sprintf("Reachable (Status: %s)", resp.Status), resp.StatusCode
}
authStatus, authMsg, authCode := checkURL(h.config.SatuSehat.AuthURL)
baseStatus, baseMsg, baseCode := checkURL(h.config.SatuSehat.BaseURL)
consentStatus, consentMsg, _ := checkURL(h.config.SatuSehat.ConsentURL)
kfaStatus, kfaMsg, _ := checkURL(h.config.SatuSehat.KFAURL)
overallStatus := "UP"
overallMessage := "SatuSehat services are reachable"
// Evaluasi status keseluruhan berdasarkan Base URL & Auth URL
if baseStatus == "DOWN" || authStatus == "DOWN" {
overallStatus = "DOWN"
overallMessage = "One or more critical SatuSehat services are unreachable"
} else if baseCode == http.StatusForbidden || authCode == http.StatusForbidden {
overallStatus = "DEGRADED"
overallMessage = "Services are reachable, but access is forbidden (Check IP/Permissions)"
}
return gin.H{
"status": overallStatus,
"message": overallMessage,
"details": gin.H{
"org_id": h.config.SatuSehat.OrgID,
"fasyankes_id": h.config.SatuSehat.FasyakesID,
"endpoints": gin.H{
"auth": gin.H{"url": h.config.SatuSehat.AuthURL, "status": authStatus, "message": authMsg, "status_code": authCode},
"fhir_base": gin.H{"url": h.config.SatuSehat.BaseURL, "status": baseStatus, "message": baseMsg, "status_code": baseCode},
"consent": gin.H{"url": h.config.SatuSehat.ConsentURL, "status": consentStatus, "message": consentMsg},
"kfa": gin.H{"url": h.config.SatuSehat.KFAURL, "status": kfaStatus, "message": kfaMsg},
},
},
}
}
@@ -0,0 +1,318 @@
package role
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
masterService "service/internal/master/role/master"
"service/pkg/errors"
"service/pkg/logger"
"service/pkg/response"
"github.com/gin-gonic/gin"
)
type RoleMasterHandler struct {
service masterService.Service
}
func NewRoleMasterHandler(service masterService.Service) *RoleMasterHandler {
return &RoleMasterHandler{service: service}
}
func (h *RoleMasterHandler) RegisterRoutes(router *gin.RouterGroup) {
group := router.Group("/roles/master")
{
group.GET("", h.GetList)
group.GET("/search", h.Search)
group.GET("/:id", h.GetDetail)
group.POST("", h.Create)
group.PUT("/:id", h.Update)
group.DELETE("/:id", h.Delete)
}
}
// GetList godoc
//
// @Summary Get list of Role
// @Description Retrieve a paginated list of RoleMaster
// @Tags roles
// @Produce json
// @Param limit query int false "Number of items per page" default(10)
// @Param offset query int false "Offset for pagination" default(0)
// @Param sort query string false "Sort fields (e.g. +name,-created_at)"
// @Param active query boolean false "Filter by active status"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/master [get]
func (h *RoleMasterHandler) GetList(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limitStr := c.Query("limit"); limitStr != "" {
limit, _ = strconv.Atoi(limitStr)
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset > 0 && limit > 0 {
offset = (offset - 1) * limit
}
}
activeParam := c.Query("active")
var activeFilter *bool // default nil (no filter / ambil semua)
if activeParam != "" {
isActive := activeParam == "true" || activeParam == "1"
activeFilter = &isActive
}
// Parse parameter sort (format: sort=column1,-column2,+column3)
// -column untuk DESC, +column atau column untuk ASC
var sorts []string
if sortParam := c.Query("sort"); sortParam != "" {
sorts = strings.Split(sortParam, ",")
// Validasi dan bersihkan sort parameters
for i, sort := range sorts {
sorts[i] = strings.TrimSpace(sort)
}
}
ctx := c.Request.Context()
result, err := h.service.GetList(ctx, limit, offset, sorts, activeFilter)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
data := result["data"]
total := result["total"].(int64)
limitVal := result["limit"].(int)
offsetVal := result["offset"].(int)
page := 1
if limitVal > 0 {
page = (offsetVal / limitVal) + 1
}
totalPages := 0
if limitVal > 0 {
totalPages = int(math.Ceil(float64(total) / float64(limitVal)))
}
meta := response.Meta{Page: page, Limit: limitVal, Total: int(total), TotalPages: totalPages}
response.Paginated(c, http.StatusOK, "Successfully retrieved RolPages list", data, meta)
}
// GetDetail godoc
//
// @Summary Get RoleMaster detail
// @Description Retrieve detailed information about a specific RoleMaster
// @Tags roles
// @Produce json
// @Param id path int true "RoleMaster ID"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/master/{id} [get]
func (h *RoleMasterHandler) GetDetail(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid ID format", nil)
return
}
ctx := c.Request.Context()
result, err := h.service.GetDetail(ctx, id)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully retrieved RoleMaster detail", result)
}
// Search godoc
//
// @Summary Search Role
// @Description Search RoleMaster records using dynamic filters
// @Tags roles
// @Produce json
// @Param limit query int false "Limit per page" default(10)
// @Param offset query int false "Offset for pagination" default(0)
// @Param sort query string false "Sort fields (e.g. +name,-created_at)"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/master/search [get]
func (h *RoleMasterHandler) Search(c *gin.Context) {
// Parse Limit & Offset dengan fallback ke page & page_size
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limitStr := c.Query("limit"); limitStr != "" {
limit, _ = strconv.Atoi(limitStr)
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset > 0 && limit > 0 {
offset = (offset - 1) * limit
}
}
// Ambil parameter filter
// Ambil parameter filter secara dinamis
filters := make(map[string]interface{})
for key, values := range c.Request.URL.Query() {
if key == "page" || key == "limit" || key == "page_size" || key == "offset" || key == "sort" {
continue
}
if len(values) > 0 && values[0] != "" {
filters[key] = values[0]
}
}
// Parse parameter sort (format: sort=column1,-column2,+column3)
// -column untuk DESC, +column atau column untuk ASC
var sorts []string
if sortParam := c.Query("sort"); sortParam != "" {
sorts = strings.Split(sortParam, ",")
// Validasi dan bersihkan sort parameters
for i, sort := range sorts {
sorts[i] = strings.TrimSpace(sort)
}
}
ctx := c.Request.Context()
logger.Default().WithContext(ctx).Info("Search request",
logger.String("filters", fmt.Sprintf("%v", filters)),
logger.String("sorts", fmt.Sprintf("%v", sorts)),
logger.Int("offset", offset),
logger.Int("limit", limit))
// Panggil service dengan parameter sort tambahan
result, err := h.service.Search(ctx, filters, sorts, limit, offset)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
// Extract data dari map service untuk response format
data := result["data"]
total := result["total"].(int64)
limitVal := result["limit"].(int)
offsetVal := result["offset"].(int)
page := 1
if limitVal > 0 {
page = (offsetVal / limitVal) + 1
}
// Hitung total pages
totalPages := 0
if limitVal > 0 {
totalPages = int(math.Ceil(float64(total) / float64(limitVal)))
}
meta := response.Meta{
Page: page,
Limit: limitVal,
Total: int(total),
TotalPages: totalPages,
}
response.Paginated(c, http.StatusOK, "Successfully retrieved RoleMaster search results", data, meta)
}
// Create godoc
//
// @Summary Create new RoleMaster
// @Description Create a new RoleMaster record
// @Tags roles
// @Accept json
// @Produce json
// @Param request body masterService.RoleMasterRequest true "Payload"
// @Success 201 {object} response.Response
// @Security BearerAuth
// @Router /roles/master [post]
func (h *RoleMasterHandler) Create(c *gin.Context) {
var req masterService.RoleMasterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
ctx := c.Request.Context()
created, err := h.service.Create(ctx, req)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusCreated, "Successfully created RoleMaster", created)
}
// Update godoc
//
// @Summary Update an existing RoleMaster
// @Description Update details of an existing RoleMaster record by ID
// @Tags roles
// @Accept json
// @Produce json
// @Param id path int true "RoleMaster ID"
// @Param request body masterService.RoleMasterRequest true "Payload"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/master/{id} [put]
func (h *RoleMasterHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid ID format", nil)
return
}
var req masterService.RoleMasterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
ctx := c.Request.Context()
updated, err := h.service.Update(ctx, id, req)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully updated RoleMaster", updated)
}
// Delete godoc
//
// @Summary Delete a RoleMaster
// @Description Delete a RoleMaster record by ID (soft delete)
// @Tags roles
// @Produce json
// @Param id path int true "RoleMaster ID"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/master/{id} [delete]
func (h *RoleMasterHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid ID format", nil)
return
}
ctx := c.Request.Context()
if err := h.service.Delete(ctx, id); err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully deleted RoleMaster", nil)
}
@@ -0,0 +1,384 @@
package role
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
roleService "service/internal/master/role/pages"
"service/pkg/errors"
"service/pkg/logger"
"service/pkg/response"
"github.com/gin-gonic/gin"
)
type RolPagesHandler struct {
service roleService.Service
}
func NewRolPagesHandler(service roleService.Service) *RolPagesHandler {
return &RolPagesHandler{service: service}
}
func (h *RolPagesHandler) RegisterRoutes(router *gin.RouterGroup) {
group := router.Group("/roles/pages")
{
group.GET("", h.GetList)
group.GET("/tree", h.GetTree)
group.GET("/tree/level/:level", h.GetTreeByLevel)
group.GET("/search", h.Search)
group.GET("/:id", h.GetDetail)
group.POST("", h.Create)
group.PUT("/:id", h.Update)
group.DELETE("/:id", h.Delete)
}
}
// GetList godoc
//
// @Summary Get list of Role Pages
// @Description Retrieve a paginated list of Role Pages
// @Tags roles-pages
// @Produce json
// @Param limit query int false "Number of items per page" default(10)
// @Param offset query int false "Offset for pagination" default(0)
// @Param sort query string false "Sort fields (e.g. +name,-created_at)"
// @Param active query boolean false "Filter by active status"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/pages [get]
func (h *RolPagesHandler) GetList(c *gin.Context) {
// Parse Limit & Offset dengan fallback ke page & page_size
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limitStr := c.Query("limit"); limitStr != "" {
limit, _ = strconv.Atoi(limitStr)
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset > 0 && limit > 0 {
offset = (offset - 1) * limit
}
}
activeParam := c.Query("active")
var activeFilter *bool // default nil (no filter / ambil semua)
if activeParam != "" {
isActive := activeParam == "true" || activeParam == "1"
activeFilter = &isActive
}
// Parse parameter sort (format: sort=column1,-column2,+column3)
// -column untuk DESC, +column atau column untuk ASC
var sorts []string
if sortParam := c.Query("sort"); sortParam != "" {
sorts = strings.Split(sortParam, ",")
// Validasi dan bersihkan sort parameters
for i, sort := range sorts {
sorts[i] = strings.TrimSpace(sort)
}
}
ctx := c.Request.Context()
result, err := h.service.GetList(ctx, limit, offset, sorts, activeFilter)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
data := result["data"]
total := result["total"].(int64)
limitVal := result["limit"].(int)
offsetVal := result["offset"].(int)
page := 1
if limitVal > 0 {
page = (offsetVal / limitVal) + 1
}
totalPages := 0
if limitVal > 0 {
totalPages = int(math.Ceil(float64(total) / float64(limitVal)))
}
meta := response.Meta{Page: page, Limit: limitVal, Total: int(total), TotalPages: totalPages}
response.Paginated(c, http.StatusOK, "Successfully retrieved RolPages list", data, meta)
}
// GetDetail godoc
//
// @Summary Get Role Pages detail
// @Description Retrieve detailed information about a specific Role Pages
// @Tags roles-pages
// @Produce json
// @Param id path int true "Role Pages ID"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/pages/{id} [get]
func (h *RolPagesHandler) GetDetail(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid ID format", nil)
return
}
ctx := c.Request.Context()
result, err := h.service.GetDetail(ctx, id)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully retrieved RolPages detail", result)
}
// Search godoc
//
// @Summary Search Role Pages
// @Description Search Role Pages records using dynamic filters
// @Tags roles-pages
// @Produce json
// @Param limit query int false "Limit per page" default(10)
// @Param offset query int false "Offset for pagination" default(0)
// @Param sort query string false "Sort fields"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/pages/search [get]
func (h *RolPagesHandler) Search(c *gin.Context) {
// Parse Limit & Offset dengan fallback ke page & page_size
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limitStr := c.Query("limit"); limitStr != "" {
limit, _ = strconv.Atoi(limitStr)
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset > 0 && limit > 0 {
offset = (offset - 1) * limit
}
}
// Ambil parameter filter secara dinamis
filters := make(map[string]interface{})
for key, values := range c.Request.URL.Query() {
if key == "page" || key == "limit" || key == "page_size" || key == "offset" || key == "sort" {
continue
}
if len(values) > 0 && values[0] != "" {
filters[key] = values[0]
}
}
// Parse parameter sort (format: sort=column1,-column2,+column3)
// -column untuk DESC, +column atau column untuk ASC
var sorts []string
if sortParam := c.Query("sort"); sortParam != "" {
sorts = strings.Split(sortParam, ",")
// Validasi dan bersihkan sort parameters
for i, sort := range sorts {
sorts[i] = strings.TrimSpace(sort)
}
}
ctx := c.Request.Context()
logger.Default().WithContext(ctx).Info("Search request",
logger.String("filters", fmt.Sprintf("%v", filters)),
logger.String("sorts", fmt.Sprintf("%v", sorts)),
logger.Int("offset", offset),
logger.Int("limit", limit))
// Panggil service dengan parameter sort tambahan
result, err := h.service.Search(ctx, filters, sorts, limit, offset)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
// Extract data dari map service untuk response format
data := result["data"]
total := result["total"].(int64)
limitVal := result["limit"].(int)
offsetVal := result["offset"].(int)
page := 1
if limitVal > 0 {
page = (offsetVal / limitVal) + 1
}
totalPages := 0
if limitVal > 0 {
totalPages = int(math.Ceil(float64(total) / float64(limitVal)))
}
meta := response.Meta{
Page: page,
Limit: limitVal,
Total: int(total),
TotalPages: totalPages,
}
response.Paginated(c, http.StatusOK, "Successfully retrieved RolPages search results", data, meta)
}
// Create godoc
//
// @Summary Create new Role Pages
// @Description Create a new Role Pages record
// @Tags roles-pages
// @Accept json
// @Produce json
// @Param request body roleService.RolPagesRequest true "Payload"
// @Success 201 {object} response.Response
// @Security BearerAuth
// @Router /roles/pages [post]
func (h *RolPagesHandler) Create(c *gin.Context) {
var req roleService.RolPagesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
ctx := c.Request.Context()
created, err := h.service.Create(ctx, req)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusCreated, "Successfully created RolPages", created)
}
// Update godoc
//
// @Summary Update an existing Role Pages
// @Description Update details of an existing Role Pages record by ID
// @Tags roles-pages
// @Accept json
// @Produce json
// @Param id path int true "Role Pages ID"
// @Param request body roleService.RolPagesRequest true "Payload"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/pages/{id} [put]
func (h *RolPagesHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid ID format", nil)
return
}
var req roleService.RolPagesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
ctx := c.Request.Context()
updated, err := h.service.Update(ctx, id, req)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully updated RolPages", updated)
}
// Delete godoc
//
// @Summary Delete a Role Pages
// @Description Delete a Role Pages record by ID
// @Tags roles-pages
// @Produce json
// @Param id path int true "Role Pages ID"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/pages/{id} [delete]
func (h *RolPagesHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid ID format", nil)
return
}
ctx := c.Request.Context()
if err := h.service.Delete(ctx, id); err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully deleted RolPages", nil)
}
// GetTree godoc
//
// @Summary Get tree of Role Pages
// @Description Retrieve a hierarchical tree of Role Pages
// @Tags roles-pages
// @Produce json
// @Param active query boolean false "Filter by active status"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/pages/tree [get]
func (h *RolPagesHandler) GetTree(c *gin.Context) {
activeParam := c.Query("active")
var activeFilter *bool // default nil (no filter / ambil semua)
if activeParam != "" {
isActive := activeParam == "true" || activeParam == "1"
activeFilter = &isActive
}
ctx := c.Request.Context()
tree, err := h.service.GetTree(ctx, activeFilter)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully retrieved RolPages", tree)
}
// GetTreeByLevel godoc
//
// @Summary Get tree of Role Pages by level
// @Description Retrieve a hierarchical tree of Role Pages up to a specific level
// @Tags roles-pages
// @Produce json
// @Param level path int true "Hierarchy Level"
// @Param active query boolean false "Filter by active status"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/pages/tree/level/{level} [get]
func (h *RolPagesHandler) GetTreeByLevel(c *gin.Context) {
level, err := strconv.ParseInt(c.Param("level"), 10, 16)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid level format", nil)
return
}
activeParam := c.Query("active")
var activeFilter *bool // default nil (no filter / ambil semua)
if activeParam != "" {
isActive := activeParam == "true" || activeParam == "1"
activeFilter = &isActive
}
ctx := c.Request.Context()
tree, err := h.service.GetTreeByLevel(ctx, int16(level), activeFilter)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully retrieved RolPages", tree)
}
@@ -0,0 +1,502 @@
package role
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
roleService "service/internal/master/role/permission"
"service/pkg/errors"
"service/pkg/logger"
"service/pkg/response"
"github.com/gin-gonic/gin"
)
type RolPermissionHandler struct {
service roleService.Service
}
func NewRolPermissionHandler(service roleService.Service) *RolPermissionHandler {
return &RolPermissionHandler{service: service}
}
func (h *RolPermissionHandler) RegisterRoutes(router *gin.RouterGroup) {
group := router.Group("/roles/permissions") // get by role
{
group.GET("", h.GetList)
group.GET("/search", h.Search)
group.GET("/:id", h.GetDetail) // using role id
group.GET("/role/:role", h.GetPermissionTreeRole) // get permission tree by role keycloak
group.POST("", h.Create)
group.PUT("/:id", h.Update)
group.DELETE("/:id", h.Delete)
group.GET("/rolemaster/:id", h.GetRolePagesList)
}
}
// GetList godoc
//
// @Summary Get list of Role Permissions
// @Description Retrieve a paginated list of Role Permissions
// @Tags roles-permissions
// @Produce json
// @Param limit query int false "Number of items per page" default(10)
// @Param offset query int false "Offset for pagination" default(0)
// @Param sort query string false "Sort fields (e.g. +name,-created_at)"
// @Param active query boolean false "Filter by active status"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/permissions [get]
func (h *RolPermissionHandler) GetList(c *gin.Context) {
// Parse Limit & Offset dengan fallback ke page & page_size
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if pageSizeStr := c.Query("limit"); pageSizeStr != "" {
limit, _ = strconv.Atoi(pageSizeStr)
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if pageStr := c.Query("offset"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 && limit > 0 {
offset = (page - 1) * limit
}
}
activeParam := c.Query("active")
var activeFilter *bool // default nil (no filter / ambil semua)
if activeParam != "" {
isActive := activeParam == "true" || activeParam == "1"
activeFilter = &isActive
}
// Parse parameter sort (format: sort=column1,-column2,+column3)
// -column untuk DESC, +column atau column untuk ASC
var sorts []string
if sortParam := c.Query("sort"); sortParam != "" {
sorts = strings.Split(sortParam, ",")
// Validasi dan bersihkan sort parameters
for i, sort := range sorts {
sorts[i] = strings.TrimSpace(sort)
}
}
ctx := c.Request.Context()
result, err := h.service.GetList(ctx, limit, offset, sorts, activeFilter)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
data := result["data"]
total := result["total"].(int64)
limitVal := result["limit"].(int)
offsetVal := result["offset"].(int)
page := 1
if limitVal > 0 {
page = (offsetVal / limitVal) + 1
}
totalPages := 0
if limitVal > 0 {
totalPages = int(math.Ceil(float64(total) / float64(limitVal)))
}
meta := response.Meta{Page: page, Limit: limitVal, Total: int(total), TotalPages: totalPages}
response.Paginated(c, http.StatusOK, "Successfully retrieved RolPermission list", data, meta)
}
// GetDetail godoc
//
// @Summary Get Role Permission detail
// @Description Retrieve detailed information about a specific Role Permission
// @Tags roles-permissions
// @Produce json
// @Param id path int true "Role Permission ID"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/permissions/{id} [get]
func (h *RolPermissionHandler) GetDetail(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid ID format", nil)
return
}
ctx := c.Request.Context()
result, err := h.service.GetDetail(ctx, id)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully retrieved RolPermission detail", result)
}
// Search godoc
//
// @Summary Search Role Permissions
// @Description Search Role Permissions records using dynamic filters
// @Tags roles-permissions
// @Produce json
// @Param limit query int false "Limit per page" default(10)
// @Param offset query int false "Offset for pagination" default(0)
// @Param sort query string false "Sort fields"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/permissions/search [get]
func (h *RolPermissionHandler) Search(c *gin.Context) {
// Parse Limit & Offset dengan fallback ke page & page_size
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if pageSizeStr := c.Query("limit"); pageSizeStr != "" {
limit, _ = strconv.Atoi(pageSizeStr)
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if pageStr := c.Query("offset"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 && limit > 0 {
offset = (page - 1) * limit
}
}
// Ambil parameter filter secara dinamis
filters := make(map[string]interface{})
for key, values := range c.Request.URL.Query() {
if key == "page" || key == "limit" || key == "page_size" || key == "offset" || key == "sort" {
continue
}
if len(values) > 0 && values[0] != "" {
filters[key] = values[0]
}
}
// Parse parameter sort (format: sort=column1,-column2,+column3)
// -column untuk DESC, +column atau column untuk ASC
var sorts []string
if sortParam := c.Query("sort"); sortParam != "" {
sorts = strings.Split(sortParam, ",")
// Validasi dan bersihkan sort parameters
for i, sort := range sorts {
sorts[i] = strings.TrimSpace(sort)
}
}
ctx := c.Request.Context()
logger.Default().WithContext(ctx).Info("Search request",
logger.String("filters", fmt.Sprintf("%v", filters)),
logger.String("sorts", fmt.Sprintf("%v", sorts)),
logger.Int("offset", offset),
logger.Int("limit", limit))
// Panggil service dengan parameter sort tambahan
result, err := h.service.Search(ctx, filters, sorts, limit, offset)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
// Extract data dari map service untuk response format
data := result["data"]
total := result["total"].(int64)
limitVal := result["limit"].(int)
offsetVal := result["offset"].(int)
page := 1
if limitVal > 0 {
page = (offsetVal / limitVal) + 1
}
totalPages := 0
if limitVal > 0 {
totalPages = int(math.Ceil(float64(total) / float64(limitVal)))
}
meta := response.Meta{
Page: page,
Limit: limitVal,
Total: int(total),
TotalPages: totalPages,
}
response.Paginated(c, http.StatusOK, "Successfully retrieved RolPermission search results", data, meta)
}
// GetPermissionTree godoc
//
// @Summary Get Permission Tree
// @Description Retrieve a permission tree
// @Tags roles-permissions
// @Produce json
// @Param role path string true "Role Keycloak"
// @Param groups query string false "Comma-separated groups"
// @Param active query boolean false "Filter by active status"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/permissions/tree/{role} [get]
func (h *RolPermissionHandler) GetPermissionTree(c *gin.Context) {
roleKeycloak := c.Param("role")
// Get groups from query parameter (comma-separated)
groupsParam := c.Query("groups")
var groups []string
if groupsParam != "" {
groups = strings.Split(groupsParam, ",")
// Trim spaces from each group
for i, group := range groups {
groups[i] = strings.TrimSpace(group)
}
}
// Parse active parameter - handle "true", "false", "1", "0"
activeParam := c.Query("active")
var activeOnly *bool // default nil (no filter)
if activeParam != "" {
isActive := activeParam == "true" || activeParam == "1"
activeOnly = &isActive
}
ctx := c.Request.Context()
result, err := h.service.GetRolePermissionTree(ctx, roleKeycloak, groups, activeOnly)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
// Jika role disediakan tapi tidak ada data, return empty result
if roleKeycloak != "" && (result == nil || len(result.Data.Access) == 0) {
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "No permissions found for the specified role",
"data": []interface{}{},
})
return
}
// Return the result in the desired format
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Successfully retrieved RolPages",
"data": result.Data,
})
}
// GetPermissionTreeRole godoc
//
// @Summary Get Permission Tree by Role
// @Description Retrieve a permission tree by Keycloak role
// @Tags roles-permissions
// @Produce json
// @Param role path string true "Role Keycloak"
// @Param active query boolean false "Filter by active status"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/permissions/role/{role} [get]
func (h *RolPermissionHandler) GetPermissionTreeRole(c *gin.Context) {
roleKeycloak := c.Param("role")
// Parse active parameter - handle "true", "false", "1", "0"
activeParam := c.Query("active")
var activeOnly *bool // default nil (no filter)
if activeParam != "" {
isActive := activeParam == "true" || activeParam == "1"
activeOnly = &isActive
}
ctx := c.Request.Context()
result, err := h.service.GetRolePermissionTreeRole(ctx, roleKeycloak, activeOnly)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
// Jika role disediakan tapi tidak ada data, return empty result
if roleKeycloak != "" && (result == nil || len(result.Data.Access) == 0) {
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "No permissions found for the specified role",
"data": []interface{}{},
})
return
}
// Return the result in the desired format
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Successfully retrieved RolPages",
"data": result.Data,
})
}
// Create godoc
//
// @Summary Create new Role Permission
// @Description Create a new Role Permission record
// @Tags roles-permissions
// @Accept json
// @Produce json
// @Param request body roleService.RolPermissionRequest true "Payload"
// @Success 201 {object} response.Response
// @Security BearerAuth
// @Router /roles/permissions [post]
func (h *RolPermissionHandler) Create(c *gin.Context) {
var req roleService.RolPermissionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
ctx := c.Request.Context()
created, err := h.service.Create(ctx, req)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusCreated, "Successfully created RolPermission", created)
}
// Update godoc
//
// @Summary Update an existing Role Permission
// @Description Update details of an existing Role Permission record by ID
// @Tags roles-permissions
// @Accept json
// @Produce json
// @Param id path int true "Role Permission ID"
// @Param request body roleService.RolPermissionRequest true "Payload"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/permissions/{id} [put]
func (h *RolPermissionHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid ID format", nil)
return
}
var req roleService.RolPermissionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
ctx := c.Request.Context()
updated, err := h.service.Update(ctx, id, req)
if err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully updated RolPermission", updated)
}
// Delete godoc
//
// @Summary Delete a Role Permission
// @Description Delete a Role Permission record by ID
// @Tags roles-permissions
// @Produce json
// @Param id path int true "Role Permission ID"
// @Success 200 {object} response.Response
// @Security BearerAuth
// @Router /roles/permissions/{id} [delete]
func (h *RolPermissionHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "Invalid ID format", nil)
return
}
ctx := c.Request.Context()
if err := h.service.Delete(ctx, id); err != nil {
appErr := errors.FromError(err)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
return
}
response.Success(c, http.StatusOK, "Successfully deleted RolPermission", nil)
}
// GetRolePagesList mengambil daftar role beserta page dan permission-nya
// @Summary Get Role Pages Access List
// @Description Retrieve a paginated list of RolePages grouped by Role with Tree Hierarchy
// @Tags roles-permissions
// @Produce json
// @Param id path string true "Role Master ID"
// @Param page query integer false "Page number" default(1)
// @Param limit query integer false "Items per page" default(10)
// @Param active query boolean false "Filter by active status"
// @Security BearerAuth
// @Success 200 {object} response.Response
// @Router /roles/permissions/rolemaster/{id} [get]
func (h *RolPermissionHandler) GetRolePagesList(c *gin.Context) {
ctx := c.Request.Context()
roleID := c.Param("id")
// 1. Ambil Parameter Pagination
pageStr := c.DefaultQuery("page", "1")
limitStr := c.DefaultQuery("limit", "10")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < -1 {
limit = 10
}
offset := (page - 1) * limit
if limit == -1 {
offset = 0 // Jika limit -1, fetch all data
}
// 2. Ambil Parameter Filter Active
defaultActive := true
activeFilter := &defaultActive
if activeStr := c.Query("active"); activeStr != "" {
parsedActive, err := strconv.ParseBool(activeStr)
if err == nil {
activeFilter = &parsedActive
}
}
// 3. Panggil Service layer
res, err := h.service.GetRolePagesList(ctx, roleID, limit, offset, activeFilter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": "error",
"message": err.Error(),
})
return
}
// 4. Kalkulasi Metadata & Response Sesuai Format
total := res["total"].(int64)
totalPages := 1
if limit > 0 {
totalPages = int((total + int64(limit) - 1) / int64(limit))
}
// Format JSON Response
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Successfully retrieved RolPages list",
"data": res["data"],
"meta": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
@@ -0,0 +1,232 @@
package middleware
import (
"context"
"log"
"net/http"
"os"
"strings"
"time"
"service/pkg/logger" // Tambahkan import ini
"github.com/google/uuid"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func CORSMiddleware() gin.HandlerFunc {
// Development mode - izinkan semua origin (hanya untuk dev!)
if os.Getenv("APP_ENV") == "development" && os.Getenv("CORS_ALLOW_ALL") == "true" {
log.Println("WARNING: CORS allowing all origins (development mode)")
// Gunakan config khusus untuk allow all origins
config := cors.DefaultConfig()
config.AllowAllOrigins = true
config.AllowCredentials = false // Tidak bisa digunakan dengan AllowAllOrigins
config.AllowMethods = []string{
"GET", "POST", "PUT", "PATCH", "DELETE",
"HEAD", "OPTIONS",
}
config.AllowHeaders = []string{
"Origin",
"Content-Length",
"Content-Type",
"Authorization",
"X-Requested-With",
"X-API-Key",
"X-CSRF-Token",
"X-Custom-Header",
"Accept",
"Accept-Language",
"Accept-Encoding",
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
// Headers tambahan untuk Nuxt 3
"x-use-fetch",
"x-nuxt-base-url",
"x-forwarded-for",
"x-forwarded-proto",
"x-forwarded-host",
}
config.MaxAge = 12 * time.Hour
return cors.New(config)
}
// Config untuk specific origins
config := cors.DefaultConfig()
// Baca allowed origins dari environment variable
// Format: CORS_ORIGINS=http://localhost:3000,http://localhost:3001,https://myapp.com
originsEnv := os.Getenv("CORS_ORIGINS")
if originsEnv != "" {
// Split by comma dan trim spaces
origins := strings.Split(originsEnv, ",")
for i, origin := range origins {
origins[i] = strings.TrimSpace(origin)
}
config.AllowOrigins = origins
log.Printf("CORS: Using origins from environment: %v", config.AllowOrigins)
} else {
// Default origins untuk Nuxt 3 development
config.AllowOrigins = []string{
"http://localhost:3000", // Nuxt 3 default
"http://localhost:3001", // Nuxt 3 alternatif
"http://localhost:3002", // Nuxt 3 alternatif
"http://localhost:3005", // Nuxt 3 port Anda
"http://localhost:8080", // Common dev port
"http://localhost:5173", // Vite default port
"http://localhost:5174", // Vite alternatif
"https://localhost:3000", // HTTPS Nuxt
"https://localhost:8080", // HTTPS common
"http://meninjar.dev.rssa.id:8094", // Domain production Anda
}
log.Printf("CORS: Using default origins: %v", config.AllowOrigins)
}
// Method yang diizinkan untuk Nuxt 3 + TypeScript
config.AllowMethods = []string{
"GET", "POST", "PUT", "PATCH", "DELETE",
"HEAD", "OPTIONS",
}
// Headers yang diizinkan untuk Nuxt 3 + Axios
config.AllowHeaders = []string{
"Origin",
"Content-Length",
"Content-Type",
"Authorization",
"X-Requested-With",
"X-API-Key",
"X-CSRF-Token",
"X-Custom-Header",
"Accept",
"Accept-Language",
"Accept-Encoding",
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
// Headers tambahan untuk Nuxt 3
"x-use-fetch",
"x-nuxt-base-url",
"x-forwarded-for",
"x-forwarded-proto",
"x-forwarded-host",
}
// Izinkan credentials (penting untuk Nuxt 3)
config.AllowCredentials = true
// Preflight cache duration
config.MaxAge = 12 * time.Hour
return cors.New(config)
}
func LoggingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
// Ambil atau Generate Request ID untuk Tracing (Correlation ID)
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = uuid.New().String()
}
// Injeksi request_id ke dalam context request
ctx := context.WithValue(c.Request.Context(), "request_id", requestID)
c.Request = c.Request.WithContext(ctx)
c.Header("X-Request-ID", requestID)
// Process request
c.Next()
// Log using custom logger
latency := time.Since(start)
clientIP := c.ClientIP()
method := c.Request.Method
statusCode := c.Writer.Status()
fields := []logger.Field{
logger.String("ip", clientIP),
logger.String("method", method),
logger.String("path", path),
logger.Int("status", statusCode),
logger.Duration("latency", latency),
logger.String("user_agent", c.Request.UserAgent()),
}
if raw != "" {
fields = append(fields, logger.String("query", raw))
}
if len(c.Errors) > 0 {
fields = append(fields, logger.String("error", c.Errors.String()))
}
logCtx := logger.Default().WithContext(ctx)
// Use appropriate log level based on status code
if statusCode >= 500 {
logCtx.Error("HTTP Request", fields...)
} else if statusCode >= 400 {
logCtx.Warn("HTTP Request", fields...)
} else {
logCtx.Info("HTTP Request", fields...)
}
}
}
func ErrorMiddleware() gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
logger.Default().WithContext(c.Request.Context()).Error("Panic recovered", logger.Any("panic", recovered))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error",
"code": "INTERNAL_ERROR",
})
})
}
func SecurityMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. HSTS (Strict-Transport-Security)
// Memaksa browser hanya menggunakan HTTPS selama 1 tahun, termasuk subdomain.
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// 2. Content Security Policy (CSP) - Code Injection Protection
// Kita longgarkan khusus untuk path /swagger agar UI bisa melakukan load JavaScript & CSS inline bawaannya.
if strings.HasPrefix(c.Request.URL.Path, "/swagger") {
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'")
} else {
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content")
}
// 3. X-Content-Type-Options
// Mencegah browser menebak (sniffing) MIME type.
c.Header("X-Content-Type-Options", "nosniff")
// 4. X-Frame-Options (Clickjacking Protection)
// Mencegah website di-embed dalam iframe orang lain.
c.Header("X-Frame-Options", "DENY")
// 5. X-XSS-Protection
// Layer pertahanan lama untuk browser lama (Legacy), tapi tetap bagus untuk ada.
c.Header("X-XSS-Protection", "1; mode=block")
// 6. Referrer-Policy
// Menjaga privasi user saat klik link keluar dari aplikasi Anda.
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// 7. Permissions-Policy (Feature Policy)
// Mematikan fitur browser yang tidak dipakai (kamera, mic, lokasi) untuk mengurangi attack vector.
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()")
// Remove Information Leakage
c.Header("Server", "Unknown") // Atau hapus total
c.Header("X-Powered-By", "")
c.Next()
}
}
@@ -0,0 +1,574 @@
package middleware
import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"os"
pkgErrors "service/pkg/errors"
"strings"
"sync"
"time"
"service/internal/infrastructure/cache"
"service/internal/infrastructure/config"
"service/pkg/logger"
"service/pkg/response"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/sync/singleflight"
)
// Definisi Error kustom untuk autentikasi
var (
ErrInvalidToken = errors.New("invalid token")
ErrMissingClaims = errors.New("missing claims")
ErrTokenExpired = errors.New("token expired")
ErrInvalidSignature = errors.New("invalid signature")
ErrInvalidIssuer = errors.New("invalid issuer")
ErrInvalidAudience = errors.New("invalid audience")
ErrMissingAuthHeader = errors.New("missing authorization header")
ErrInvalidAuthHeader = errors.New("invalid authorization header format")
)
// JWTClaims menyimpan struktur payload token yang terekstrak
type JWTClaims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Name string `json:"name"`
}
// AuthProvider interface for different authentication methods
type AuthProvider interface {
ValidateToken(tokenString string) (*JWTClaims, error)
Name() string
}
// ProviderFactory creates authentication providers based on configuration
type ProviderFactory struct {
config *config.Config
}
func NewProviderFactory(config *config.Config) *ProviderFactory {
return &ProviderFactory{
config: config,
}
}
func (f *ProviderFactory) CreateProviders() []AuthProvider {
var providers []AuthProvider
reqLogger := logger.Default()
reqLogger.Info("Creating authentication providers",
logger.String("auth_type", f.config.Auth.Type),
logger.Bool("keycloak_enabled", f.config.Keycloak.Enabled),
logger.String("keycloak_issuer", f.config.Keycloak.Issuer),
logger.Int("static_tokens_len", len(f.config.Auth.StaticTokens)),
logger.String("fallback_to", f.config.Auth.FallbackTo),
)
switch f.config.Auth.Type {
case "static":
if len(f.config.Auth.StaticTokens) > 0 {
providers = append(providers, NewStaticTokenProvider(f.config.Auth.StaticTokens))
} else {
reqLogger.Warn("No static tokens configured for static auth type", logger.String("type", "static"))
}
case "jwt":
providers = append(providers, NewJWTAuthProvider())
reqLogger.Info("JWT provider added")
case "keycloak":
if f.config.Keycloak.Issuer != "" {
providers = append(providers, NewKeycloakAuthProvider(f.config))
reqLogger.Info("Keycloak provider added")
} else {
reqLogger.Warn("Keycloak issuer not configured for keycloak auth type", logger.String("type", "keycloak"))
}
case "hybrid":
if f.config.Keycloak.Issuer != "" {
providers = append(providers, NewKeycloakAuthProvider(f.config))
reqLogger.Info("Keycloak provider added for hybrid")
} else {
reqLogger.Warn("Keycloak issuer not configured for hybrid auth type", logger.String("type", "keycloak"))
}
switch f.config.Auth.FallbackTo {
case "static":
if len(f.config.Auth.StaticTokens) > 0 {
providers = append(providers, NewStaticTokenProvider(f.config.Auth.StaticTokens))
} else {
reqLogger.Warn("No static tokens configured for hybrid fallback", logger.String("type", "static"))
}
case "jwt":
providers = append(providers, NewJWTAuthProvider())
default:
providers = append(providers, NewJWTAuthProvider())
reqLogger.Info("JWT fallback provider added as default")
}
default:
providers = append(providers, NewJWTAuthProvider())
}
return providers
}
// StaticTokenProvider handles static token authentication
type StaticTokenProvider struct {
tokens map[string]bool
}
func NewStaticTokenProvider(tokens []string) *StaticTokenProvider {
tokenMap := make(map[string]bool)
for _, token := range tokens {
if token != "" {
tokenMap[token] = true
}
}
return &StaticTokenProvider{tokens: tokenMap}
}
func (s *StaticTokenProvider) ValidateToken(tokenString string) (*JWTClaims, error) {
if !s.tokens[tokenString] {
return nil, ErrInvalidToken
}
return &JWTClaims{
UserID: "static-user",
Username: "static-user",
Email: "[email protected]",
Role: "user",
Name: "Static User",
}, nil
}
func (s *StaticTokenProvider) Name() string {
return "static"
}
// JWTAuthProvider handles JWT authentication
type JWTAuthProvider struct {
secret string
}
func NewJWTAuthProvider() *JWTAuthProvider {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
secret = "fallback_secret_key_change_in_production"
}
return &JWTAuthProvider{secret: secret}
}
func (j *JWTAuthProvider) ValidateToken(tokenString string) (*JWTClaims, error) {
parsedToken, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(j.secret), nil
})
if err != nil || !parsedToken.Valid {
return nil, ErrInvalidToken
}
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrMissingClaims
}
return &JWTClaims{
UserID: fmt.Sprintf("%v", claims["user_id"]),
Email: fmt.Sprintf("%v", claims["email"]),
Role: fmt.Sprintf("%v", claims["role_id"]),
Name: fmt.Sprintf("%v", claims["name"]),
}, nil
}
func (j *JWTAuthProvider) Name() string {
return "jwt"
}
// KeycloakAuthProvider handles Keycloak JWT authentication
type KeycloakAuthProvider struct {
jwksCache *JwksCache
config *config.Config
}
func NewKeycloakAuthProvider(cfg *config.Config) *KeycloakAuthProvider {
return &KeycloakAuthProvider{
jwksCache: NewJwksCache(cfg),
config: cfg,
}
}
func (k *KeycloakAuthProvider) ValidateToken(tokenString string) (*JWTClaims, error) {
parsedToken, _, err := jwt.NewParser().ParseUnverified(tokenString, jwt.MapClaims{})
if err != nil {
return nil, ErrInvalidToken
}
// Extract claims for logging
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrMissingClaims
}
// Check if token is expired
if exp, ok := claims["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
return nil, ErrTokenExpired
}
}
// Pastikan token yang diterima adalah Access Token ("Bearer"), bukan ID Token
if typ, ok := claims["typ"].(string); ok {
if typ != "Bearer" {
return nil, fmt.Errorf("invalid token type: expected Bearer, got %s", typ)
}
}
// Now parse with verification
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
// Dihilangkan logger Warn agar tidak menyebabkan log spam ketika mekanisme fallback JWT aktif
return nil, ErrInvalidSignature
}
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, errors.New("kid header not found")
}
key, err := k.jwksCache.GetKey(kid)
if err != nil {
return nil, err
}
return key, nil
}, jwt.WithIssuer(k.config.Keycloak.Issuer))
if err != nil {
// Return specific error based on the error type
if strings.Contains(err.Error(), "expired") {
return nil, ErrTokenExpired
} else if strings.Contains(err.Error(), "signature") {
return nil, ErrInvalidSignature
} else if strings.Contains(err.Error(), "issuer") {
return nil, ErrInvalidIssuer
}
return nil, fmt.Errorf("invalid token: %v", err)
}
if !token.Valid {
return nil, ErrInvalidToken
}
// Extract claims
claims, ok = token.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrMissingClaims
}
// Validasi custom untuk Audience (aud) atau Authorized Party (azp)
// Keycloak menempatkan Client ID di 'azp' untuk Access Token
expectedAudience := k.config.Keycloak.Audience
if expectedAudience != "" {
validAudience := false
// 1. Cek klaim azp (Authorized Party)
if azp := getClaimString(claims, "azp"); azp == expectedAudience {
validAudience = true
}
// 2. Cek klaim aud (Audience) jika azp tidak cocok
if !validAudience {
if audValue, ok := claims["aud"]; ok {
if audList, err := extractAudience(audValue); err == nil {
for _, a := range audList {
if a == expectedAudience {
validAudience = true
break
}
}
}
}
}
if !validAudience {
return nil, ErrInvalidAudience
}
}
// Validate required claims
userID := getClaimString(claims, "sub")
if userID == "" {
return nil, ErrMissingClaims
}
// Ekstraksi nested roles dari Keycloak (realm_access.roles)
var roleStr string
if realmAccess, ok := claims["realm_access"].(map[string]interface{}); ok {
if roles, ok := realmAccess["roles"].([]interface{}); ok {
var roleList []string
for _, r := range roles {
roleList = append(roleList, fmt.Sprintf("%v", r))
}
roleStr = strings.Join(roleList, ",") // Menggabungkan array role menjadi string: "admin,user"
}
}
return &JWTClaims{
UserID: userID,
Username: getClaimString(claims, "preferred_username"),
Email: getClaimString(claims, "email"),
Role: roleStr,
Name: getClaimString(claims, "name"),
}, nil
}
func (k *KeycloakAuthProvider) Name() string {
return "keycloak"
}
// AuthMiddleware provides flexible authentication based on configuration and implements redis blacklist check
func AuthMiddleware(cfg *config.Config, cacheManager *cache.Manager) gin.HandlerFunc {
factory := NewProviderFactory(cfg)
providers := factory.CreateProviders()
// Validate that we have at least one provider
if len(providers) == 0 {
return func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
}
}
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": ErrMissingAuthHeader.Error()})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": ErrInvalidAuthHeader.Error()})
return
}
tokenString := parts[1]
// Cek Blacklist Token (User yang sudah logout dilarang menggunakan token yang sama)
if cacheManager != nil {
var isBlacklisted bool
if err := cacheManager.Get(c.Request.Context(), "blacklist_token:"+tokenString, &isBlacklisted); err == nil && isBlacklisted {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token has been revoked or logged out"})
return
}
}
// Coba setiap provider sampai salah satu berhasil
var claims *JWTClaims
var err error
var providerName string
providerErrorDetails := make(map[string]string)
for _, provider := range providers {
claims, err = provider.ValidateToken(tokenString)
if err == nil {
providerName = provider.Name()
break // Berhenti jika ada yang berhasil
}
providerErrorDetails[provider.Name()] = err.Error()
}
if err != nil {
var finalErr error
if errors.Is(err, ErrTokenExpired) {
finalErr = pkgErrors.UnauthorizedError().
Code(pkgErrors.ErrCodeTokenExpired).
Message(pkgErrors.GetLocalizedMessage(pkgErrors.ErrCodeTokenExpired, "id", "Token telah kadaluarsa")).
Metadata("provider_errors", providerErrorDetails).Build()
} else {
finalErr = pkgErrors.UnauthorizedError().
Code(pkgErrors.ErrCodeInvalidToken).
Message(pkgErrors.GetLocalizedMessage(pkgErrors.ErrCodeInvalidToken, "id", "Token tidak valid")).
Metadata("provider_errors", providerErrorDetails).Build()
}
appErr := pkgErrors.FromError(finalErr)
response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata())
c.Abort()
return
}
// Set informasi pengguna di konteks
if claims != nil {
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("name", claims.Name)
c.Set("role_id", claims.Role) // Kompatibilitas untuk handler lama
c.Set("token", tokenString) // Kompatibilitas untuk handler lama
c.Set("auth_provider", providerName)
}
c.Next()
}
}
// InitializeAuth initializes authentication configuration
func InitializeAuth(cfg *config.Config) {
// This function can be used to initialize global auth settings if needed
logger.Default().Info("Authentication initialized", logger.String("auth_type", cfg.Auth.Type))
}
// Helper functions
func getClaimString(claims jwt.MapClaims, key string) string {
if value, ok := claims[key]; ok && value != nil {
if str, ok := value.(string); ok {
return str
}
}
return ""
}
// extractAudience parses audience claim which can be a string or an array of strings
func extractAudience(audValue interface{}) ([]string, error) {
switch v := audValue.(type) {
case string:
return []string{v}, nil
case []interface{}:
var auds []string
for _, a := range v {
if s, ok := a.(string); ok {
auds = append(auds, s)
}
}
return auds, nil
default:
return nil, errors.New("invalid audience format")
}
}
// JwksCache and related functions
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.Issuer == "" {
return nil, fmt.Errorf("keycloak issuer is not configured")
}
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"
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(jwksURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch JWKS: HTTP %d", resp.StatusCode)
}
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 := base64.RawURLEncoding.DecodeString(nStr)
if err != nil {
return nil, err
}
eBytes, err := base64.RawURLEncoding.DecodeString(eStr)
if err != nil {
return nil, err
}
n := new(big.Int).SetBytes(nBytes)
e := int(new(big.Int).SetBytes(eBytes).Int64())
return &rsa.PublicKey{
N: n,
E: e,
}, nil
}
@@ -0,0 +1,183 @@
// internal/infrastructure/transport/http/middleware/rate_limit.go
package middleware
import (
"context"
"net/http"
"sync"
"time"
"service/internal/infrastructure/cache"
"service/pkg/logger" // Pastikan import ini benar
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
// RateLimitMiddleware creates rate limiting middleware using Redis cache
func RateLimitMiddleware(cacheManager *cache.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
clientIP := c.ClientIP()
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
// Increment rate limit counter
count, err := cacheManager.IncrementRateLimit(ctx, clientIP)
if err != nil {
// PERBAIKAN: Gunakan logger baru dengan konteks dan field terstruktur
logger.Default().WithContext(ctx).
Error("Failed to increment rate limit for IP",
logger.ErrorField(err),
logger.String("client_ip", clientIP),
)
// Allow request to proceed if cache is unavailable
c.Next()
return
}
// Set TTL on first request
if count == 1 {
if err := cacheManager.SetRateLimit(ctx, clientIP, count); err != nil {
// PERBAIKAN: Gunakan logger baru
logger.Default().WithContext(ctx).
Error("Failed to set rate limit TTL for IP",
logger.ErrorField(err),
logger.String("client_ip", clientIP),
)
}
}
// Check if rate limit exceeded (60 requests per minute)
if count > 60 {
// PERBAIKAN: Tambahkan log saat rate limit terlampaui
logger.Default().WithContext(ctx).
Warn("Rate limit exceeded for IP",
logger.String("client_ip", clientIP),
logger.Int64("count", count),
)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many requests",
"code": "RATE_LIMIT_EXCEEDED",
"retry_after": 60,
})
c.Abort()
return
}
c.Next()
}
}
// RateLimitByToken creates rate limiting middleware based on auth token
func RateLimitByToken(cacheManager *cache.Manager, requestsPerMinute int) gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.Next()
return
}
// Extract token from Bearer format
if len(token) > 7 && token[:7] == "Bearer " {
token = token[7:]
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
// Increment rate limit counter
count, err := cacheManager.IncrementRateLimit(ctx, "token:"+token)
if err != nil {
// PERBAIKAN: Gunakan logger baru
logger.Default().WithContext(ctx).
Error("Failed to increment token rate limit",
logger.ErrorField(err),
logger.String("token_prefix", token[:minLen(len(token), 10)]+"..."), // Jangan log token utuh
)
c.Next()
return
}
// Set TTL on first request
if count == 1 {
if err := cacheManager.SetRateLimit(ctx, "token:"+token, count); err != nil {
// PERBAIKAN: Gunakan logger baru
logger.Default().WithContext(ctx).
Error("Failed to set token rate limit TTL",
logger.ErrorField(err),
logger.String("token_prefix", token[:minLen(len(token), 10)]+"..."),
)
}
}
// Check if rate limit exceeded
if count > int64(requestsPerMinute) {
// PERBAIKAN: Tambahkan log saat rate limit token terlampaui
logger.Default().WithContext(ctx).
Warn("Rate limit exceeded for token",
logger.String("token_prefix", token[:minLen(len(token), 10)]+"..."),
logger.Int64("count", count),
)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many requests for this token",
"code": "TOKEN_RATE_LIMIT_EXCEEDED",
"retry_after": 60,
})
c.Abort()
return
}
c.Next()
}
}
// Helper function untuk menghindari error jika token lebih pendek dari 10 karakter
func minLen(a, b int) int {
if a < b {
return a
}
return b
}
// Variabel global untuk menyimpan limiter per-client IP/Identifier untuk memory rate limiter
var (
visitors = make(map[string]*rate.Limiter)
mu sync.Mutex
)
// getVisitor mengambil atau membuat limiter baru untuk client identifier tertentu
func getVisitor(identifier string, r rate.Limit, b int) *rate.Limiter {
mu.Lock()
defer mu.Unlock()
limiter, exists := visitors[identifier]
if !exists {
limiter = rate.NewLimiter(r, b)
visitors[identifier] = limiter
}
return limiter
}
// MemoryRateLimitMiddleware membatasi jumlah request per client secara lokal di memori.
// requestsPerSecond: jumlah hit yang diizinkan per detik.
// burstSize: jumlah hit maksimal dalam satu waktu (burst).
func MemoryRateLimitMiddleware(requestsPerSecond float64, burstSize int) gin.HandlerFunc {
limit := rate.Limit(requestsPerSecond)
return func(c *gin.Context) {
clientIdentifier := c.ClientIP()
limiter := getVisitor(clientIdentifier, limit, burstSize)
if !limiter.Allow() {
c.JSON(http.StatusTooManyRequests, gin.H{
"status": "error",
"message": "Terlalu banyak permintaan ke API Satu Sehat. Silakan coba beberapa saat lagi.",
"code": "RATE_LIMIT_EXCEEDED",
})
c.Abort()
return
}
c.Next()
}
}
@@ -0,0 +1,122 @@
package routes
import (
"net/http"
"time"
"service/internal/infrastructure/cache"
"service/internal/infrastructure/config"
"service/internal/infrastructure/transport/http/middleware"
authHttp "service/internal/infrastructure/transport/http/handlers/main/auth"
healthHttp "service/internal/infrastructure/transport/http/handlers/main/health"
roleHttp "service/internal/infrastructure/transport/http/handlers/main/master/roles"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// ModuleHandlers menampung seluruh HTTP handler opsional dari berbagai modul.
// Jika sebuah handler bernilai nil, maka rutenya akan otomatis diabaikan (disabled).
type ModuleHandlers struct {
Auth *authHttp.AuthHandler
RolePages *roleHttp.RolPagesHandler
RolePermission *roleHttp.RolPermissionHandler
RoleMaster *roleHttp.RoleMasterHandler
}
// SetupRoutes mendaftarkan seluruh endpoint API secara dinamis berdasarkan
// modul-modul yang aktif (tidak nil) pada aplikasi.
func SetupRoutes(
engine *gin.Engine,
cfg *config.Config,
cacheManager *cache.Manager,
healthHandler *healthHttp.HealthHandler,
h *ModuleHandlers,
) {
// Handle 405 Method Not Allowed for better client feedback
engine.HandleMethodNotAllowed = true
// Health check endpoints - using consistent http.Status constants
engine.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"timestamp": time.Now().UTC(),
"service": "service-general",
"version": "1.0.0",
})
})
// Comprehensive health check with dependencies
engine.GET("/health/complete", healthHandler.HealthCheckComplete)
// Database health check
engine.GET("/health/database", healthHandler.HealthCheckDatabase)
// Redis/cache health check
engine.GET("/health/cache", healthHandler.HealthCheckCache)
// External services health check
engine.GET("/health/external", healthHandler.HealthCheckExternal)
// Minio service
engine.GET("/health/minio", healthHandler.HealthCheckMinio)
engine.POST("/health/minio/upload", healthHandler.TestUploadMinio)
// All databases health check
// engine.GET("/health/databases", healthHandler.HealthCheckAllDatabases)
// Readiness check
engine.GET("/ready", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ready",
"timestamp": time.Now().UTC(),
})
})
// Liveness check
engine.GET("/live", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "alive",
"timestamp": time.Now().UTC(),
})
})
// Redirect otomatis dari /swagger ke /swagger/index.html
engine.GET("/swagger", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/swagger/index.html")
})
// Endpoint untuk Swagger UI
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// API v1 routes
v1 := engine.Group("/api/v1")
{
// Register auth routes (Public)
if h.Auth != nil {
h.Auth.RegisterRoutes(v1)
}
// Private routes (Membutuhkan Autentikasi)
protected := v1.Group("")
protected.Use(middleware.AuthMiddleware(cfg, cacheManager))
{
if h.Auth != nil {
h.Auth.RegisterProtectedRoutes(protected)
}
// --- Routes Modul Master ---
if h.RolePages != nil {
h.RolePages.RegisterRoutes(protected)
}
if h.RolePermission != nil {
h.RolePermission.RegisterRoutes(protected)
}
if h.RoleMaster != nil {
h.RoleMaster.RegisterRoutes(protected)
}
}
}
}
@@ -0,0 +1,34 @@
package servers
import (
"service/internal/infrastructure/cache"
"service/internal/infrastructure/config"
"service/internal/infrastructure/database"
"gorm.io/gorm"
"service/internal/auth"
roleMaster "service/internal/master/role/master"
rolePages "service/internal/master/role/pages"
rolePermission "service/internal/master/role/permission"
)
// MasterServices menampung kumpulan service untuk domain Master Data
type MasterServices struct {
RolePages rolePages.Service
RolePermission rolePermission.Service
RoleMaster roleMaster.Service
}
// ServiceRegistry berfungsi sebagai Dependency Injection Container untuk transport HTTP.
// Struktur ini mencegah membengkaknya parameter pada saat inisialisasi API.
type ServiceRegistry struct {
Config *config.Config
DBManager database.Service
PrimaryDB *gorm.DB
CacheManager *cache.Manager
// Modul Aplikasi (Pisahkan berdasarkan Bounded Context)
AuthService auth.Service
Master *MasterServices
}
@@ -0,0 +1,148 @@
// internal/infrastructure/transport/http/servers/server.go
package servers
import (
"context" // PERBAIKAN: Tambahkan import untuk graceful shutdown
"fmt"
"log"
"net/http"
"time"
"service/internal/infrastructure/cache" // PERBAIKAN: Tambahkan import cache
"service/internal/infrastructure/config"
"service/internal/infrastructure/transport/http/middleware"
"service/internal/infrastructure/transport/http/routes"
authHttp "service/internal/infrastructure/transport/http/handlers/main/auth"
healthHttp "service/internal/infrastructure/transport/http/handlers/main/health"
roleHttp "service/internal/infrastructure/transport/http/handlers/main/master/roles"
// Inisialisasi pkg
"service/pkg/errors"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
type Server struct {
engine *gin.Engine
config *config.ServerRESTConfig
cacheConfig cache.CacheConfig // PERBAIKAN: Tambahkan cacheConfig
httpServer *http.Server // PERBAIKAN: Simpan instance http.Server untuk graceful shutdown
}
// NewHTTPServer membuat instance server HTTP baru
func NewHTTPServer(
restConfig *config.ServerRESTConfig,
registry *ServiceRegistry,
) *Server {
if !restConfig.Enabled {
log.Println("HTTP server is disabled in configuration.")
return &Server{
config: restConfig,
}
}
// Set mode Gin berdasarkan konfigurasi global
if registry.Config.Server.Mode == "release" {
gin.SetMode(gin.ReleaseMode)
}
engine := gin.New()
// Middleware untuk recovery dan penanganan error konsisten.
// Diletakkan paling awal untuk menangkap panic dari middleware/handler lain.
engine.Use(errors.CombinedMiddleware())
// Middleware lainnya
engine.Use(middleware.LoggingMiddleware())
engine.Use(middleware.SecurityMiddleware())
engine.Use(middleware.CORSMiddleware())
// PERBAIKAN: Berikan cacheManager ke RateLimitMiddleware
engine.Use(middleware.RateLimitMiddleware(registry.CacheManager))
// Inisialisasi ModuleHandlers secara dinamis berdasarkan fitur yang aktif
appHandlers := &routes.ModuleHandlers{}
if registry.AuthService != nil {
appHandlers.Auth = authHttp.NewAuthHandler(registry.AuthService)
}
// Inisialisasi Handlers Modul Master
if registry.Master != nil {
if registry.Master.RolePages != nil {
appHandlers.RolePages = roleHttp.NewRolPagesHandler(registry.Master.RolePages)
}
if registry.Master.RolePermission != nil {
appHandlers.RolePermission = roleHttp.NewRolPermissionHandler(registry.Master.RolePermission)
}
if registry.Master.RoleMaster != nil {
appHandlers.RoleMaster = roleHttp.NewRoleMasterHandler(registry.Master.RoleMaster)
}
}
// Inisialisasi Health Handler dengan dependensi yang benar
// Gunakan constructor baru yang menerima cache manager
healthHandler := healthHttp.NewHealthHandlerWithCache(registry.PrimaryDB, registry.CacheManager, registry.Config, registry.DBManager)
// Setup routes menggunakan handler yang sudah diinisialisasi
routes.SetupRoutes(
engine,
registry.Config,
registry.CacheManager,
healthHandler,
appHandlers,
)
return &Server{
engine: engine,
config: restConfig,
}
}
// RegisterSwagger mendaftarkan dokumentasi Swagger UI
func (s *Server) RegisterSwagger() {
if s.engine == nil {
log.Println("Cannot register Swagger: engine is nil (server disabled)")
return
}
s.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
// Start menjalankan server HTTP
func (s *Server) Start(globalConfig *config.ServerConfig) error {
if !s.config.Enabled {
log.Println("HTTP server is disabled, not starting.")
return nil
}
addr := fmt.Sprintf(":%d", s.config.Port)
log.Printf("Starting HTTP server on %s", addr)
// PERBAIKAN: Simpan instance server ke struct
s.httpServer = &http.Server{
Addr: addr,
Handler: s.engine,
ReadTimeout: time.Duration(globalConfig.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(globalConfig.WriteTimeout) * time.Second,
IdleTimeout: 60 * time.Second, // Good practice to have an idle timeout
}
return s.httpServer.ListenAndServe()
}
// PERBAIKAN: Tambahkan method untuk graceful shutdown
// Shutdown memberhentikan server HTTP dengan graceful shutdown
func (s *Server) Shutdown(ctx context.Context) error {
if s.httpServer == nil {
return nil
}
log.Println("Shutting down HTTP server...")
return s.httpServer.Shutdown(ctx)
}
// GetEngine mengembalikan instance Gin engine (terutama untuk testing)
func (s *Server) GetEngine() *gin.Engine {
return s.engine
}