initial commit

This commit is contained in:
Hafizh A. Y
2025-09-25 10:46:46 +07:00
parent c43544e5e8
commit 10506238ae
64 changed files with 3564 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
package config
import (
"os"
"time"
)
type AuthCfg struct {
Issuer string
JWTSecret string
AccessTTL time.Duration
RefreshTTL time.Duration
RefreshCookieName string
RefreshCookiePath string
}
func LoadAuth() AuthCfg {
return AuthCfg{
JWTSecret: getenv("JWT_SECRET", "dev-secret-change-me"),
AccessTTL: getenvDuration("ACCESS_TTL", 10*time.Minute),
RefreshTTL: getenvDuration("REFRESH_TTL", 30*24*time.Hour),
RefreshCookieName: getenv("REFRESH_COOKIE_NAME", "rt"),
RefreshCookiePath: getenv("REFRESH_COOKIE_PATH", "/api/auth"),
Issuer: getenv("ISSUER", "http://localhost:8080"),
}
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func getenvDuration(k string, def time.Duration) time.Duration {
if v := os.Getenv(k); v != "" {
d, err := time.ParseDuration(v)
if err == nil {
return d
}
}
return def
}
+111
View File
@@ -0,0 +1,111 @@
package config
import (
"fmt"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/utils"
"github.com/spf13/viper"
)
var (
IsProd bool
AppHost string
Version string
LogLevel string
AppPort int
DBHost string
DBUser string
DBPassword string
DBName string
DBPort int
JWTSecret string
JWTAccessExp int
JWTRefreshExp int
JWTResetPasswordExp int
JWTVerifyEmailExp int
PostgresDSN string
RedisURL string
Issuer string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
EmailFrom string
GoogleClientID string
GoogleClientSecret string
RedirectURL string
)
func init() {
loadConfig()
// server configuration
IsProd = viper.GetString("APP_ENV") == "prod"
// AppHost = viper.GetString("APP_HOST")
// AppPort = viper.GetInt("APP_PORT")
AppHost = viper.GetString("APP_HOST")
if AppHost == "" {
AppHost = "0.0.0.0"
}
AppPort = viper.GetInt("APP_PORT")
if AppPort == 0 {
AppPort = 8080
}
Version = viper.GetString("VERSION")
LogLevel = viper.GetString("LOG_LEVEL")
// database configuration
DBHost = viper.GetString("DB_HOST")
DBUser = viper.GetString("DB_USER")
DBPassword = viper.GetString("DB_PASSWORD")
DBName = viper.GetString("DB_NAME")
DBPort = viper.GetInt("DB_PORT")
PostgresDSN = viper.GetString("POSTGRES_DSN")
if PostgresDSN == "" {
PostgresDSN = fmt.Sprintf(
"postgres://%s:%s@%s:%d/%s?sslmode=disable",
DBUser, DBPassword, DBHost, DBPort, DBName,
)
}
// jwt configuration
JWTSecret = viper.GetString("JWT_SECRET")
JWTAccessExp = viper.GetInt("JWT_ACCESS_EXP_MINUTES")
JWTRefreshExp = viper.GetInt("JWT_REFRESH_EXP_DAYS")
JWTResetPasswordExp = viper.GetInt("JWT_RESET_PASSWORD_EXP_MINUTES")
JWTVerifyEmailExp = viper.GetInt("JWT_VERIFY_EMAIL_EXP_MINUTES")
// Redis / OIDC
RedisURL = viper.GetString("REDIS_URL")
if RedisURL == "" {
RedisURL = "redis://redis:6379/0"
}
Issuer = viper.GetString("ISSUER")
if Issuer == "" {
// fallback ke SSO_ISSUER jika kamu sudah pakai itu sebelumnya
Issuer = viper.GetString("SSO_ISSUER")
}
// SMTP configuration
SMTPHost = viper.GetString("SMTP_HOST")
SMTPPort = viper.GetInt("SMTP_PORT")
SMTPUsername = viper.GetString("SMTP_USERNAME")
SMTPPassword = viper.GetString("SMTP_PASSWORD")
EmailFrom = viper.GetString("EMAIL_FROM")
// oauth2 configuration
GoogleClientID = viper.GetString("GOOGLE_CLIENT_ID")
GoogleClientSecret = viper.GetString("GOOGLE_CLIENT_SECRET")
RedirectURL = viper.GetString("REDIRECT_URL")
}
func loadConfig() {
viper.AutomaticEnv()
viper.SetConfigFile(".env")
if err := viper.ReadInConfig(); err == nil {
utils.Log.Info("Config file loaded from .env")
} else {
utils.Log.Warn("No .env file found, using environment variables only")
}
}
+20
View File
@@ -0,0 +1,20 @@
package config
import (
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/utils"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
)
func FiberConfig() fiber.Config {
return fiber.Config{
Prefork: IsProd,
CaseSensitive: true,
ServerHeader: "Fiber",
AppName: "Fiber API",
ErrorHandler: utils.ErrorHandler,
JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal,
}
}
+27
View File
@@ -0,0 +1,27 @@
package config
import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
type Config struct {
GoogleLoginConfig oauth2.Config
}
var AppConfig Config
func GoogleConfig() oauth2.Config {
AppConfig.GoogleLoginConfig = oauth2.Config{
RedirectURL: RedirectURL,
ClientID: GoogleClientID,
ClientSecret: GoogleClientSecret,
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
return AppConfig.GoogleLoginConfig
}
+17
View File
@@ -0,0 +1,17 @@
package config
var allRoles = map[string][]string{
"user": {},
"admin": {"getUsers", "manageUsers"},
}
var Roles = getKeys(allRoles)
var RoleRights = allRoles
func getKeys(m map[string][]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
+8
View File
@@ -0,0 +1,8 @@
package config
const (
TokenTypeAccess = "access"
TokenTypeRefresh = "refresh"
TokenTypeResetPassword = "resetPassword"
TokenTypeVerifyEmail = "verifyEmail"
)
+42
View File
@@ -0,0 +1,42 @@
package database
import (
"fmt"
"time"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/config"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/utils"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func Connect(dbHost, dbName string) *gorm.DB {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=Asia/Shanghai",
dbHost, config.DBUser, config.DBPassword, dbName, config.DBPort,
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
SkipDefaultTransaction: true,
PrepareStmt: true,
TranslateError: true,
})
if err != nil {
utils.Log.Errorf("Failed to connect to database: %+v", err)
}
sqlDB, errDB := db.DB()
if errDB != nil {
utils.Log.Errorf("Failed to connect to database: %+v", errDB)
}
// Config connection pooling
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(60 * time.Minute)
return db
}
+1
View File
@@ -0,0 +1 @@
CREATE DATABASE IF NOT EXISTS db_lti_erp;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS users;
@@ -0,0 +1,8 @@
-- Users
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
+30
View File
@@ -0,0 +1,30 @@
package seed
import (
"fmt"
mUser "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/models"
"gorm.io/gorm"
)
func Run(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// pw, err := secure.Hash("asdasdasd", nil)
// if err != nil {
// return err
// }
// ===== Users (user) =====
user := mUser.User{
Name: "Super Admin",
}
if err := tx.Where("email = ?", user.Id).FirstOrCreate(&user).Error; err != nil {
return err
}
fmt.Println("✅ Seeder successfully")
return nil
})
}
+57
View File
@@ -0,0 +1,57 @@
package middleware
import (
"strings"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/config"
service "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/services"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/utils"
"github.com/gofiber/fiber/v2"
)
func Auth(userService service.UserService, requiredRights ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
if token == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
userID, err := utils.VerifyToken(token, config.JWTSecret, config.TokenTypeAccess)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
user, err := userService.GetOne(c, userID)
if err != nil || user == nil {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
c.Locals("user", user)
// if len(requiredRights) > 0 {
// userRights, hasRights := config.RoleRights[user.Role]
// if (!hasRights || !hasAllRights(userRights, requiredRights)) && c.Params("userId") != userID {
// return fiber.NewError(fiber.StatusForbidden, "You don't have permission to access this resource")
// }
// }
return c.Next()
}
}
// func hasAllRights(userRights, requiredRights []string) bool {
// rightSet := make(map[string]struct{}, len(userRights))
// for _, right := range userRights {
// rightSet[right] = struct{}{}
// }
// for _, right := range requiredRights {
// if _, exists := rightSet[right]; !exists {
// return false
// }
// }
// return true
// }
+12
View File
@@ -0,0 +1,12 @@
package middleware
import (
jwtware "github.com/gofiber/contrib/jwt"
"github.com/gofiber/fiber/v2"
)
func JwtConfig() fiber.Handler {
return jwtware.New(jwtware.Config{
SigningKey: jwtware.SigningKey{Key: []byte("secret")},
})
}
+26
View File
@@ -0,0 +1,26 @@
package middleware
import (
"time"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/response"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/limiter"
)
func LimiterConfig() fiber.Handler {
return limiter.New(limiter.Config{
Max: 20,
Expiration: 15 * time.Minute,
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).
JSON(response.Common{
Code: fiber.StatusTooManyRequests,
Status: "error",
Message: "Too many requests, please try again later",
})
},
SkipSuccessfulRequests: true,
})
}
+13
View File
@@ -0,0 +1,13 @@
package middleware
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
)
func LoggerConfig() fiber.Handler {
return logger.New(logger.Config{
Format: "${time} ${method} ${status} ${path} in ${latency}\n",
TimeFormat: "15:04:05.00",
})
}
+12
View File
@@ -0,0 +1,12 @@
package middleware
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/recover"
)
func RecoverConfig() fiber.Handler {
return recover.New(recover.Config{
EnableStackTrace: true,
})
}
+11
View File
@@ -0,0 +1,11 @@
package modules
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type Module interface {
RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate)
}
@@ -0,0 +1,140 @@
package controller
import (
"math"
"strconv"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/dto"
service "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/services"
validation "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/validations"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type UserController struct {
UserService service.UserService
}
func NewUserController(userService service.UserService) *UserController {
return &UserController{
UserService: userService,
}
}
func (u *UserController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
result, totalResults, err := u.UserService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.UserListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all users successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToUserListDTOs(result),
})
}
func (u *UserController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.UserService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get user successfully",
Data: dto.ToUserListDTO(*result),
})
}
func (u *UserController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.UserService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create user successfully",
Data: dto.ToUserListDTO(*result),
})
}
func (u *UserController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.UserService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update user successfully",
Data: dto.ToUserListDTO(*result),
})
}
func (u *UserController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.UserService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete user successfully",
})
}
+37
View File
@@ -0,0 +1,37 @@
package dto
import (
"time"
model "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/models"
)
// === DTO Structs ===
type UserListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserDetailDTO struct {
UserListDTO
}
// === Mapper Functions ===
func ToUserListDTO(m model.User) UserListDTO {
return UserListDTO{
Id: m.Id,
Name: m.Name,
}
}
func ToUserListDTOs(m []model.User) []UserListDTO {
result := make([]UserListDTO, len(m))
for i, r := range m {
result[i] = ToUserListDTO(r)
}
return result
}
@@ -0,0 +1,15 @@
package model
import (
"time"
"gorm.io/gorm"
)
type User struct {
Id uint `gorm:"primaryKey"`
Name string `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
+20
View File
@@ -0,0 +1,20 @@
package users
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rUser "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/repositories"
sUser "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/services"
)
type UserModule struct{}
func (UserModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
userRepo := rUser.NewUserRepository(db)
userService := sUser.NewUserService(userRepo, validate)
UserRoutes(router, userService)
}
@@ -0,0 +1,22 @@
package repository
import (
model "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/models"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/repository"
"gorm.io/gorm"
)
type UserRepository interface {
repository.BaseRepository[model.User]
}
type UserRepositoryImpl struct {
*repository.BaseRepositoryImpl[model.User]
}
func NewUserRepository(db *gorm.DB) UserRepository {
return &UserRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[model.User](db),
}
}
+20
View File
@@ -0,0 +1,20 @@
package users
import (
controller "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/controllers"
user "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func UserRoutes(v1 fiber.Router, s user.UserService) {
ctrl := controller.NewUserController(s)
route := v1.Group("/users")
route.Get("/", ctrl.GetAll)
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne)
}
@@ -0,0 +1,119 @@
package service
import (
"errors"
model "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/models"
repository "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/repositories"
validation "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/validations"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type UserService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]model.User, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*model.User, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*model.User, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*model.User, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
}
type userService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.UserRepository
}
func NewUserService(repo repository.UserRepository, validate *validator.Validate) UserService {
return &userService{
Log: utils.Log,
Validate: validate,
Repository: repo,
}
}
func (s userService) GetAll(c *fiber.Ctx, params *validation.Query) ([]model.User, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
users, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
if params.Search != "" {
return db.Where("name LIKE ?", "%"+params.Search+"%")
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get users: %+v", err)
return nil, 0, err
}
return users, total, nil
}
func (s userService) GetOne(c *fiber.Ctx, id uint) (*model.User, error) {
user, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "User not found")
}
if err != nil {
s.Log.Errorf("Failed get user by id: %+v", err)
return nil, err
}
return user, nil
}
func (s *userService) CreateOne(c *fiber.Ctx, req *validation.Create) (*model.User, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
createBody := &model.User{
Name: req.Name,
}
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil {
s.Log.Errorf("Failed to create user: %+v", err)
return nil, err
}
return createBody, nil
}
func (s userService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*model.User, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.Name != nil {
updateBody["name"] = *req.Name
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "User not found")
}
s.Log.Errorf("Failed to update user: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func (s userService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "User not found")
}
s.Log.Errorf("Failed to delete user: %+v", err)
return err
}
return nil
}
@@ -0,0 +1,15 @@
package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty,max=50"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty,max=50"`
}
+242
View File
@@ -0,0 +1,242 @@
package repository
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type BaseRepository[T any] interface {
GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]T, int64, error)
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*T, error)
GetByIDs(ctx context.Context, ids []uint, modifier func(*gorm.DB) *gorm.DB) ([]T, error)
CreateOne(ctx context.Context, entity *T, modifier func(*gorm.DB) *gorm.DB) error
CreateMany(ctx context.Context, entities []*T, modifier func(*gorm.DB) *gorm.DB) error
UpdateOne(ctx context.Context, id uint, entity *T, modifier func(*gorm.DB) *gorm.DB) error
UpdateMany(ctx context.Context, entities []*T, modifier func(*gorm.DB) *gorm.DB) error
PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error
DeleteOne(ctx context.Context, id uint) error
DeleteMany(ctx context.Context, modifier func(*gorm.DB) *gorm.DB) error
Upsert(ctx context.Context, entity *T, conflictColumns []clause.Column, modifier func(*gorm.DB) *gorm.DB) error
WithTx(tx *gorm.DB) BaseRepository[T]
DB() *gorm.DB
}
type BaseRepositoryImpl[T any] struct {
db *gorm.DB
}
func NewBaseRepository[T any](db *gorm.DB) *BaseRepositoryImpl[T] {
return &BaseRepositoryImpl[T]{db: db}
}
func (r *BaseRepositoryImpl[T]) GetAll(
ctx context.Context,
offset, limit int,
modifier func(*gorm.DB) *gorm.DB,
) ([]T, int64, error) {
var entities []T
var total int64
q := r.db.WithContext(ctx).Model(new(T))
if modifier != nil {
q = modifier(q)
}
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := q.Offset(offset).Limit(limit).Find(&entities).Error; err != nil {
return nil, 0, err
}
return entities, total, nil
}
func (r *BaseRepositoryImpl[T]) GetByID(
ctx context.Context,
id uint,
modifier func(*gorm.DB) *gorm.DB,
) (*T, error) {
entity := new(T)
q := r.db.WithContext(ctx)
if modifier != nil {
q = modifier(q)
}
if err := q.First(entity, id).Error; err != nil {
return nil, err
}
return entity, nil
}
func (r *BaseRepositoryImpl[T]) GetByIDs(
ctx context.Context,
ids []uint,
modifier func(*gorm.DB) *gorm.DB,
) ([]T, error) {
var entities []T
q := r.db.WithContext(ctx).Model(new(T))
if modifier != nil {
q = modifier(q)
}
if err := q.Where("id IN ?", ids).Find(&entities).Error; err != nil {
return nil, err
}
if len(entities) == 0 {
return nil, gorm.ErrRecordNotFound
}
return entities, nil
}
// ---- CREATE ----
func (r *BaseRepositoryImpl[T]) CreateOne(
ctx context.Context,
entity *T,
modifier func(*gorm.DB) *gorm.DB,
) error {
q := r.db.WithContext(ctx)
if modifier != nil {
q = modifier(q)
}
return q.Create(entity).Error
}
func (r *BaseRepositoryImpl[T]) CreateMany(
ctx context.Context,
entities []*T,
modifier func(*gorm.DB) *gorm.DB,
) error {
q := r.db.WithContext(ctx)
if modifier != nil {
q = modifier(q)
}
return q.Create(&entities).Error
}
// ---- UPDATE ----
func (r *BaseRepositoryImpl[T]) UpdateOne(
ctx context.Context,
id uint,
entity *T,
modifier func(*gorm.DB) *gorm.DB,
) error {
q := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id)
if modifier != nil {
q = modifier(q)
}
result := q.Updates(entity)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *BaseRepositoryImpl[T]) UpdateMany(
ctx context.Context,
entities []*T,
modifier func(*gorm.DB) *gorm.DB,
) error {
q := r.db.WithContext(ctx)
if modifier != nil {
q = modifier(q)
}
result := q.Save(&entities)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *BaseRepositoryImpl[T]) PatchOne(
ctx context.Context,
id uint,
updates map[string]any,
modifier func(*gorm.DB) *gorm.DB,
) error {
q := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id)
if modifier != nil {
q = modifier(q)
}
result := q.Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// ---- DELETE ----
func (r *BaseRepositoryImpl[T]) DeleteOne(ctx context.Context, id uint) error {
result := r.db.WithContext(ctx).Delete(new(T), id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *BaseRepositoryImpl[T]) DeleteMany(ctx context.Context, modifier func(*gorm.DB) *gorm.DB) error {
q := r.db.WithContext(ctx).Model(new(T))
if modifier != nil {
q = modifier(q)
}
result := q.Delete(new(T))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// ---- UPSERT ----
func (r *BaseRepositoryImpl[T]) Upsert(
ctx context.Context,
entity *T,
conflictColumns []clause.Column,
modifier func(*gorm.DB) *gorm.DB,
) error {
q := r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: conflictColumns,
UpdateAll: true,
})
if modifier != nil {
q = modifier(q)
}
return q.Create(entity).Error
}
func (r *BaseRepositoryImpl[T]) WithTx(tx *gorm.DB) BaseRepository[T] {
return &BaseRepositoryImpl[T]{db: tx}
}
func (r *BaseRepositoryImpl[T]) DB() *gorm.DB {
return r.db
}
+30
View File
@@ -0,0 +1,30 @@
package response
import (
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
)
func Error(c *fiber.Ctx, statusCode int, message string, details interface{}) error {
var errRes error
if details != nil {
errRes = c.Status(statusCode).JSON(ErrorDetails{
Code: statusCode,
Status: "error",
Message: message,
Errors: details,
})
} else {
errRes = c.Status(statusCode).JSON(Common{
Code: statusCode,
Status: "error",
Message: message,
})
}
if errRes != nil {
logrus.Errorf("Failed to send error response : %+v", errRes)
}
return errRes
}
+36
View File
@@ -0,0 +1,36 @@
package response
type Common struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
}
type Success struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
type Meta struct {
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int64 `json:"total_pages"`
TotalResults int64 `json:"total_results"`
}
type SuccessWithPaginate[T any] struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Meta Meta `json:"meta"`
Data []T `json:"data"`
}
type ErrorDetails struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Errors interface{} `json:"errors"`
}
+31
View File
@@ -0,0 +1,31 @@
package route
import (
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/validation"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
users "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users"
// MODULE IMPORTS
)
func Routes(app *fiber.App, db *gorm.DB) {
validate := validation.Validator()
api := app.Group("/api")
// masterRoute.Routes(api, db)
// root modules di sini
allModules := []modules.Module{
users.UserModule{},
// MODULE REGISTRY
}
// daftarkan root modules
for _, m := range allModules {
m.RegisterRoutes(api, db, validate)
}
}
+30
View File
@@ -0,0 +1,30 @@
package utils
// -------------------------------------------------------------------
// FlagType
// -------------------------------------------------------------------
type FlagType string
const (
FlagIsActive FlagType = "IS_ACTIVE"
)
// -------------------------------------------------------------------
// Validators
// -------------------------------------------------------------------
func IsValidFlagType(v string) bool {
switch FlagType(v) {
case FlagIsActive:
return true
}
return false
}
// example use
/**
if !utils.IsValidFlagType(req.FlagName) {
return fiber.NewError(fiber.StatusBadRequest, "invalid flag type")
}
*/
+27
View File
@@ -0,0 +1,27 @@
package utils
import (
"errors"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/response"
"github.com/hafizhproject45/Golang-Boilerplate.git/internal/validation"
"github.com/gofiber/fiber/v2"
)
func ErrorHandler(c *fiber.Ctx, err error) error {
if errorsMap := validation.CustomErrorMessages(err); len(errorsMap) > 0 {
return response.Error(c, fiber.StatusBadRequest, "Bad Request", errorsMap)
}
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
return response.Error(c, fiberErr.Code, fiberErr.Message, nil)
}
return response.Error(c, fiber.StatusInternalServerError, "Internal Server Error", nil)
}
func NotFoundHandler(c *fiber.Ctx) error {
return response.Error(c, fiber.StatusNotFound, "Endpoint Not Found", nil)
}
+28
View File
@@ -0,0 +1,28 @@
package utils
import (
"os"
"github.com/sirupsen/logrus"
)
type CustomFormatter struct {
logrus.TextFormatter
}
var Log *logrus.Logger
func init() {
Log = logrus.New()
// Set logger to use the custom text formatter
Log.SetFormatter(&CustomFormatter{
TextFormatter: logrus.TextFormatter{
TimestampFormat: "15:04:05.000",
FullTimestamp: true,
ForceColors: true,
},
})
Log.SetOutput(os.Stdout)
}
+22
View File
@@ -0,0 +1,22 @@
package utils
import "encoding/json"
type NullString struct {
Set bool
Value *string
}
func (ns *NullString) UnmarshalJSON(b []byte) error {
ns.Set = true
if string(b) == "null" {
ns.Value = nil
return nil
}
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
ns.Value = &s
return nil
}
+59
View File
@@ -0,0 +1,59 @@
package secure
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
type Params struct {
Memory uint32
Time uint32
Threads uint8
SaltLen uint32
KeyLen uint32
}
var Default = &Params{
Memory: 64 * 1024,
Time: 3,
Threads: 2,
SaltLen: 16,
KeyLen: 32,
}
func Hash(plain string, p *Params) (string, error) {
if strings.TrimSpace(plain) == "" {
return "", errors.New("empty password")
}
if p == nil { p = Default }
salt := make([]byte, p.SaltLen)
if _, err := rand.Read(salt); err != nil { return "", err }
key := argon2.IDKey([]byte(plain), salt, p.Time, p.Memory, p.Threads, p.KeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
p.Memory, p.Time, p.Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
func Verify(encoded, plain string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 6 { return false }
var m uint32; var t uint32; var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { return false }
salt, err := base64.RawStdEncoding.DecodeString(parts[4]); if err != nil { return false }
want, err := base64.RawStdEncoding.DecodeString(parts[5]); if err != nil { return false }
got := argon2.IDKey([]byte(plain), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(want, got) == 1
}
+13
View File
@@ -0,0 +1,13 @@
package secure
import (
"crypto/rand"
"encoding/base64"
)
func RandomToken(n int) (string, error) {
// n = bytes, 32 → 256-bit
b := make([]byte, n)
if _, err := rand.Read(b); err != nil { return "", err }
return base64.RawURLEncoding.EncodeToString(b), nil
}
+11
View File
@@ -0,0 +1,11 @@
package secure
import (
"crypto/sha256"
"encoding/hex"
)
func SHA256Hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
+45
View File
@@ -0,0 +1,45 @@
package utils
import (
"errors"
"strconv"
"github.com/golang-jwt/jwt/v5"
)
func VerifyToken(tokenStr, secret, tokenType string) (uint, error) {
token, err := jwt.Parse(tokenStr, func(_ *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
return 0, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return 0, errors.New("invalid token claims")
}
jwtType, ok := claims["type"].(string)
if !ok || jwtType != tokenType {
return 0, errors.New("invalid token type")
}
sub, ok := claims["sub"]
if !ok {
return 0, errors.New("invalid token sub")
}
switch v := sub.(type) {
case float64:
return uint(v), nil
case string:
id, err := strconv.Atoi(v)
if err != nil {
return 0, errors.New("invalid sub format")
}
return uint(id), nil
default:
return 0, errors.New("unsupported sub type")
}
}
+83
View File
@@ -0,0 +1,83 @@
package validation
import (
"reflect"
"regexp"
"strings"
"github.com/go-playground/validator/v10"
)
var (
reUpper = regexp.MustCompile(`[A-Z]`)
reLower = regexp.MustCompile(`[a-z]`)
reDigit = regexp.MustCompile(`[0-9]`)
reSym = regexp.MustCompile(`[^A-Za-z0-9]`)
)
func Password(fl validator.FieldLevel) bool {
pw := fl.Field().String()
pw = strings.TrimSpace(pw)
if len(pw) < 8 {
return false
}
if !reUpper.MatchString(pw) {
return false
}
if !reLower.MatchString(pw) {
return false
}
if !reDigit.MatchString(pw) {
return false
}
if !reSym.MatchString(pw) {
return false
}
if strings.Contains(pw, " ") {
return false
}
parent := fl.Parent()
if parent.IsValid() && parent.Kind() == reflect.Struct {
emailField := parent.FieldByName("Email")
if emailField.IsValid() && emailField.Kind() == reflect.String {
if email := emailField.String(); email != "" {
if i := strings.IndexByte(email, '@'); i > 0 {
local := strings.ToLower(email[:i])
if local != "" && strings.Contains(strings.ToLower(pw), local) {
return false
}
}
}
}
}
return true
}
func RequiredStrict(fl validator.FieldLevel) bool {
field := fl.Field()
switch field.Kind() {
case reflect.String:
return field.String() != ""
case reflect.Ptr:
return !field.IsNil()
}
return field.IsValid() && !field.IsZero()
}
func OmitemptyStrict(fl validator.FieldLevel) bool {
field := fl.Field()
if !field.IsValid() || field.IsZero() {
return true
}
if field.Kind() == reflect.String {
return field.String() != ""
}
return true
}
+75
View File
@@ -0,0 +1,75 @@
package validation
import (
"errors"
"fmt"
"github.com/go-playground/validator/v10"
)
var customMessages = map[string]string{
"required": "Field %s is required",
"required_strict": "Field %s is required and cannot be null or empty",
"omitempty_strict": "Field %s cannot be null or empty when provided",
"email": "Invalid email address for field %s",
"min": "Field %s must have a minimum length of %s characters",
"max": "Field %s must have a maximum length of %s characters",
"len": "Field %s must be exactly %s characters long",
"number": "Field %s must be a number",
"positive": "Field %s must be a positive number",
"alphanum": "Field %s must contain only alphanumeric characters",
"oneof": "Invalid value for field %s",
"password": "Field %s must be at least 8 characters, contain uppercase, lowercase, number, and special character",
}
func CustomErrorMessages(err error) map[string]string {
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
return generateErrorMessages(validationErrors)
}
return nil
}
func generateErrorMessages(validationErrors validator.ValidationErrors) map[string]string {
errorsMap := make(map[string]string)
for _, err := range validationErrors {
fieldName := err.StructNamespace()
tag := err.Tag()
customMessage := customMessages[tag]
if customMessage != "" {
errorsMap[fieldName] = formatErrorMessage(customMessage, err, tag)
} else {
errorsMap[fieldName] = defaultErrorMessage(err)
}
}
return errorsMap
}
func formatErrorMessage(customMessage string, err validator.FieldError, tag string) string {
if tag == "min" || tag == "max" || tag == "len" {
return fmt.Sprintf(customMessage, err.Field(), err.Param())
}
return fmt.Sprintf(customMessage, err.Field())
}
func defaultErrorMessage(err validator.FieldError) string {
return fmt.Sprintf("Field validation for '%s' failed on the '%s' tag", err.Field(), err.Tag())
}
func Validator() *validator.Validate {
validate := validator.New()
if err := validate.RegisterValidation("password", Password); err != nil {
return nil
}
if err := validate.RegisterValidation("required_strict", RequiredStrict); err != nil {
return nil
}
if err := validate.RegisterValidation("omitempty_strict", OmitemptyStrict); err != nil {
return nil
}
return validate
}