diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..dc7f0bb3 Binary files /dev/null and b/.DS_Store differ diff --git a/.air.toml b/.air.toml new file mode 100644 index 00000000..0c534172 --- /dev/null +++ b/.air.toml @@ -0,0 +1,13 @@ +# .air.toml +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/main ./cmd/api" +bin = "tmp/main" +full_bin = "APP_ENV=dev ./tmp/main" +include_ext = ["go", "tpl", "tmpl", "html"] +exclude_dir = ["vendor", "tmp"] + +[log] +time = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b0f03952 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,40 @@ +# Git & CI +.git +.gitignore +.github + +# Build artifacts +/tmp +/bin +/out +*.exe +*.dll +*.so +*.dylib +*.test +*.out + +# Go specific +*.exe +*.test +*.prof +*.log + +# Dependencies cache (biar tidak kebawa) +vendor/ +go-build-cache/ +go-mod-cache/ + +# Editor/IDE +.vscode +.idea +*.swp + +# Env & secrets +.env +.env.* +!/.env.example + +# Docker sendiri +Dockerfile* +docker-compose*.yml diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..cb2c0623 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# server configuration +# Env value : prod || dev +APP_ENV=dev +APP_HOST=0.0.0.0 +APP_PORT=8080 +APP_URL=http://localhost:8080 + +# database configuration +DB_HOST=postgresdb +DB_USER=postgres +DB_PASSWORD=changeme +DB_NAME=db_lti_erp +DB_PORT=5432 + +# JWT +# JWT secret key +JWT_SECRET=changeme +# Number of minutes after which an access token expires +JWT_ACCESS_EXP_MINUTES=30 +# Number of days after which a refresh token expires +JWT_REFRESH_EXP_DAYS=30 +# Number of minutes after which a reset password token expires +JWT_RESET_PASSWORD_EXP_MINUTES=10 +# Number of minutes after which a verify email token expires +JWT_VERIFY_EMAIL_EXP_MINUTES=10 + +# SMTP configuration options for the email service +SMTP_HOST=email-server +SMTP_PORT=587 +SMTP_USERNAME=email-server-username +SMTP_PASSWORD=changeme +EMAIL_FROM=support@yourapp.com + +# OAuth2 configuration +GOOGLE_CLIENT_ID=yourapps.googleusercontent.com +GOOGLE_CLIENT_SECRET=changeme +REDIRECT_URL=http://localhost:3000/v1/auth/google-callback diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..57cc0755 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Environment variables +.env + +# Air temp dir +tmp/ + +# Binaries +main +bin/ +*.exe +*.out + +# Logs & reports +*.log +*.txt +coverage/ + +# IDE / editor files +.vscode/ +.idea/ +*.swp diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..01781016 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,346 @@ +# ## Config for golangci-lint v1.60.1 + +# run: +# # Timeout for analysis, e.g. 30s, 5m. +# # Default: 1m +# timeout: 3m + +# # This file contains only configs which differ from defaults. +# # All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml +# linters-settings: +# cyclop: +# # The maximal code complexity to report. +# # Default: 10 +# max-complexity: 30 +# # The maximal average package complexity. +# # If it's higher than 0.0 (float) the check is enabled +# # Default: 0.0 +# package-average: 10.0 + +# errcheck: +# # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. +# # Such cases aren't reported by default. +# # Default: false +# check-type-assertions: true + +# exhaustive: +# # Program elements to check for exhaustiveness. +# # Default: [ switch ] +# check: +# - switch +# - map + +# exhaustruct: +# # List of regular expressions to exclude struct packages and their names from checks. +# # Regular expressions must match complete canonical struct package/name/structname. +# # Default: [] +# exclude: +# # std libs +# - "^net/http.Client$" +# - "^net/http.Cookie$" +# - "^net/http.Request$" +# - "^net/http.Response$" +# - "^net/http.Server$" +# - "^net/http.Transport$" +# - "^net/url.URL$" +# - "^os/exec.Cmd$" +# - "^reflect.StructField$" +# # public libs +# - "^github.com/Shopify/sarama.Config$" +# - "^github.com/Shopify/sarama.ProducerMessage$" +# - "^github.com/mitchellh/mapstructure.DecoderConfig$" +# - "^github.com/prometheus/client_golang/.+Opts$" +# - "^github.com/spf13/cobra.Command$" +# - "^github.com/spf13/cobra.CompletionOptions$" +# - "^github.com/stretchr/testify/mock.Mock$" +# - "^github.com/testcontainers/testcontainers-go.+Request$" +# - "^github.com/testcontainers/testcontainers-go.FromDockerfile$" +# - "^golang.org/x/tools/go/analysis.Analyzer$" +# - "^google.golang.org/protobuf/.+Options$" +# - "^gopkg.in/yaml.v3.Node$" + +# funlen: +# # Checks the number of lines in a function. +# # If lower than 0, disable the check. +# # Default: 60 +# lines: 100 +# # Checks the number of statements in a function. +# # If lower than 0, disable the check. +# # Default: 40 +# statements: 50 +# # Ignore comments when counting lines. +# # Default false +# ignore-comments: true + +# gocognit: +# # Minimal code complexity to report. +# # Default: 30 (but we recommend 10-20) +# min-complexity: 20 + +# gocritic: +# # Settings passed to gocritic. +# # The settings key is the name of a supported gocritic checker. +# # The list of supported checkers can be find in https://go-critic.github.io/overview. +# settings: +# captLocal: +# # Whether to restrict checker to params only. +# # Default: true +# paramsOnly: false +# underef: +# # Whether to skip (*x).method() calls where x is a pointer receiver. +# # Default: true +# skipRecvDeref: false + +# gomodguard: +# blocked: +# # List of blocked modules. +# # Default: [] +# modules: +# - github.com/golang/protobuf: +# recommendations: +# - google.golang.org/protobuf +# reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" +# - github.com/satori/go.uuid: +# recommendations: +# - github.com/google/uuid +# reason: "satori's package is not maintained" +# - github.com/gofrs/uuid: +# recommendations: +# - github.com/gofrs/uuid/v5 +# reason: "gofrs' package was not go module before v5" + +# govet: +# # Enable all analyzers. +# # Default: false +# enable-all: true +# # Disable analyzers by name. +# # Run `go tool vet help` to see all analyzers. +# # Default: [] +# disable: +# - fieldalignment # too strict +# # Settings per analyzer. +# settings: +# shadow: +# # Whether to be strict about shadowing; can be noisy. +# # Default: false +# strict: true + +# inamedparam: +# # Skips check for interface methods with only a single parameter. +# # Default: false +# skip-single-param: true + +# mnd: +# # List of function patterns to exclude from analysis. +# # Values always ignored: `time.Date`, +# # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, +# # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. +# # Default: [] +# ignored-functions: +# - args.Error +# - flag.Arg +# - flag.Duration.* +# - flag.Float.* +# - flag.Int.* +# - flag.Uint.* +# - os.Chmod +# - os.Mkdir.* +# - os.OpenFile +# - os.WriteFile +# - prometheus.ExponentialBuckets.* +# - prometheus.LinearBuckets + +# nakedret: +# # Make an issue if func has more lines of code than this setting, and it has naked returns. +# # Default: 30 +# max-func-lines: 0 + +# nolintlint: +# # Exclude following linters from requiring an explanation. +# # Default: [] +# allow-no-explanation: [funlen, gocognit, lll] +# # Enable to require an explanation of nonzero length after each nolint directive. +# # Default: false +# require-explanation: true +# # Enable to require nolint directives to mention the specific linter being suppressed. +# # Default: false +# require-specific: true + +# perfsprint: +# # Optimizes into strings concatenation. +# # Default: true +# strconcat: false + +# rowserrcheck: +# # database/sql is always checked +# # Default: [] +# packages: +# - github.com/jmoiron/sqlx + +# sloglint: +# # Enforce not using global loggers. +# # Values: +# # - "": disabled +# # - "all": report all global loggers +# # - "default": report only the default slog logger +# # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global +# # Default: "" +# no-global: "all" +# # Enforce using methods that accept a context. +# # Values: +# # - "": disabled +# # - "all": report all contextless calls +# # - "scope": report only if a context exists in the scope of the outermost function +# # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only +# # Default: "" +# context: "scope" + +# tenv: +# # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. +# # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. +# # Default: false +# all: true + +# linters: +# disable-all: true +# enable: +# ## enabled by default +# - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases +# - gosimple # specializes in simplifying a code +# - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string +# - ineffassign # detects when assignments to existing variables are not used +# - staticcheck # is a go vet on steroids, applying a ton of static analysis checks +# - typecheck # like the front-end of a Go compiler, parses and type-checks Go code +# - unused # checks for unused constants, variables, functions and types +# ## disabled by default +# - asasalint # checks for pass []any as any in variadic func(...any) +# - asciicheck # checks that your code does not contain non-ASCII identifiers +# - bidichk # checks for dangerous unicode character sequences +# - bodyclose # checks whether HTTP response body is closed successfully +# - canonicalheader # checks whether net/http.Header uses canonical header +# - cyclop # checks function and package cyclomatic complexity +# - dupl # tool for code clone detection +# - durationcheck # checks for two durations multiplied together +# - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error +# - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 +# - exhaustive # checks exhaustiveness of enum switch statements +# - fatcontext # detects nested contexts in loops +# - forbidigo # forbids identifiers +# - funlen # tool for detection of long functions +# - gocheckcompilerdirectives # validates go compiler directive comments (//go:) +# #- gochecknoglobals # checks that no global variables exist +# #- gochecknoinits # checks that no init functions are present in Go code +# - gochecksumtype # checks exhaustiveness on Go "sum types" +# - gocognit # computes and checks the cognitive complexity of functions +# - goconst # finds repeated strings that could be replaced by a constant +# - gocritic # provides diagnostics that check for bugs, performance and style issues +# - gocyclo # computes and checks the cyclomatic complexity of functions +# #- godot # checks if comments end in a period +# - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt +# - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod +# - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations +# - goprintffuncname # checks that printf-like functions are named with f at the end +# - gosec # inspects source code for security problems +# - intrange # finds places where for loops could make use of an integer range +# - lll # reports long lines +# - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) +# - makezero # finds slice declarations with non-zero initial length +# - mirror # reports wrong mirror patterns of bytes/strings usage +# #- mnd # detects magic numbers +# - musttag # enforces field tags in (un)marshaled structs +# - nakedret # finds naked returns in functions greater than a specified function length +# - nestif # reports deeply nested if statements +# - nilerr # finds the code that returns nil even if it checks that the error is not nil +# - nilnil # checks that there is no simultaneous return of nil error and an invalid value +# - noctx # finds sending http request without context.Context +# - nolintlint # reports ill-formed or insufficient nolint directives +# - nonamedreturns # reports all named returns +# - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL +# - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative +# - predeclared # finds code that shadows one of Go's predeclared identifiers +# - promlinter # checks Prometheus metrics naming via promlint +# - protogetter # reports direct reads from proto message fields when getters should be used +# - reassign # checks that package variables are not reassigned +# - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint +# - rowserrcheck # checks whether Err of rows is checked successfully +# - sloglint # ensure consistent code style when using log/slog +# - spancheck # checks for mistakes with OpenTelemetry/Census spans +# - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed +# - stylecheck # is a replacement for golint +# - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 +# - testableexamples # checks if examples are testable (have an expected output) +# #- testifylint # checks usage of github.com/stretchr/testify +# - testpackage # makes you use a separate _test package +# - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes +# - unconvert # removes unnecessary type conversions +# - unparam # reports unused function parameters +# - usestdlibvars # detects the possibility to use variables/constants from the Go standard library +# - wastedassign # finds wasted assignment statements +# - whitespace # detects leading and trailing whitespace + +# ## you may want to enable +# #- decorder # checks declaration order and count of types, constants, variables and functions +# #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized +# #- gci # controls golang package import order and makes it always deterministic +# #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega +# #- godox # detects FIXME, TODO and other comment keywords +# #- goheader # checks is file header matches to pattern +# #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters +# #- interfacebloat # checks the number of methods inside an interface +# #- ireturn # accept interfaces, return concrete types +# #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated +# #- tagalign # checks that struct tags are well aligned +# #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope +# #- wrapcheck # checks that errors returned from external packages are wrapped +# #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + +# ## disabled +# #- containedctx # detects struct contained context.Context field +# #- contextcheck # [too many false positives] checks the function whether use a non-inherited context +# #- copyloopvar # [not necessary from Go 1.22] detects places where loop variables are copied +# #- depguard # [replaced by gomodguard] checks if package imports are in a list of acceptable packages +# #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) +# #- dupword # [useless without config] checks for duplicate words in the source code +# #- err113 # [too strict] checks the errors handling expressions +# #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted +# #- execinquery # [deprecated] checks query string in Query function which reads your Go src files and warning it finds +# #- exportloopref # [not necessary from Go 1.22] checks for pointers to enclosing loop variables +# #- forcetypeassert # [replaced by errcheck] finds forced type assertions +# #- gofmt # [replaced by goimports] checks whether code was gofmt-ed +# #- gofumpt # [replaced by goimports, gofumports is not available yet] checks whether code was gofumpt-ed +# #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase +# #- grouper # analyzes expression groups +# #- importas # enforces consistent import aliases +# #- maintidx # measures the maintainability index of each function +# #- misspell # [useless] finds commonly misspelled English words in comments +# #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity +# #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test +# #- tagliatelle # checks the struct tags +# #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers +# #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines + +# issues: +# # Maximum count of issues with the same text. +# # Set to 0 to disable. +# # Default: 3 +# max-same-issues: 50 + +# exclude-rules: +# - source: "(noinspection|TODO)" +# linters: [godot] +# - source: "//noinspection" +# linters: [gocritic] +# - path: "example\\.go" +# linters: +# - lll +# - path: "_test\\.go" +# linters: +# - bodyclose +# - dupl +# - funlen +# - goconst +# - gosec +# - noctx +# - wrapcheck +# - lll +# - testpackage diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..1236b180 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,20 @@ +FROM golang:1.23-alpine + +# Install dependensi dasar +RUN apk add --no-cache git curl bash build-base + +# Install Air (pakai repo baru air-verse) +RUN go install github.com/air-verse/air@v1.52.3 + +WORKDIR /golang-boilerplate + +# Cache dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +EXPOSE 8080 + +CMD ["air", "-c", ".air.toml"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 00000000..d0315963 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,33 @@ +FROM golang:1.23-alpine AS builder + +# Install tools build +RUN apk add --no-cache git curl bash build-base + +WORKDIR /golang-boilerplate + +# Cache dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build binary dari entrypoint (cmd/api/main.go) +RUN CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/api + +FROM alpine:3.20 + +# Install tools +RUN apk add --no-cache curl + +WORKDIR /golang-boilerplate + +# Copy binary hasil build +COPY --from=builder /golang-boilerplate/app . + +# Copy file env example +COPY --from=builder /golang-boilerplate/.env.example .env + +EXPOSE 8080 + +CMD ["./app"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..754adf43 --- /dev/null +++ b/Makefile @@ -0,0 +1,120 @@ +# --- Load .env kalau ada, dan export ke shell child --- +ifneq (,$(wildcard .env)) +include .env +export +endif + +# --- Konfigurasi umum --- +COMPOSE ?= docker compose -f docker-compose.dev.yml +NETWORK ?= boilerplate-template_go-network +MIGRATE_IMAGE ?= migrate/migrate +MIGRATIONS_DIR := $(PWD)/internal/database/migrations + +# Fallback agar tetap jalan meski .env kosong +DB_HOST ?= postgresdb +DB_PORT ?= 5432 +DB_USER ?= postgres +DB_PASSWORD ?= postgres +DB_NAME ?= db_lti_erp + +DB_URL := postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable + +# Tunggu DB ready memakai pg_isready dari image postgres +WAIT_DB := docker run --rm --network $(NETWORK) postgres:alpine \ + sh -c 'until pg_isready -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d $(DB_NAME); do echo "waiting for postgres..."; sleep 1; done' + +# Default target +.DEFAULT_GOAL := start + +# --- Daftar phony targets --- +.PHONY: start build lint gen \ + db-up wait-db \ + migration-% migrate-up migrate-down migrate-fresh \ + seed \ + docker-dev docker-prod docker-down docker-nuke docker-cache psql + +# --- Go workflow --- +start: + @go run cmd/api/main.go + +build: + @go build -o tmp/app ./cmd/api + +lint: + @golangci-lint run + +# --- Compose / DB helpers --- +db-up: + @$(COMPOSE) up -d postgresdb + +wait-db: + @$(WAIT_DB) + +# --- Migration (pembuatan file) --- +# Contoh: make migration-create_users_table +# ":" akan diubah ke "_" (biar aman untuk nama file) +migration-%: + @migrate create -ext sql -dir internal/database/migrations $(subst :,_,$*) + +# --- Migration (apply via docker image 'migrate') --- +migrate-up: db-up wait-db + @docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ + $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" up + +# Contoh: +# make migrate-down step=2 → rollback 2 step +# make migrate-down → rollback semua + +migrate-down: db-up wait-db + @if [ -n "$(step)" ]; then \ + echo "⬇️ Migrating down $(step) step(s)..."; \ + docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ + $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" down $(step); \ + else \ + echo "⬇️ Migrating down ALL steps..."; \ + docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ + $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" down -all; \ + fi + +migrate-fresh: migrate-down migrate-up + @true + +# Pakai: make migrate-force v=20250917120000 +migrate-force: + @docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ + $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" force $(v) + + +# --- Seeder --- +seed: db-up wait-db + @$(COMPOSE) run --rm app go run cmd/seed/main.go + +# --- Docker orchestration convenience --- +docker-dev: + @$(COMPOSE) up --build -d + +docker-prod: + @docker compose -f docker-compose.prod.yml up --build -d + +docker-down: + @$(COMPOSE) down --remove-orphans + +# ⚠️ Akan menghapus container, images dan volumes. +docker-nuke: + @$(COMPOSE) down --rmi all --volumes --remove-orphans + +docker-cache: + @docker builder prune -f + +# --- PSQL shell ke DB di container --- +psql: db-up + @$(COMPOSE) exec -it postgresdb psql -U $(DB_USER) -d $(DB_NAME) + +# Single feature +# example: make gen feat=product-category + +# Sub feature +# make gen feat=master/area +gen: + @go run tools/gen.go $(feat) +# @goimports -w internal diff --git a/README.md b/README.md new file mode 100644 index 00000000..93527086 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Golang Boilerplate (golang-boilerplate) + +Boilerplate RESTful API, built with **Go, Fiber, GORM** and **PostgreSQL**. + +--- + +## 📦 Tech Stack + +- **Go + Fiber** — Web framework +- **GORM** — ORM for PostgreSQL +- **PostgreSQL** — Relational database +- **go-playground/validator** — Input validation +- **JWT** — Authentication +- **Logrus** — Logging +- **Fiber middleware** — Rate limiting, CORS, recovery, logger +- **Air** — Hot reload for development +- **Docker + Docker Compose** — Containerization + +--- + +## 🚀 Getting Started + +### 1. Clone Project + +```bash +git clone https://github.com/hafizhproject45/Golang-Boilerplate.git +cd golang-boilerplate +``` + +### 2. Install Dependencies + +```bash +go mod tidy +``` + +### 3. Configure Environment + +Copy .env.example to .env and adjust the variables (e.g. DATABASE_URL, JWT secrets, etc). + +```bash +cp .env.example .env +``` + +### 5. Setup Docker + +Run initial docker. + +```bash +make docker-dev +``` + +### 4. Migrate Database + +Run initial migrations and generate views. + +```bash +make migrate-up +``` + +### 5. Run App + +Run project via Docker + +### 6. Create New Module + +```bash +make gen feat=user +``` + +output: + +```bash +cmd/ +├── api/ +│ └── main.go # Application entrypoint (initialize Fiber, load config, connect DB, register route) + +internal/ +├── config/ # App config (env loader, logger, app settings) +│ +├── database/ # Database connection + migration setup +│ +├── middleware/ # Global Fiber middleware (auth, logger, recovery, rate limiting) +│ +├── modules/ # Feature modules (users, products, suppliers, etc.) +│ ├── / +│ │ ├── controllers/ # HTTP handler layer (receive request, call service, return response) +│ │ ├── dto/ # Data Transfer Objects (request & response payloads, separate from models) +│ │ ├── models/ # GORM models (represent database tables/entities) +│ │ ├── repositories/ # Data access layer (queries to DB, CRUD abstraction) +│ │ ├── services/ # Business logic layer (process rules, orchestrate repository calls) +│ │ ├── validation/ # Request validation (custom rules per module) +│ │ ├── module.go # Module bootstrapper (wire controller, service, repository together) +│ │ └── route.go # Module route (register module routes into Fiber app) +│ +├── repository/ # Shared repositories (reusable DB access layer across multiple modules) +│ +├── response/ # Standardized API responses (success, error, pagination) +│ +├── utils/ # Helper functions (JWT, hashing, constants, enums, etc.) +│ +├── validation/ # Shared request validation structs & rules +│ +└── route/ # Central route aggregator (load all module routes into main app) +``` + +## ✨ Author + +Hafizh Athallah Yovanka + +## 📃 License + +Free to use diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 00000000..3cd71a7c --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/config" + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/database" + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/middleware" + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/route" + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/utils" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/compress" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/helmet" + "github.com/redis/go-redis/v9" + + "gorm.io/gorm" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + app := setupFiberApp() + db := setupDatabase() + defer closeDatabase(db) + rdb := setupRedis() + defer rdb.Close() + setupRoutes(app, db, rdb) + + address := fmt.Sprintf("%s:%d", config.AppHost, config.AppPort) + + // Start server and handle graceful shutdown + serverErrors := make(chan error, 1) + go startServer(app, address, serverErrors) + handleGracefulShutdown(ctx, app, serverErrors) +} + +func setupRedis() *redis.Client { + redisURL := os.Getenv("REDIS_URL") + if redisURL == "" { + redisURL = "redis://redis:6379/0" + } + opt, err := redis.ParseURL(redisURL) + if err != nil { + utils.Log.Fatalf("Redis URL parse error: %v", err) + } + rdb := redis.NewClient(opt) + if err := rdb.Ping(context.Background()).Err(); err != nil { + utils.Log.Fatalf("Redis ping failed: %v", err) + } + utils.Log.Infof("Redis connected: %s", redisURL) + return rdb +} + +func setupFiberApp() *fiber.App { + app := fiber.New(config.FiberConfig()) + + // Middleware setup + app.Use("/api/auth", middleware.LimiterConfig()) + app.Use(middleware.LoggerConfig()) + app.Use(helmet.New()) + app.Use(compress.New()) + app.Use(cors.New()) + app.Use(middleware.RecoverConfig()) + + return app +} + +func setupDatabase() *gorm.DB { + db := database.Connect(config.DBHost, config.DBName) + return db +} + +func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) { + + // route.Routes(app, db) + // app.Use(utils.NotFoundHandler) + app.Get("/healthz", func(c *fiber.Ctx) error { + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "status": "ok", + "service": "api", + "version": os.Getenv("VERSION"), + }) + }) + + app.Get("/readyz", func(c *fiber.Ctx) error { + sqlDB, err := db.DB() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "status": "error", "db": "unavailable", "redis": "unknown", + }) + } + ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second) + defer cancel() + + dbOK := sqlDB.PingContext(ctx) == nil + redisOK := rdb.Ping(ctx).Err() == nil + + status := fiber.StatusOK + statusText := "ok" + if !dbOK || !redisOK { + status = fiber.StatusServiceUnavailable + statusText = "degraded" + } + body := fiber.Map{ + "status": statusText, + "db": map[bool]string{true: "up", false: "down"}[dbOK], + "redis": map[bool]string{true: "up", false: "down"}[redisOK], + } + return c.Status(status).JSON(body) + }) + + route.Routes(app, db) + app.Use(utils.NotFoundHandler) +} + +func startServer(app *fiber.App, address string, errs chan<- error) { + if err := app.Listen(address); err != nil { + errs <- fmt.Errorf("error starting server: %w", err) + } +} + +func closeDatabase(db *gorm.DB) { + sqlDB, errDB := db.DB() + if errDB != nil { + utils.Log.Errorf("Error getting database instance: %v", errDB) + return + } + + if err := sqlDB.Close(); err != nil { + utils.Log.Errorf("Error closing database connection: %v", err) + } else { + utils.Log.Info("Database connection closed successfully") + } +} + +func handleGracefulShutdown(ctx context.Context, app *fiber.App, serverErrors <-chan error) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + + select { + case err := <-serverErrors: + utils.Log.Fatalf("Server error: %v", err) + case <-quit: + utils.Log.Info("Shutting down server...") + if err := app.Shutdown(); err != nil { + utils.Log.Fatalf("Error during server shutdown: %v", err) + } + case <-ctx.Done(): + utils.Log.Info("Server exiting due to context cancellation") + } + + utils.Log.Info("Server exited") +} diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 00000000..f80eec60 --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/config" + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/database" + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/database/seed" +) + +func main() { + db := database.Connect(config.DBHost, config.DBName) + + if err := seed.Run(db); err != nil { + log.Fatalf("❌ Failed run seeder: %v", err) + } + + log.Println("✅ Seed Successfully") +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..0255eb6b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,88 @@ +services: + postgresdb: + image: postgres:alpine + restart: always + ports: + - "${DB_PORT:-5432}:5432" + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-db_lti_erp} + volumes: + - dbdata:/var/lib/postgresql/data + - ./internal/database/init:/docker-entrypoint-initdb.d + networks: [go-network] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}"] + interval: 10s + timeout: 5s + retries: 5 + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 5s + timeout: 3s + retries: 10 + networks: [go-network] + + mailhog: + image: mailhog/mailhog:latest + restart: unless-stopped + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8025/ || exit 1"] + interval: 10s + timeout: 3s + retries: 5 + networks: [go-network] + + app: + build: + context: . + dockerfile: Dockerfile.dev + image: cosmtrek/air:v1.52.3 + working_dir: /golang-boilerplate + volumes: + - .:/golang-boilerplate + - ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key:ro + - ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub:ro + command: air -c .air.toml + env_file: + - .env + environment: + DB_HOST: postgresdb + DB_PORT: 5432 + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_NAME: ${DB_NAME:-db_lti_erp} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + SMTP_HOST: ${SMTP_HOST:-mailhog} + SMTP_PORT: ${SMTP_PORT:-1025} + ports: + - "${APP_PORT:-8080}:8080" + depends_on: + postgresdb: + condition: service_healthy + networks: [go-network] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 10s + +volumes: + dbdata: + go-mod-cache: + go-build-cache: + +networks: + go-network: + name: boilerplate-template_go-network + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..a63fbaf2 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,50 @@ +services: + postgresdb: + image: postgres:alpine + restart: always + ports: + - "${DB_PORT:-5432}:5432" + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-db_lti_erp} + volumes: + - dbdata:/var/lib/postgresql/data + - ./internal/database/init:/docker-entrypoint-initdb.d + networks: [go-network] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: + context: . + dockerfile: Dockerfile.prod + image: golang-boilerplate-app + working_dir: /golang-boilerplate + command: ./app # asumsi Dockerfile.prod menghasilkan binary bernama "app" + env_file: + - .env + environment: + DB_HOST: postgresdb + DB_PORT: 5432 + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_NAME: ${DB_NAME:-db_lti_erp} + ports: + - "${APP_PORT:-8080}:8080" + depends_on: + postgresdb: + condition: service_healthy + restart: on-failure + networks: [go-network] + +volumes: + dbdata: + +networks: + go-network: + name: boilerplate-template_go-network + driver: bridge diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..ce983771 --- /dev/null +++ b/go.mod @@ -0,0 +1,76 @@ +module github.com/hafizhproject45/Golang-Boilerplate.git + +go 1.23 + +require ( + github.com/bytedance/sonic v1.12.1 + github.com/go-playground/validator/v10 v10.27.0 + github.com/gofiber/contrib/jwt v1.0.10 + github.com/gofiber/fiber/v2 v2.52.5 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/redis/go-redis/v9 v9.14.0 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/viper v1.19.0 + golang.org/x/crypto v0.33.0 + golang.org/x/oauth2 v0.22.0 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.11 +) + +require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/bytedance/sonic/loader v0.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/philhofer/fwd v1.1.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.1.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.55.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..a7c25f83 --- /dev/null +++ b/go.sum @@ -0,0 +1,207 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= +github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24= +github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= +github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/gofiber/contrib/jwt v1.0.10 h1:/ilGepl6i0Bntl0Zcd+lAzagY8BiS1+fEiAj32HMApk= +github.com/gofiber/contrib/jwt v1.0.10/go.mod h1:1qBENE6sZ6PPT4xIpBzx1VxeyROQO7sj48OlM1I9qdU= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= +github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= +github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= +gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/config/auth.go b/internal/config/auth.go new file mode 100644 index 00000000..767853e0 --- /dev/null +++ b/internal/config/auth.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..cd1fbda0 --- /dev/null +++ b/internal/config/config.go @@ -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") + } +} diff --git a/internal/config/fiber.go b/internal/config/fiber.go new file mode 100644 index 00000000..dd833ff3 --- /dev/null +++ b/internal/config/fiber.go @@ -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, + } +} diff --git a/internal/config/oauth2.go b/internal/config/oauth2.go new file mode 100644 index 00000000..9e982113 --- /dev/null +++ b/internal/config/oauth2.go @@ -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 +} diff --git a/internal/config/roles.go b/internal/config/roles.go new file mode 100644 index 00000000..64fa80bd --- /dev/null +++ b/internal/config/roles.go @@ -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 +} diff --git a/internal/config/tokens.go b/internal/config/tokens.go new file mode 100644 index 00000000..c0932feb --- /dev/null +++ b/internal/config/tokens.go @@ -0,0 +1,8 @@ +package config + +const ( + TokenTypeAccess = "access" + TokenTypeRefresh = "refresh" + TokenTypeResetPassword = "resetPassword" + TokenTypeVerifyEmail = "verifyEmail" +) diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 00000000..9ebaa7f6 --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/database/init/init.sql b/internal/database/init/init.sql new file mode 100755 index 00000000..b02fe351 --- /dev/null +++ b/internal/database/init/init.sql @@ -0,0 +1 @@ +CREATE DATABASE IF NOT EXISTS db_lti_erp; diff --git a/internal/database/migrations/20250825071938_create-table-sso.down.sql b/internal/database/migrations/20250825071938_create-table-sso.down.sql new file mode 100644 index 00000000..c99ddcdc --- /dev/null +++ b/internal/database/migrations/20250825071938_create-table-sso.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/internal/database/migrations/20250825071938_create-table-sso.up.sql b/internal/database/migrations/20250825071938_create-table-sso.up.sql new file mode 100644 index 00000000..63397098 --- /dev/null +++ b/internal/database/migrations/20250825071938_create-table-sso.up.sql @@ -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 +); diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go new file mode 100644 index 00000000..4c031342 --- /dev/null +++ b/internal/database/seed/seeder.go @@ -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 + }) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 00000000..d8eef2d9 --- /dev/null +++ b/internal/middleware/auth.go @@ -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 +// } diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go new file mode 100644 index 00000000..33f40271 --- /dev/null +++ b/internal/middleware/jwt.go @@ -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")}, + }) +} diff --git a/internal/middleware/limiter.go b/internal/middleware/limiter.go new file mode 100644 index 00000000..2b1087db --- /dev/null +++ b/internal/middleware/limiter.go @@ -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, + }) +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 00000000..3f96607b --- /dev/null +++ b/internal/middleware/logger.go @@ -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", + }) +} diff --git a/internal/middleware/recover.go b/internal/middleware/recover.go new file mode 100644 index 00000000..846aff29 --- /dev/null +++ b/internal/middleware/recover.go @@ -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, + }) +} diff --git a/internal/modules/module.go b/internal/modules/module.go new file mode 100644 index 00000000..7c44047f --- /dev/null +++ b/internal/modules/module.go @@ -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) +} diff --git a/internal/modules/users/controllers/user.controller.go b/internal/modules/users/controllers/user.controller.go new file mode 100644 index 00000000..f8508e6a --- /dev/null +++ b/internal/modules/users/controllers/user.controller.go @@ -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", + }) +} diff --git a/internal/modules/users/dto/user.dto.go b/internal/modules/users/dto/user.dto.go new file mode 100644 index 00000000..34f9ab7c --- /dev/null +++ b/internal/modules/users/dto/user.dto.go @@ -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 +} diff --git a/internal/modules/users/models/user.model.go b/internal/modules/users/models/user.model.go new file mode 100644 index 00000000..6c37887c --- /dev/null +++ b/internal/modules/users/models/user.model.go @@ -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:"-"` +} diff --git a/internal/modules/users/module.go b/internal/modules/users/module.go new file mode 100644 index 00000000..4ccb354b --- /dev/null +++ b/internal/modules/users/module.go @@ -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) +} diff --git a/internal/modules/users/repositories/user.repository.go b/internal/modules/users/repositories/user.repository.go new file mode 100644 index 00000000..cbe9f5f2 --- /dev/null +++ b/internal/modules/users/repositories/user.repository.go @@ -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), + } +} diff --git a/internal/modules/users/route.go b/internal/modules/users/route.go new file mode 100644 index 00000000..1a2a0de4 --- /dev/null +++ b/internal/modules/users/route.go @@ -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) +} diff --git a/internal/modules/users/services/user.service.go b/internal/modules/users/services/user.service.go new file mode 100644 index 00000000..f8ec6acb --- /dev/null +++ b/internal/modules/users/services/user.service.go @@ -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 +} diff --git a/internal/modules/users/validations/user.validation.go b/internal/modules/users/validations/user.validation.go new file mode 100644 index 00000000..e423aa8c --- /dev/null +++ b/internal/modules/users/validations/user.validation.go @@ -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"` +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go new file mode 100644 index 00000000..6605f95f --- /dev/null +++ b/internal/repository/repository.go @@ -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 +} diff --git a/internal/response/error_response.go b/internal/response/error_response.go new file mode 100644 index 00000000..6803f75c --- /dev/null +++ b/internal/response/error_response.go @@ -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 +} diff --git a/internal/response/response.go b/internal/response/response.go new file mode 100644 index 00000000..c4ecca0f --- /dev/null +++ b/internal/response/response.go @@ -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"` +} diff --git a/internal/route/route.go b/internal/route/route.go new file mode 100644 index 00000000..69b19258 --- /dev/null +++ b/internal/route/route.go @@ -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) + } + +} diff --git a/internal/utils/constant.go b/internal/utils/constant.go new file mode 100644 index 00000000..129eb1bf --- /dev/null +++ b/internal/utils/constant.go @@ -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") +} +*/ diff --git a/internal/utils/error.go b/internal/utils/error.go new file mode 100644 index 00000000..5c46ee27 --- /dev/null +++ b/internal/utils/error.go @@ -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) +} diff --git a/internal/utils/logrus.go b/internal/utils/logrus.go new file mode 100644 index 00000000..85690306 --- /dev/null +++ b/internal/utils/logrus.go @@ -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) +} diff --git a/internal/utils/nullable.go b/internal/utils/nullable.go new file mode 100644 index 00000000..18f3ac63 --- /dev/null +++ b/internal/utils/nullable.go @@ -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 +} diff --git a/internal/utils/secure/argon2id.go b/internal/utils/secure/argon2id.go new file mode 100644 index 00000000..88677d0c --- /dev/null +++ b/internal/utils/secure/argon2id.go @@ -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 +} diff --git a/internal/utils/secure/opaque.go b/internal/utils/secure/opaque.go new file mode 100644 index 00000000..10e7034a --- /dev/null +++ b/internal/utils/secure/opaque.go @@ -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 +} diff --git a/internal/utils/secure/sha256.go b/internal/utils/secure/sha256.go new file mode 100644 index 00000000..8b0531a3 --- /dev/null +++ b/internal/utils/secure/sha256.go @@ -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[:]) +} diff --git a/internal/utils/verify.go b/internal/utils/verify.go new file mode 100644 index 00000000..e8b3a850 --- /dev/null +++ b/internal/utils/verify.go @@ -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") + } +} diff --git a/internal/validation/custom_validation.go b/internal/validation/custom_validation.go new file mode 100644 index 00000000..17dace27 --- /dev/null +++ b/internal/validation/custom_validation.go @@ -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 +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go new file mode 100644 index 00000000..a2400047 --- /dev/null +++ b/internal/validation/validation.go @@ -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 +} diff --git a/tools/gen.go b/tools/gen.go new file mode 100644 index 00000000..10cf9284 --- /dev/null +++ b/tools/gen.go @@ -0,0 +1,263 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "text/template" +) + +type Data struct { + FeatName string // input full feature (ex: "master/area") + Parts []string // split parts ["master","area"] + Entity string // last ("area") +} + +func main() { + if len(os.Args) < 2 { + log.Fatal("usage: make gen feat= (ex: customer | master/area)") + } + + feat := os.Args[1] + parts := strings.Split(feat, "/") + entity := parts[len(parts)-1] + + d := Data{ + FeatName: feat, + Parts: parts, + Entity: entity, + } + + // daftar template yang mau diproses + files := []struct { + TplPath string + OutDir string + OutSuffix string + TplName string + }{ + { + TplPath: "tools/templates/model.tmpl", + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "models"), + OutSuffix: ".model.go", + TplName: "model", + }, + { + TplPath: "tools/templates/validation.tmpl", + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "validations"), + OutSuffix: ".validation.go", + TplName: "validation", + }, + { + TplPath: "tools/templates/service.tmpl", + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "services"), + OutSuffix: ".service.go", + TplName: "service", + }, + { + TplPath: "tools/templates/controller.tmpl", + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "controllers"), + OutSuffix: ".controller.go", + TplName: "controller", + }, + { + TplPath: "tools/templates/repository.tmpl", + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "repositories"), + OutSuffix: ".repository.go", + TplName: "repository", + }, + { + TplPath: "tools/templates/dto.tmpl", + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "dto"), + OutSuffix: ".dto.go", + TplName: "dto", + }, + { + TplPath: "tools/templates/route.tmpl", + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s"), + OutSuffix: "", + TplName: "route", + }, + { + TplPath: "tools/templates/module.tmpl", + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s"), + OutSuffix: "", + TplName: "module", + }, + } + + for _, file := range files { + // pastikan template ketemu + if _, err := os.Stat(file.TplPath); err != nil { + log.Fatalf("template not found at %s: %v", file.TplPath, err) + } + + // parse template + tpl := template.Must( + template.New(file.TplName). + Funcs(template.FuncMap{ + "Pascal": toPascalCase, + "Camel": toCamelCase, + "Plural": toPlural, + "Kebab": toKebab, + }). + ParseFiles(file.TplPath), + ) + + // pastikan folder ada + if err := os.MkdirAll(file.OutDir, 0o755); err != nil { + log.Fatalf("make dir: %v", err) + } + + // nama file output + var outFile string + switch file.TplName { + case "route": + outFile = filepath.Join(file.OutDir, "route.go") + case "module": + outFile = filepath.Join(file.OutDir, "module.go") + default: + outFile = filepath.Join(file.OutDir, strings.ToLower(d.Entity)+file.OutSuffix) + } + + // hindari overwrite + if _, err := os.Stat(outFile); err == nil { + log.Fatalf("file already exists: %s", outFile) + } + + f, err := os.Create(outFile) + if err != nil { + log.Fatalf("create file: %v", err) + } + defer f.Close() + + if err := tpl.ExecuteTemplate(f, file.TplName, d); err != nil { + log.Fatal(err) + } + + log.Println("Generated:", outFile) + } + + updateMainRoute(d) +} + +func updateMainRoute(d Data) { + routeFile := "internal/route/route.go" + content, err := os.ReadFile(routeFile) + if err != nil { + log.Printf("skip update route.go: %v", err) + return + } + + // entity & path + modPath := filepath.Join(append(toCamelParts(d.Parts[:len(d.Parts)-1]), toCamelCase(d.Entity)+"s")...) + modName := toCamelCase(d.Entity) + "s" + pkgName := toPascalCase(d.Entity) + "Module" + + // Inject import + importLine := fmt.Sprintf("\t%[1]s \"%s/internal/modules/%s\"", modName, "github.com/hafizhproject45/Golang-Boilerplate.git", modPath) + if !strings.Contains(string(content), importLine) { + content = []byte(strings.Replace(string(content), + "// MODULE IMPORTS", + importLine+"\n\t// MODULE IMPORTS", + 1)) + } + + // Inject registry + registryLine := fmt.Sprintf("\t\t%[1]s.%[2]s{},", modName, pkgName) + if !strings.Contains(string(content), registryLine) { + content = []byte(strings.Replace(string(content), + "// MODULE REGISTRY", + registryLine+"\n\t\t// MODULE REGISTRY", + 1)) + } + + if err := os.WriteFile(routeFile, content, 0644); err != nil { + log.Fatal(err) + } + log.Println("Updated:", routeFile) +} + +func toPascalCase(s string) string { + sep := func(r rune) bool { return r == '_' || r == '-' || r == ' ' || r == '/' } + parts := strings.FieldsFunc(s, sep) + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:]) + } + return strings.Join(parts, "") +} + +func toCamelCase(s string) string { + p := toPascalCase(s) + if p == "" { + return "" + } + return strings.ToLower(p[:1]) + p[1:] +} + +// simple pluralizer (cukup untuk kasus umum: tambah 's') +func toPlural(s string) string { + s = strings.ToLower(s) + if strings.HasSuffix(s, "y") && len(s) > 1 { + prev := s[len(s)-2] + if !(prev == 'a' || prev == 'i' || prev == 'u' || prev == 'e' || prev == 'o') { + return s[:len(s)-1] + "ies" + } + } + return s + "s" +} + +// kebab-case (untuk folder) +func toKebab(s string) string { + s = strings.ReplaceAll(s, "_", "-") + var b strings.Builder + for i, r := range s { + if r >= 'A' && r <= 'Z' { + if i > 0 { + b.WriteByte('-') + } + b.WriteRune(r + 32) + } else { + b.WriteRune(r) + } + } + out := b.String() + out = strings.ReplaceAll(out, "--", "-") + return strings.Trim(out, "-") +} + +// join multiple parts jadi kebab path +func toKebabPath(parts []string) string { + return filepath.Join(toKebabParts(parts)...) +} + +func toKebabParts(parts []string) []string { + var out []string + for _, p := range parts { + out = append(out, toKebab(p)) + } + return out +} + +// join multiple parts jadi camelCase path +// func toCamelPath(parts []string) string { +// return filepath.Join(toCamelParts(parts)...) +// } + +func toCamelParts(parts []string) []string { + var out []string + for i, p := range parts { + if i == 0 { + // part pertama lower-case semua + out = append(out, toCamelCase(p)) + } else { + // part berikutnya PascalCase biar tetap nyambung camel + out = append(out, toPascalCase(p)) + } + } + return out +} diff --git a/tools/templates/controller.tmpl b/tools/templates/controller.tmpl new file mode 100644 index 00000000..8a5e7e68 --- /dev/null +++ b/tools/templates/controller.tmpl @@ -0,0 +1,141 @@ +{{define "controller"}}package controller + +import ( + "math" + "strconv" + + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/dto" + service "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/services" + validation "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/validations" + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type {{Pascal .Entity}}Controller struct { + {{Pascal .Entity}}Service service.{{Pascal .Entity}}Service +} + +func New{{Pascal .Entity}}Controller({{Camel .Entity}}Service service.{{Pascal .Entity}}Service) *{{Pascal .Entity}}Controller { + return &{{Pascal .Entity}}Controller{ + {{Pascal .Entity}}Service: {{Camel .Entity}}Service, + } +} + +func (u *{{Pascal .Entity}}Controller) 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.{{Pascal .Entity}}Service.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.{{Pascal .Entity}}ListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all {{Camel .Entity}}s successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.To{{Pascal .Entity}}ListDTOs(result), + }) +} + +func (u *{{Pascal .Entity}}Controller) 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.{{Pascal .Entity}}Service.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get {{Camel .Entity}} successfully", + Data: dto.To{{Pascal .Entity}}ListDTO(*result), + }) +} + +func (u *{{Pascal .Entity}}Controller) 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.{{Pascal .Entity}}Service.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create {{Camel .Entity}} successfully", + Data: dto.To{{Pascal .Entity}}ListDTO(*result), + }) +} + +func (u *{{Pascal .Entity}}Controller) 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.{{Pascal .Entity}}Service.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 {{Camel .Entity}} successfully", + Data: dto.To{{Pascal .Entity}}ListDTO(*result), + }) +} + +func (u *{{Pascal .Entity}}Controller) 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.{{Pascal .Entity}}Service.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete {{Camel .Entity}} successfully", + }) +} +{{end}} diff --git a/tools/templates/dto.tmpl b/tools/templates/dto.tmpl new file mode 100644 index 00000000..c621612d --- /dev/null +++ b/tools/templates/dto.tmpl @@ -0,0 +1,38 @@ +{{define "dto"}}package dto + +import ( + "time" + + model "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/models" +) + +// === DTO Structs === + +type {{Pascal .Entity}}ListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type {{Pascal .Entity}}DetailDTO struct { + {{Pascal .Entity}}ListDTO +} + +// === Mapper Functions === + +func To{{Pascal .Entity}}ListDTO(m model.{{Pascal .Entity}}) {{Pascal .Entity}}ListDTO { + return {{Pascal .Entity}}ListDTO{ + Id: m.Id, + Name: m.Name, + } +} + +func To{{Pascal .Entity}}ListDTOs(m []model.{{Pascal .Entity}}) []{{Pascal .Entity}}ListDTO { + result := make([]{{Pascal .Entity}}ListDTO, len(m)) + for i, r := range m { + result[i] = To{{Pascal .Entity}}ListDTO(r) + } + return result +} +{{end}} diff --git a/tools/templates/model.tmpl b/tools/templates/model.tmpl new file mode 100644 index 00000000..c6070f2c --- /dev/null +++ b/tools/templates/model.tmpl @@ -0,0 +1,13 @@ +{{define "model"}}package model + +import ( + "time" +) + +type {{Pascal .Entity}} struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + CreatedAt time.Time + UpdatedAt time.Time +} +{{end}} diff --git a/tools/templates/module.tmpl b/tools/templates/module.tmpl new file mode 100644 index 00000000..3a0aa678 --- /dev/null +++ b/tools/templates/module.tmpl @@ -0,0 +1,27 @@ +{{define "module"}}package {{Kebab .Entity}}s + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + r{{Pascal .Entity}} "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/repositories" + s{{Pascal .Entity}} "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/services" + + rUser "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/repositories" + sUser "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/services" +) + +type {{Pascal .Entity}}Module struct{} + +func ({{Pascal .Entity}}Module) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + {{Camel .Entity}}Repo := r{{Pascal .Entity}}.New{{Pascal .Entity}}Repository(db) + userRepo := rUser.NewUserRepository(db) + + {{Camel .Entity}}Service := s{{Pascal .Entity}}.New{{Pascal .Entity}}Service({{Camel .Entity}}Repo, validate) + userService := sUser.NewUserService(userRepo, validate) + + {{Pascal .Entity}}Routes(router, userService, {{Camel .Entity}}Service) +} + +{{end}} diff --git a/tools/templates/repository.tmpl b/tools/templates/repository.tmpl new file mode 100644 index 00000000..7780ee44 --- /dev/null +++ b/tools/templates/repository.tmpl @@ -0,0 +1,22 @@ +{{define "repository"}}package repository + +import ( + model "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/models" + "github.com/hafizhproject45/Golang-Boilerplate.git/internal/repository" + "gorm.io/gorm" +) + +type {{Pascal .Entity}}Repository interface { + repository.BaseRepository[model.{{Pascal .Entity}}] +} + +type {{Pascal .Entity}}RepositoryImpl struct { + *repository.BaseRepositoryImpl[model.{{Pascal .Entity}}] +} + +func New{{Pascal .Entity}}Repository(db *gorm.DB) {{Pascal .Entity}}Repository { + return &{{Pascal .Entity}}RepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[model.{{Pascal .Entity}}](db), + } +} +{{end}} diff --git a/tools/templates/route.tmpl b/tools/templates/route.tmpl new file mode 100644 index 00000000..2dddbec4 --- /dev/null +++ b/tools/templates/route.tmpl @@ -0,0 +1,23 @@ +{{define "route"}}package {{Kebab .Entity}}s + +import ( + m "github.com/hafizhproject45/Golang-Boilerplate.git/internal/middleware" + controller "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/controllers" + {{Camel .Entity}} "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/services" + user "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func {{Pascal .Entity}}Routes(v1 fiber.Router, u user.UserService, s {{Camel .Entity}}.{{Pascal .Entity}}Service) { + ctrl := controller.New{{Pascal .Entity}}Controller(s) + + route := v1.Group("/{{Kebab .Entity}}s") + + route.Get("/", m.Auth(u), ctrl.GetAll) + route.Post("/", m.Auth(u), ctrl.CreateOne) + route.Get("/:id", m.Auth(u), ctrl.GetOne) + route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) +} +{{end}} diff --git a/tools/templates/service.tmpl b/tools/templates/service.tmpl new file mode 100644 index 00000000..b5c54955 --- /dev/null +++ b/tools/templates/service.tmpl @@ -0,0 +1,120 @@ +{{define "service"}}package service + +import ( + "errors" + + model "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/models" + repository "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/repositories" + validation "github.com/hafizhproject45/Golang-Boilerplate.git/internal/modules/{{Kebab .FeatName}}s/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 {{Pascal .Entity}}Service interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]model.{{Pascal .Entity}}, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*model.{{Pascal .Entity}}, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*model.{{Pascal .Entity}}, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*model.{{Pascal .Entity}}, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type {{Camel .Entity}}Service struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.{{Pascal .Entity}}Repository +} + +func New{{Pascal .Entity}}Service(repo repository.{{Pascal .Entity}}Repository, validate *validator.Validate) {{Pascal .Entity}}Service { + return &{{Camel .Entity}}Service{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} +func (s {{Camel .Entity}}Service) GetAll(c *fiber.Ctx, params *validation.Query) ([]model.{{Pascal .Entity}}, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + {{Camel .Entity}}s, 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 {{Camel .Entity}}s: %+v", err) + return nil, 0, err + } + return {{Camel .Entity}}s, total, nil +} + +func (s {{Camel .Entity}}Service) GetOne(c *fiber.Ctx, id uint) (*model.{{Pascal .Entity}}, error) { + {{Camel .Entity}}, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "{{Pascal .Entity}} not found") + } + if err != nil { + s.Log.Errorf("Failed get {{Camel .Entity}} by id: %+v", err) + return nil, err + } + return {{Camel .Entity}}, nil +} + +func (s *{{Camel .Entity}}Service) CreateOne(c *fiber.Ctx, req *validation.Create) (*model.{{Pascal .Entity}}, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + createBody := &model.{{Pascal .Entity}}{ + Name: req.Name, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create {{Camel .Entity}}: %+v", err) + return nil, err + } + + return createBody, nil +} + +func (s {{Camel .Entity}}Service) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*model.{{Pascal .Entity}}, 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, "{{Pascal .Entity}} not found") + } + s.Log.Errorf("Failed to update {{Camel .Entity}}: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s {{Camel .Entity}}Service) 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, "{{Pascal .Entity}} not found") + } + s.Log.Errorf("Failed to delete {{Camel .Entity}}: %+v", err) + return err + } + return nil +} +{{end}} diff --git a/tools/templates/validation.tmpl b/tools/templates/validation.tmpl new file mode 100644 index 00000000..a01cf8c1 --- /dev/null +++ b/tools/templates/validation.tmpl @@ -0,0 +1,16 @@ +{{define "validation"}}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"` +} +{{end}}