first commit
This commit is contained in:
105
internal/pkg/logger/logger.go
Normal file
105
internal/pkg/logger/logger.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fields adalah data tambahan yang disisipkan ke satu baris log, contoh:
|
||||
//
|
||||
// logger.Info("request selesai", logger.Fields{"path": "/users", "status": 200})
|
||||
type Fields map[string]interface{}
|
||||
|
||||
var std = log.New(os.Stdout, "", 0)
|
||||
|
||||
// SetOutput mengganti tujuan tulis log. Berguna untuk unit test (redirect ke buffer)
|
||||
// atau kalau nanti mau kirim log ke file/agent lain.
|
||||
func SetOutput(w io.Writer) {
|
||||
std = log.New(w, "", 0)
|
||||
}
|
||||
|
||||
var sensitiveKeys = map[string]bool{
|
||||
"password": true,
|
||||
"password_confirmation": true,
|
||||
"old_password": true,
|
||||
"new_password": true,
|
||||
"confirm_password": true,
|
||||
"token": true,
|
||||
"access_token": true,
|
||||
"refresh_token": true,
|
||||
"authorization": true,
|
||||
"secret": true,
|
||||
"jwt_secret": true,
|
||||
"credit_card": true,
|
||||
"card_number": true,
|
||||
"cvv": true,
|
||||
"otp": true,
|
||||
}
|
||||
|
||||
const redactedValue = "***REDACTED***"
|
||||
|
||||
// sanitize menyensor field sensitif secara rekursif (termasuk di dalam nested object/body JSON)
|
||||
// supaya data seperti password tidak pernah ikut tercetak di log, walau caller lupa menyaringnya.
|
||||
func sanitize(fields Fields) Fields {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
clean := make(Fields, len(fields))
|
||||
for key, value := range fields {
|
||||
if sensitiveKeys[strings.ToLower(key)] {
|
||||
clean[key] = redactedValue
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
clean[key] = sanitize(Fields(v))
|
||||
case Fields:
|
||||
clean[key] = sanitize(v)
|
||||
default:
|
||||
clean[key] = value
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Fields Fields `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
func write(level, msg string, fields Fields) {
|
||||
e := entry{
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
Level: level,
|
||||
Message: msg,
|
||||
Fields: sanitize(fields),
|
||||
}
|
||||
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
// fallback kalau ternyata ada value yang gagal di-marshal, tetap harus ada jejak log-nya
|
||||
std.Printf("[%s] %s (gagal marshal fields: %v)", level, msg, err)
|
||||
return
|
||||
}
|
||||
|
||||
std.Println(string(b))
|
||||
}
|
||||
|
||||
// Info dipakai untuk mencatat aktivitas normal, contoh: request masuk dan sukses diproses.
|
||||
func Info(msg string, fields Fields) { write("INFO", msg, fields) }
|
||||
|
||||
// Warn dipakai untuk kondisi yang bukan bug tapi perlu diperhatikan, contoh: request 4xx
|
||||
// (input salah, tidak diotorisasi, dst).
|
||||
func Warn(msg string, fields Fields) { write("WARN", msg, fields) }
|
||||
|
||||
// Error dipakai untuk kegagalan sisi server (5xx) atau error tak terduga lainnya yang
|
||||
// butuh investigasi lebih lanjut oleh developer.
|
||||
func Error(msg string, fields Fields) { write("ERROR", msg, fields) }
|
||||
131
internal/pkg/response/response.go
Normal file
131
internal/pkg/response/response.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"cardverse/internal/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response adalah struktur baku untuk seluruh response API
|
||||
type Response struct {
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
Error interface{} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Pagination berisi metadata pagination yang dikirim di field "meta"
|
||||
type Pagination struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalData int64 `json:"total_data"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
HasNext bool `json:"has_next"`
|
||||
HasPrev bool `json:"has_prev"`
|
||||
}
|
||||
|
||||
// NewPagination menghitung metadata pagination dari page, limit, dan total data.
|
||||
// Dipakai di service/handler setelah query COUNT(*) ke database.
|
||||
func NewPagination(page, limit int, totalData int64) Pagination {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
totalPages := int(math.Ceil(float64(totalData) / float64(limit)))
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
return Pagination{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
TotalData: totalData,
|
||||
TotalPages: totalPages,
|
||||
HasNext: page < totalPages,
|
||||
HasPrev: page > 1,
|
||||
}
|
||||
}
|
||||
|
||||
func Success(c *gin.Context, statusCode int, message string, data interface{}) {
|
||||
c.JSON(statusCode, Response{
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func SuccessWithPagination(c *gin.Context, statusCode int, message string, data interface{}, pagination Pagination) {
|
||||
c.JSON(statusCode, Response{
|
||||
Message: message,
|
||||
Data: data,
|
||||
Meta: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
var defaultErrorMessages = map[int]string{
|
||||
http.StatusUnauthorized: "Anda tidak memiliki akses, silakan login kembali",
|
||||
http.StatusForbidden: "Anda tidak memiliki izin untuk melakukan aksi ini",
|
||||
http.StatusNotFound: "Data yang diminta tidak ditemukan",
|
||||
http.StatusConflict: "Data sudah ada atau bertentangan dengan data yang tersimpan",
|
||||
http.StatusUnprocessableEntity: "Data yang dikirim tidak dapat diproses",
|
||||
http.StatusTooManyRequests: "Terlalu banyak request, silakan coba lagi nanti",
|
||||
http.StatusInternalServerError: "Terjadi kesalahan pada server, silakan coba lagi nanti",
|
||||
http.StatusBadGateway: "Layanan sedang tidak tersedia, silakan coba lagi nanti",
|
||||
http.StatusServiceUnavailable: "Layanan sedang tidak tersedia, silakan coba lagi nanti",
|
||||
}
|
||||
|
||||
const fallbackErrorMessage = "Terjadi kesalahan, silakan coba lagi nanti"
|
||||
|
||||
// Error mengirim response gagal ke client.
|
||||
//
|
||||
// - Untuk 400 Bad Request: message & err (data) dikirim apa adanya ke client — biasanya
|
||||
// cuma detail validasi input, aman untuk membantu client memperbaiki request-nya.
|
||||
// - Untuk status lain (401, 403, 404, 409, 500, dst): client HANYA menerima pesan generik
|
||||
// dari defaultErrorMessages, TIDAK menerima err/message asli sama sekali. Detail asli
|
||||
// (message & err yang dikirim caller) dicatat lewat logger supaya tetap bisa di-debug
|
||||
// dari log server tanpa membocorkannya ke response API.
|
||||
func Error(c *gin.Context, statusCode int, message string, err interface{}) {
|
||||
if statusCode == http.StatusBadRequest {
|
||||
c.JSON(statusCode, Response{
|
||||
Message: message,
|
||||
Error: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logFields := logger.Fields{
|
||||
"method": c.Request.Method,
|
||||
"path": c.Request.URL.Path,
|
||||
"status": statusCode,
|
||||
"ip": c.ClientIP(),
|
||||
"detail": message,
|
||||
}
|
||||
if err != nil {
|
||||
logFields["error"] = err
|
||||
}
|
||||
|
||||
if statusCode >= http.StatusInternalServerError {
|
||||
logger.Error("request gagal diproses", logFields)
|
||||
} else {
|
||||
logger.Warn("request ditolak", logFields)
|
||||
}
|
||||
|
||||
clientMessage := message
|
||||
|
||||
if clientMessage == "" {
|
||||
if msg, ok := defaultErrorMessages[statusCode]; ok {
|
||||
clientMessage = msg
|
||||
} else {
|
||||
clientMessage = fallbackErrorMessage
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(statusCode, Response{
|
||||
Message: clientMessage,
|
||||
})
|
||||
}
|
||||
28
internal/pkg/utils/file.go
Normal file
28
internal/pkg/utils/file.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeleteLocalFile menghapus file lokal jika file tersebut berada di folder ./public
|
||||
func DeleteLocalFile(relPath string) {
|
||||
if relPath == "" || strings.HasPrefix(relPath, "http://") || strings.HasPrefix(relPath, "https://") {
|
||||
return
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(relPath)
|
||||
var localPath string
|
||||
if strings.HasPrefix(cleaned, "/images/") {
|
||||
localPath = "./public" + cleaned
|
||||
} else if strings.HasPrefix(cleaned, "/public/") {
|
||||
localPath = "." + cleaned
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
_ = os.Remove(localPath)
|
||||
}
|
||||
}
|
||||
95
internal/pkg/utils/jwt.go
Normal file
95
internal/pkg/utils/jwt.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"cardverse/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateAccessToken(userID uint, email string, role string) (string, error) {
|
||||
cfg := config.Cfg
|
||||
expiration := time.Now().Add(time.Duration(cfg.JWTExpiresHours) * time.Hour)
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiration),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func GenerateRefreshToken(userID uint) (string, error) {
|
||||
cfg := config.Cfg
|
||||
expiration := time.Now().Add(time.Duration(cfg.JWTRefreshExpiresHours) * time.Hour)
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiration),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWTRefreshSecret))
|
||||
}
|
||||
|
||||
func GenerateToken(userID uint, email string, role string) (string, error) {
|
||||
return GenerateAccessToken(userID, email, role)
|
||||
}
|
||||
|
||||
func ValidateToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Cfg
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("metode signing token tidak valid")
|
||||
}
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, errors.New("token tidak valid")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func ValidateRefreshToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Cfg
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("metode signing token tidak valid")
|
||||
}
|
||||
return []byte(cfg.JWTRefreshSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, errors.New("refresh token tidak valid")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
67
internal/pkg/utils/jwt_test.go
Normal file
67
internal/pkg/utils/jwt_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cardverse/config"
|
||||
)
|
||||
|
||||
func setupTestConfig() {
|
||||
config.Cfg = &config.Config{
|
||||
JWTSecret: "test-secret-key",
|
||||
JWTExpiresHours: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateToken_AndValidateToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
token, err := GenerateToken(42, "user@example.com", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken expected no error, got: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("token should not be empty")
|
||||
}
|
||||
|
||||
claims, err := ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken expected no error for valid token, got: %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != 42 {
|
||||
t.Errorf("UserID expected 42, got %d", claims.UserID)
|
||||
}
|
||||
if claims.Email != "user@example.com" {
|
||||
t.Errorf("Email expected user@example.com, got %s", claims.Email)
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Errorf("Role expected admin, got %s", claims.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_InvalidToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
_, err := ValidateToken("invalid.random.token")
|
||||
if err == nil {
|
||||
t.Fatal("ValidateToken should fail for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_ExpiredToken(t *testing.T) {
|
||||
config.Cfg = &config.Config{
|
||||
JWTSecret: "test-secret-key",
|
||||
JWTExpiresHours: 0,
|
||||
}
|
||||
|
||||
token, _ := GenerateToken(1, "expired@example.com", "user")
|
||||
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
_, err := ValidateToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("ValidateToken should fail for expired token")
|
||||
}
|
||||
}
|
||||
15
internal/pkg/utils/password.go
Normal file
15
internal/pkg/utils/password.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package utils
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword meng-hash plain password menggunakan bcrypt
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword membandingkan plain password dengan hash yang tersimpan
|
||||
func CheckPassword(hashedPassword, plainPassword string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
|
||||
return err == nil
|
||||
}
|
||||
34
internal/pkg/utils/password_test.go
Normal file
34
internal/pkg/utils/password_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashPassword_AndCheckPassword(t *testing.T) {
|
||||
plain := "rahasia123"
|
||||
|
||||
hashed, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword tidak boleh error, dapat: %v", err)
|
||||
}
|
||||
|
||||
if hashed == plain {
|
||||
t.Fatal("hasil hash tidak boleh sama dengan plain password")
|
||||
}
|
||||
|
||||
if !CheckPassword(hashed, plain) {
|
||||
t.Fatal("CheckPassword harus true untuk password yang benar")
|
||||
}
|
||||
|
||||
if CheckPassword(hashed, "password_salah") {
|
||||
t.Fatal("CheckPassword harus false untuk password yang salah")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword_HasilBerbedaSetiapKaliDipanggil(t *testing.T) {
|
||||
// bcrypt menyisipkan salt acak, jadi 2x hash dari password sama harus menghasilkan string berbeda
|
||||
hash1, _ := HashPassword("sama-sama")
|
||||
hash2, _ := HashPassword("sama-sama")
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("dua hash dari password yang sama seharusnya berbeda karena salt")
|
||||
}
|
||||
}
|
||||
31
internal/pkg/utils/string.go
Normal file
31
internal/pkg/utils/string.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var nonAlphanumericRegexp = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// UnderscoreString mengubah string judul/nama menjadi format_ular (misal: "Evolusi Mega" -> "evolusi_mega")
|
||||
func UnderscoreString(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "&", "and")
|
||||
s = nonAlphanumericRegexp.ReplaceAllString(s, "_")
|
||||
s = strings.Trim(s, "_")
|
||||
if s == "" {
|
||||
s = "underscore_string"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func Slugify(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "&", "and")
|
||||
s = nonAlphanumericRegexp.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
s = "slugify"
|
||||
}
|
||||
return s
|
||||
}
|
||||
77
internal/pkg/utils/url.go
Normal file
77
internal/pkg/utils/url.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"cardverse/config"
|
||||
)
|
||||
|
||||
// getBaseURL mengambil Base URL dari config (APP_BASE_URL) atau environment variable dengan fallback ke http://localhost:PORT
|
||||
func getBaseURL() string {
|
||||
if config.Cfg != nil && config.Cfg.AppBaseURL != "" {
|
||||
return strings.TrimRight(config.Cfg.AppBaseURL, "/")
|
||||
}
|
||||
|
||||
if envURL := os.Getenv("APP_BASE_URL"); envURL != "" {
|
||||
return strings.TrimRight(envURL, "/")
|
||||
}
|
||||
|
||||
port := os.Getenv("APP_PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
return "http://localhost:" + port
|
||||
}
|
||||
|
||||
// FormatMediaURL mengubah path relatif gambar (seperti /images/...) menjadi URL lengkap menggunakan APP_BASE_URL
|
||||
func FormatMediaURL(urlStr string) string {
|
||||
if urlStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Jika sudah berupa URL lengkap (http:// atau https://), kembalikan langsung
|
||||
if strings.HasPrefix(urlStr, "http://") || strings.HasPrefix(urlStr, "https://") {
|
||||
return urlStr
|
||||
}
|
||||
|
||||
baseURL := getBaseURL()
|
||||
|
||||
if !strings.HasPrefix(urlStr, "/") {
|
||||
urlStr = "/" + urlStr
|
||||
}
|
||||
|
||||
return baseURL + urlStr
|
||||
}
|
||||
|
||||
// FormatMediaURLPtr mengubah pointer path relatif gambar menjadi pointer URL lengkap
|
||||
func FormatMediaURLPtr(urlPtr *string) *string {
|
||||
if urlPtr == nil || *urlPtr == "" {
|
||||
return urlPtr
|
||||
}
|
||||
formatted := FormatMediaURL(*urlPtr)
|
||||
return &formatted
|
||||
}
|
||||
|
||||
// FormatJSONBMediaURLs mengubah seluruh path relatif gambar (/images/... atau /public/...) di dalam data byte JSONB menjadi URL lengkap
|
||||
func FormatJSONBMediaURLs(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
baseURL := getBaseURL()
|
||||
s := string(data)
|
||||
|
||||
// Replace "/images/" dengan "APP_BASE_URL/images/"
|
||||
targetImages := `"/images/`
|
||||
replaceImages := `"` + baseURL + `/images/`
|
||||
s = strings.ReplaceAll(s, targetImages, replaceImages)
|
||||
|
||||
// Replace "/public/" dengan "APP_BASE_URL/public/"
|
||||
targetPublic := `"/public/`
|
||||
replacePublic := `"` + baseURL + `/public/`
|
||||
s = strings.ReplaceAll(s, targetPublic, replacePublic)
|
||||
|
||||
return []byte(s)
|
||||
}
|
||||
96
internal/pkg/validator/validator.go
Normal file
96
internal/pkg/validator/validator.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
validatorlib "github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// FieldError merepresentasikan 1 field DTO yang gagal validasi.
|
||||
// Field mengikuti nama di tag `json` DTO (bukan nama struct Go), supaya konsisten
|
||||
// dengan nama yang dikirim client di body request.
|
||||
type FieldError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// RegisterJSONTagNameFunc membuat validator Gin melaporkan error pakai nama tag `json`
|
||||
// dari DTO (misal "email", "password"), bukan nama struct Go (misal "Email", "Password").
|
||||
// WAJIB dipanggil sekali saat startup aplikasi (lihat internal/router/router.go),
|
||||
// sebelum ada request yang divalidasi.
|
||||
func RegisterJSONTagNameFunc() {
|
||||
v, ok := binding.Validator.Engine().(*validatorlib.Validate)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
v.RegisterTagNameFunc(func(field reflect.StructField) string {
|
||||
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "-" || name == "" {
|
||||
return field.Name
|
||||
}
|
||||
return name
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func TranslateError(err error) []FieldError {
|
||||
var validationErrs validatorlib.ValidationErrors
|
||||
if !errors.As(err, &validationErrs) {
|
||||
return []FieldError{{
|
||||
Field: "body",
|
||||
Message: "format request tidak valid, pastikan body berupa JSON yang benar",
|
||||
}}
|
||||
}
|
||||
|
||||
fieldErrors := make([]FieldError, 0, len(validationErrs))
|
||||
for _, fe := range validationErrs {
|
||||
fieldErrors = append(fieldErrors, FieldError{
|
||||
Field: fe.Field(),
|
||||
Message: buildMessage(fe),
|
||||
})
|
||||
}
|
||||
return fieldErrors
|
||||
}
|
||||
|
||||
// buildMessage menerjemahkan tag validasi (required, email, min, dst) jadi kalimat
|
||||
// Bahasa Indonesia yang jelas. Tambahkan case baru di sini kalau ada tag validasi baru
|
||||
// yang dipakai di DTO tapi belum ada terjemahannya.
|
||||
func buildMessage(fe validatorlib.FieldError) string {
|
||||
field := fe.Field()
|
||||
|
||||
switch fe.Tag() {
|
||||
case "required":
|
||||
return fmt.Sprintf("%s wajib diisi", field)
|
||||
case "email":
|
||||
return fmt.Sprintf("%s harus berupa alamat email yang valid", field)
|
||||
case "min":
|
||||
if fe.Kind() == reflect.String {
|
||||
return fmt.Sprintf("%s minimal %s karakter", field, fe.Param())
|
||||
}
|
||||
return fmt.Sprintf("%s minimal bernilai %s", field, fe.Param())
|
||||
case "max":
|
||||
if fe.Kind() == reflect.String {
|
||||
return fmt.Sprintf("%s maksimal %s karakter", field, fe.Param())
|
||||
}
|
||||
return fmt.Sprintf("%s maksimal bernilai %s", field, fe.Param())
|
||||
case "gte":
|
||||
return fmt.Sprintf("%s minimal bernilai %s", field, fe.Param())
|
||||
case "lte":
|
||||
return fmt.Sprintf("%s maksimal bernilai %s", field, fe.Param())
|
||||
case "oneof":
|
||||
return fmt.Sprintf("%s harus salah satu dari: %s", field, fe.Param())
|
||||
case "len":
|
||||
return fmt.Sprintf("%s harus tepat %s karakter", field, fe.Param())
|
||||
case "numeric":
|
||||
return fmt.Sprintf("%s harus berupa angka", field)
|
||||
case "alphanum":
|
||||
return fmt.Sprintf("%s hanya boleh berisi huruf dan angka", field)
|
||||
default:
|
||||
return fmt.Sprintf("%s tidak valid", field)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user