summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-03-03 16:02:28 +0530
committerBobby <[email protected]>2026-03-03 16:02:28 +0530
commitc370e302edf99c5a230800b2ff6f4612642121d8 (patch)
tree23eda9be6873e2663719981fae87d70aee3ef037
parenteb332a9697e80d8ba138e51cc8de234dad59b0bd (diff)
downloadpagoda-c370e302edf99c5a230800b2ff6f4612642121d8.tar.xz
pagoda-c370e302edf99c5a230800b2ff6f4612642121d8.zip
feat: Implement middleware for HTTP logging and request context
- Added httpLogger middleware to log HTTP requests with status, method, IP, and duration. - Introduced request middleware to build and store request context. - Created a centralized middleware initialization function. - Enhanced logging functionality with different log levels based on HTTP status codes. feat: Set up routing with error handling - Established a router package to manage application routes. - Implemented a custom error handler to respond with appropriate error messages. - Added sample route for a hello endpoint with authentication requirement. feat: Introduce utility functions for environment variable management - Developed functions to retrieve and parse environment variables with default values. - Implemented a parser to populate configuration structs from environment variables. feat: Create structured logging with zap - Integrated zap logger for structured logging with customizable log levels and formats. - Added color-coded log messages for better visibility in the console. feat: Build request metadata utilities - Created utilities to build and manage request metadata, including query parameters, headers, and route parameters. - Implemented a facade pattern for easier access to request data. feat: Enhance URL management with namespaces - Developed a URL management system to handle route registration with namespaces. - Added functionality to retrieve full paths for registered routes. chore: Initialize database and application structure - Set up initial application structure with main entry point and middleware integration. - Created a database connection handler for graceful shutdown.
-rw-r--r--Makefile2
-rw-r--r--shrine/Makefile47
-rw-r--r--shrine/config/config.go39
-rw-r--r--shrine/config/env.go13
-rw-r--r--shrine/config/functions.go31
-rw-r--r--shrine/controllers/errors.go44
-rw-r--r--shrine/controllers/sample.go17
-rw-r--r--shrine/database/database.go64
-rw-r--r--shrine/database/migrate.go16
-rw-r--r--shrine/enums/database.go10
-rw-r--r--shrine/go.mod38
-rw-r--r--shrine/go.sum75
-rw-r--r--shrine/middleware/logger.go82
-rw-r--r--shrine/middleware/middleware.go8
-rw-r--r--shrine/middleware/request.go17
-rw-r--r--shrine/pagoda.db0
-rw-r--r--shrine/router/router.go34
-rw-r--r--shrine/router/sample.go14
-rw-r--r--shrine/shrine/main.go58
-rw-r--r--shrine/types/http.go29
-rw-r--r--shrine/types/response.go9
-rw-r--r--shrine/utils/auth/auth.go17
-rw-r--r--shrine/utils/env/functions.go97
-rw-r--r--shrine/utils/env/parser.go28
-rw-r--r--shrine/utils/env/setter.go107
-rw-r--r--shrine/utils/env/validator.go15
-rw-r--r--shrine/utils/logger/logger.go145
-rw-r--r--shrine/utils/logger/types.go29
-rw-r--r--shrine/utils/meta/builder.go20
-rw-r--r--shrine/utils/meta/functions.go40
-rw-r--r--shrine/utils/meta/request.go107
-rw-r--r--shrine/utils/meta/types.go23
-rw-r--r--shrine/utils/meta/value.go9
-rw-r--r--shrine/utils/shortcuts/response.go16
-rw-r--r--shrine/utils/shortcuts/types.go9
-rw-r--r--shrine/utils/urls/attach.go38
-rw-r--r--shrine/utils/urls/namespace.go7
-rw-r--r--shrine/utils/urls/path.go51
-rw-r--r--shrine/utils/urls/registry.go5
-rw-r--r--shrine/utils/urls/types.go23
40 files changed, 1427 insertions, 6 deletions
diff --git a/Makefile b/Makefile
index f81db56..0fe4546 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ dev-garden:
cd garden && npm run dev
dev-shrine:
- cd shrine && go run .
+ cd shrine && make dev
build-garden:
cd garden && npm run build
diff --git a/shrine/Makefile b/shrine/Makefile
index eec53ec..e65ead2 100644
--- a/shrine/Makefile
+++ b/shrine/Makefile
@@ -1,10 +1,47 @@
-.PHONY: build run clean
+BINARY_NAME=shrine
+BUILD_PATH=bin/$(BINARY_NAME)
+MAIN_PATH=$(BINARY_NAME)/main.go
+ENV_PATH=.env
+
+.PHONY: setup clean build run dev all
+
+define ensure_setup
+ @if [ ! -f $(ENV_PATH) ]; then \
+ echo "Running setup first..."; \
+ $(MAKE) -s setup; \
+ fi
+endef
+
+setup:
+ @echo "Setting up environment..."
+ @go mod download
+ @go mod tidy
+# Later when we have .env.example
+# @if [ ! -f $(ENV_PATH) ]; then cp .env.example $(ENV_PATH); fi
+ @echo "Environment setup complete."
+
+clean:
+ @echo "Cleaning up..."
+ @rm -rf bin
+ @echo "Cleanup complete."
build:
- go build -o bin/shrine .
+ $(call ensure_setup)
+ @echo "Building..."
+ @go build -o $(BUILD_PATH) $(MAIN_PATH) || true
+ @echo "Build complete."
run:
- go run .
+ $(call ensure_setup)
+ @if [ ! -f $(BUILD_PATH) ]; then echo "Binary not found. Building binary..."; $(MAKE) -s build; fi
+ @echo "Running..."
+ @$(BUILD_PATH) || true
-clean:
- rm -rf bin/ \ No newline at end of file
+dev:
+ $(call ensure_setup)
+ @echo "Running in development mode..."
+ @go run $(MAIN_PATH) || true
+
+all: setup clean build run
+
+.SILENT: \ No newline at end of file
diff --git a/shrine/config/config.go b/shrine/config/config.go
new file mode 100644
index 0000000..cc223ca
--- /dev/null
+++ b/shrine/config/config.go
@@ -0,0 +1,39 @@
+package config
+
+import (
+ "shrine/utils/env"
+ "shrine/utils/logger"
+
+ "github.com/joho/godotenv"
+)
+
+var (
+ Server server
+ Database database
+)
+
+func init() {
+ logger.Init()
+
+ if err := godotenv.Load(); err != nil {
+ logger.Infof("Config", "No .env file found. Environment variables will be used directly.")
+ }
+
+ if err := env.Parse(&Server); err != nil {
+ logger.Fatalf("Config", "Failed to parse server config: %v", err)
+ }
+
+ if err := env.Parse(&Database); err != nil {
+ logger.Fatalf("Config", "Failed to parse database config: %v", err)
+ }
+
+ if Server.Debug {
+ logger.SetDebug(true)
+ }
+
+ if err := verifyConfig(); err != nil {
+ logger.Fatalf("Config", "Configuration verification failed: %v", err)
+ }
+
+ logger.Successf("Config", "Configuration loaded successfully")
+}
diff --git a/shrine/config/env.go b/shrine/config/env.go
new file mode 100644
index 0000000..bb5567b
--- /dev/null
+++ b/shrine/config/env.go
@@ -0,0 +1,13 @@
+package config
+
+type server struct {
+ Host string `env:"HOST" default:"0.0.0.0"`
+ Port int `env:"PORT" default:"3000"`
+ Secret string `env:"SECRET" default:"pagoda-secret"`
+ Debug bool `env:"DEBUG" default:"false"`
+}
+
+type database struct {
+ Driver string `env:"DB_DRIVER" default:"sqlite"`
+ DSN string `env:"DSN" default:"pagoda.db"`
+}
diff --git a/shrine/config/functions.go b/shrine/config/functions.go
new file mode 100644
index 0000000..dc0e6c9
--- /dev/null
+++ b/shrine/config/functions.go
@@ -0,0 +1,31 @@
+package config
+
+import (
+ "fmt"
+ "shrine/enums"
+)
+
+func verifyConfig() error {
+ if Server.Port <= 0 || Server.Port > 65535 {
+ return fmt.Errorf("invalid server port: %d", Server.Port)
+ }
+
+ if !verifyDatabaseDriver(enums.DatabaseDriver(Database.Driver)) {
+ return fmt.Errorf("invalid database driver: %s", Database.Driver)
+ }
+
+ if Database.DSN == "" {
+ return fmt.Errorf("data source name (DSN) cannot be empty")
+ }
+
+ return nil
+}
+
+func verifyDatabaseDriver(driver enums.DatabaseDriver) bool {
+ switch driver {
+ case enums.SQLite, enums.Postgres:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/shrine/controllers/errors.go b/shrine/controllers/errors.go
new file mode 100644
index 0000000..55eb203
--- /dev/null
+++ b/shrine/controllers/errors.go
@@ -0,0 +1,44 @@
+package controllers
+
+import (
+ "shrine/types"
+ "shrine/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func BadRequest(context *fiber.Ctx, err error) error {
+ return shortcuts.Response(context, types.ErrorResponse{
+ Error: err.Error(),
+ }).As(fiber.StatusBadRequest)
+}
+
+func Unauthorized(context *fiber.Ctx, err error) error {
+ return shortcuts.Response(context, types.ErrorResponse{
+ Error: err.Error(),
+ }).As(fiber.StatusUnauthorized)
+}
+
+func Forbidden(context *fiber.Ctx, err error) error {
+ return shortcuts.Response(context, types.ErrorResponse{
+ Error: err.Error(),
+ }).As(fiber.StatusForbidden)
+}
+
+func NotFound(context *fiber.Ctx, err error) error {
+ return shortcuts.Response(context, types.ErrorResponse{
+ Error: err.Error(),
+ }).As(fiber.StatusNotFound)
+}
+
+func InternalServerError(context *fiber.Ctx, err error) error {
+ return shortcuts.Response(context, types.ErrorResponse{
+ Error: err.Error(),
+ }).As(fiber.StatusInternalServerError)
+}
+
+func DefaultError(context *fiber.Ctx, err error) error {
+ return shortcuts.Response(context, types.ErrorResponse{
+ Error: err.Error(),
+ }).As(fiber.StatusInternalServerError)
+}
diff --git a/shrine/controllers/sample.go b/shrine/controllers/sample.go
new file mode 100644
index 0000000..542d86c
--- /dev/null
+++ b/shrine/controllers/sample.go
@@ -0,0 +1,17 @@
+package controllers
+
+import (
+ "fmt"
+ "shrine/types"
+ "shrine/utils/meta"
+ "shrine/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func HelloController(context *fiber.Ctx) error {
+ name := meta.Request(context).Default("World").Query("name")
+ return shortcuts.Response(context, types.HelloResponse{
+ Message: fmt.Sprintf("Hello, %s!", name),
+ }).As(fiber.StatusOK)
+}
diff --git a/shrine/database/database.go b/shrine/database/database.go
new file mode 100644
index 0000000..0defd36
--- /dev/null
+++ b/shrine/database/database.go
@@ -0,0 +1,64 @@
+package database
+
+import (
+ "shrine/config"
+ "shrine/enums"
+ "shrine/utils/logger"
+ "time"
+
+ "gorm.io/driver/postgres"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ gormlogger "gorm.io/gorm/logger"
+)
+
+const (
+ MaxOpenConnections = 25
+ MaxIdleConnections = 5
+ ConnectionMaxLifetime = time.Hour
+)
+
+var DB *gorm.DB
+
+func init() {
+ var dialector gorm.Dialector
+
+ switch enums.DatabaseDriver(config.Database.Driver) {
+ case enums.SQLite:
+ dialector = sqlite.Open(config.Database.DSN)
+ case enums.Postgres:
+ dialector = postgres.Open(config.Database.DSN)
+ default:
+ logger.Fatalf("Database", "Invalid database driver: %s", config.Database.Driver)
+ }
+
+ var err error
+ DB, err = gorm.Open(dialector, &gorm.Config{
+ Logger: gormlogger.Default.LogMode(gormlogger.Silent),
+ })
+
+ if err != nil {
+ logger.Fatalf("Database", "Error connecting to database: %v", err)
+ }
+
+ db, err := DB.DB()
+ if err != nil {
+ logger.Fatalf("Database", "Failed to get underlying sql.DB: %v", err)
+ }
+ db.SetMaxOpenConns(MaxOpenConnections)
+ db.SetMaxIdleConns(MaxIdleConnections)
+ db.SetConnMaxLifetime(ConnectionMaxLifetime)
+
+ logger.Successf("Database", "Database connection established successfully")
+
+ migrate()
+}
+
+func Close() error {
+ db, err := DB.DB()
+ if err != nil {
+ return err
+ }
+
+ return db.Close()
+}
diff --git a/shrine/database/migrate.go b/shrine/database/migrate.go
new file mode 100644
index 0000000..7d99788
--- /dev/null
+++ b/shrine/database/migrate.go
@@ -0,0 +1,16 @@
+package database
+
+import (
+ "shrine/utils/logger"
+)
+
+func migrate() {
+ err := DB.AutoMigrate(
+ // Models will be added here as they are created
+ )
+ if err != nil {
+ logger.Fatalf("Database", "Error during database migration: %v", err)
+ }
+
+ logger.Successf("Database", "Database migration completed successfully")
+}
diff --git a/shrine/enums/database.go b/shrine/enums/database.go
new file mode 100644
index 0000000..d8fff0b
--- /dev/null
+++ b/shrine/enums/database.go
@@ -0,0 +1,10 @@
+package enums
+
+type DatabaseDriver string
+
+const (
+ SQLite DatabaseDriver = "sqlite"
+ MySQL DatabaseDriver = "mysql"
+ Postgres DatabaseDriver = "postgres"
+ SQLServer DatabaseDriver = "sqlserver"
+)
diff --git a/shrine/go.mod b/shrine/go.mod
new file mode 100644
index 0000000..137a175
--- /dev/null
+++ b/shrine/go.mod
@@ -0,0 +1,38 @@
+module shrine
+
+go 1.25.5
+
+require (
+ github.com/gofiber/fiber/v2 v2.52.12
+ github.com/joho/godotenv v1.5.1
+ go.uber.org/zap v1.27.1
+ gorm.io/driver/postgres v1.6.0
+ gorm.io/driver/sqlite v1.6.0
+ gorm.io/gorm v1.31.1
+)
+
+require (
+ github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.6.0 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/klauspost/compress v1.17.9 // 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/mattn/go-sqlite3 v1.14.22 // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/stretchr/testify v1.9.0 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.51.0 // indirect
+ github.com/valyala/tcplisten v1.0.0 // indirect
+ go.uber.org/multierr v1.10.0 // indirect
+ golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/sync v0.10.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+)
diff --git a/shrine/go.sum b/shrine/go.sum
new file mode 100644
index 0000000..6b475c9
--- /dev/null
+++ b/shrine/go.sum
@@ -0,0 +1,75 @@
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
+github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
+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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
+github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+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.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
+github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
+go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
+go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+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.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
+gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
+gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/shrine/middleware/logger.go b/shrine/middleware/logger.go
new file mode 100644
index 0000000..56b13eb
--- /dev/null
+++ b/shrine/middleware/logger.go
@@ -0,0 +1,82 @@
+package middleware
+
+import (
+ "fmt"
+ "shrine/utils/logger"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func httpLogger() fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ start := time.Now()
+
+ err := c.Next()
+
+ duration := time.Since(start)
+ status := c.Response().StatusCode()
+ method := c.Method()
+ path := c.Path()
+ ip := c.IP()
+
+ // Pad method for alignment
+ paddedMethod := method
+ if len(method) < 7 {
+ paddedMethod = method + strings.Repeat(" ", 7-len(method))
+ }
+
+ message := fmt.Sprintf(
+ "%s %-3d %-15s %-10s %s",
+ paddedMethod,
+ status,
+ "IP: "+ip,
+ "TTR: "+formatDuration(duration),
+ "Path: "+path,
+ )
+
+ logByStatus(status, "HTTP", message)
+
+ return err
+ }
+}
+
+func logByStatus(status int, prefix, message string) {
+ switch {
+ case status >= 500:
+ logger.Errorf(prefix, "%s", message)
+ case status >= 400:
+ logger.Warnf(prefix, "%s", message)
+ case status >= 300:
+ logger.Infof(prefix, "%s", message)
+ case status >= 200:
+ logger.Successf(prefix, "%s", message)
+ default:
+ logger.Infof(prefix, "%s", message)
+ }
+}
+
+func formatDuration(d time.Duration) string {
+ if d < time.Microsecond {
+ return strconv.FormatInt(d.Nanoseconds(), 10) + "ns"
+ }
+ if d < time.Millisecond {
+ return strconv.FormatInt(d.Nanoseconds()/1_000, 10) + "µs"
+ }
+ if d < time.Second {
+ return strconv.FormatFloat(
+ float64(d.Nanoseconds())/float64(time.Millisecond),
+ 'f',
+ 3,
+ 64,
+ ) + "ms"
+ }
+ return strconv.FormatFloat(
+ float64(d.Nanoseconds())/float64(time.Second),
+ 'f',
+ 3,
+ 64,
+ ) + "s"
+}
diff --git a/shrine/middleware/middleware.go b/shrine/middleware/middleware.go
new file mode 100644
index 0000000..33157e4
--- /dev/null
+++ b/shrine/middleware/middleware.go
@@ -0,0 +1,8 @@
+package middleware
+
+import "github.com/gofiber/fiber/v2"
+
+func Initialize(app *fiber.App) {
+ app.Use(httpLogger())
+ app.Use(request())
+}
diff --git a/shrine/middleware/request.go b/shrine/middleware/request.go
new file mode 100644
index 0000000..a9a1cd8
--- /dev/null
+++ b/shrine/middleware/request.go
@@ -0,0 +1,17 @@
+package middleware
+
+import (
+ "shrine/utils/meta"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+const requestKey = "__request_ctx"
+
+func request() fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ req := meta.BuildRequest(c)
+ c.Locals(requestKey, req)
+ return c.Next()
+ }
+}
diff --git a/shrine/pagoda.db b/shrine/pagoda.db
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/shrine/pagoda.db
diff --git a/shrine/router/router.go b/shrine/router/router.go
new file mode 100644
index 0000000..4f1da5d
--- /dev/null
+++ b/shrine/router/router.go
@@ -0,0 +1,34 @@
+package router
+
+import (
+ controllers "shrine/controllers"
+ "shrine/utils/urls"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Initialize(router *fiber.App) {
+ urls.Attach(router)
+}
+
+func ErrorHandler(ctx *fiber.Ctx, err error) error {
+ code := fiber.StatusInternalServerError
+ if e, ok := err.(*fiber.Error); ok {
+ code = e.Code
+ }
+
+ switch code {
+ case fiber.StatusBadRequest:
+ return controllers.BadRequest(ctx, err)
+ case fiber.StatusUnauthorized:
+ return controllers.Unauthorized(ctx, err)
+ case fiber.StatusForbidden:
+ return controllers.Forbidden(ctx, err)
+ case fiber.StatusNotFound:
+ return controllers.NotFound(ctx, err)
+ case fiber.StatusInternalServerError:
+ return controllers.InternalServerError(ctx, err)
+ default:
+ return controllers.DefaultError(ctx, err)
+ }
+}
diff --git a/shrine/router/sample.go b/shrine/router/sample.go
new file mode 100644
index 0000000..e879371
--- /dev/null
+++ b/shrine/router/sample.go
@@ -0,0 +1,14 @@
+package router
+
+import (
+ "shrine/controllers"
+ "shrine/types"
+ "shrine/utils/auth"
+ "shrine/utils/urls"
+)
+
+func init() {
+ urls.SetNamespace("sample")
+
+ urls.Path(types.GET, "/hello", auth.RequireAuthentication(controllers.HelloController), "hello")
+}
diff --git a/shrine/shrine/main.go b/shrine/shrine/main.go
new file mode 100644
index 0000000..45ca105
--- /dev/null
+++ b/shrine/shrine/main.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/signal"
+ "shrine/config"
+ "shrine/database"
+ "shrine/middleware"
+ "shrine/router"
+ "shrine/utils/logger"
+ "syscall"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/cors"
+ "github.com/gofiber/fiber/v2/middleware/helmet"
+)
+
+func main() {
+ app := fiber.New(fiber.Config{
+ DisableStartupMessage: true,
+ })
+ app.Use(cors.New(cors.Config{
+ AllowOrigins: "*",
+ AllowMethods: "GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS",
+ AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Requested-With, X-API-Key, X-CSRF-Token",
+ ExposeHeaders: "Content-Length, Content-Type, Content-Disposition, X-Pagination, X-Total-Count",
+ MaxAge: 86400,
+ }))
+ app.Use(helmet.New())
+
+ middleware.Initialize(app)
+ router.Initialize(app)
+
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ if err := app.Listen(fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)); err != nil {
+ logger.Fatalf("Main", "Failed to start the server on %s:%d: %v", config.Server.Host, config.Server.Port, err)
+ }
+ }()
+
+ logger.Successf("Main", "Server started on %s:%d", config.Server.Host, config.Server.Port)
+
+ <-quit
+ logger.Infof("Main", "Shutting down gracefully...")
+
+ if err := app.Shutdown(); err != nil {
+ logger.Errorf("Main", "Error during server shutdown: %v", err)
+ }
+
+ if err := database.Close(); err != nil {
+ logger.Errorf("Main", "Error closing database connection: %v", err)
+ }
+
+ logger.Successf("Main", "Shutdown complete")
+}
diff --git a/shrine/types/http.go b/shrine/types/http.go
new file mode 100644
index 0000000..ee4eb70
--- /dev/null
+++ b/shrine/types/http.go
@@ -0,0 +1,29 @@
+package types
+
+type HTTPMethod string
+
+const (
+ GET HTTPMethod = "GET"
+ POST HTTPMethod = "POST"
+ PUT HTTPMethod = "PUT"
+ PATCH HTTPMethod = "PATCH"
+ DELETE HTTPMethod = "DELETE"
+ OPTIONS HTTPMethod = "OPTIONS"
+ HEAD HTTPMethod = "HEAD"
+)
+
+type HTTPParam struct {
+ Key string
+ Value string
+}
+
+type Request struct {
+ Path string
+ Method string
+ Query []HTTPParam
+ Params []HTTPParam
+ Headers []HTTPParam
+ QueryString string
+ IP string
+ URL string
+}
diff --git a/shrine/types/response.go b/shrine/types/response.go
new file mode 100644
index 0000000..3d267bc
--- /dev/null
+++ b/shrine/types/response.go
@@ -0,0 +1,9 @@
+package types
+
+type ErrorResponse struct {
+ Error string `json:"error"`
+}
+
+type HelloResponse struct {
+ Message string `json:"message"`
+}
diff --git a/shrine/utils/auth/auth.go b/shrine/utils/auth/auth.go
new file mode 100644
index 0000000..068ddd7
--- /dev/null
+++ b/shrine/utils/auth/auth.go
@@ -0,0 +1,17 @@
+package auth
+
+import "github.com/gofiber/fiber/v2"
+
+func IsAuthenticated(context *fiber.Ctx) bool {
+ // We will implement token based authentication in the future
+ return true
+}
+
+func RequireAuthentication(handler fiber.Handler) fiber.Handler {
+ return func(context *fiber.Ctx) error {
+ if !IsAuthenticated(context) {
+ return fiber.ErrUnauthorized
+ }
+ return handler(context)
+ }
+}
diff --git a/shrine/utils/env/functions.go b/shrine/utils/env/functions.go
new file mode 100644
index 0000000..de03793
--- /dev/null
+++ b/shrine/utils/env/functions.go
@@ -0,0 +1,97 @@
+package env
+
+import (
+ "os"
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func getEnv(key, defaultVal string) string {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ return defaultVal
+}
+
+func getEnvBool(key string, defaultVal bool) bool {
+ if value := os.Getenv(key); value != "" {
+ if parsed, err := strconv.ParseBool(value); err == nil {
+ return parsed
+ }
+ }
+ return defaultVal
+}
+
+func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
+ if value := os.Getenv(key); value != "" {
+ if parsed, err := time.ParseDuration(value); err == nil {
+ return parsed
+ }
+ }
+ return defaultVal
+}
+
+func getEnvInt(key string, defaultVal int64) int64 {
+ if value := os.Getenv(key); value != "" {
+ if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
+ return parsed
+ }
+ }
+ return defaultVal
+}
+
+func getEnvFloat(key string, defaultVal float64) float64 {
+ if value := os.Getenv(key); value != "" {
+ if parsed, err := strconv.ParseFloat(value, 64); err == nil {
+ return parsed
+ }
+ }
+ return defaultVal
+}
+
+func getEnvStringSlice(key string, defaultVal []string) []string {
+ if value := os.Getenv(key); value != "" {
+ parts := strings.Split(value, ",")
+ result := make([]string, 0, len(parts))
+ for _, part := range parts {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ result = append(result, trimmed)
+ }
+ }
+ return result
+ }
+ return defaultVal
+}
+
+func Defaults[T any](config *T) *T {
+ v := reflect.ValueOf(config)
+ if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
+ return config
+ }
+
+ elem := v.Elem()
+ t := elem.Type()
+ newStruct := reflect.New(t)
+ newElem := newStruct.Elem()
+
+ for i := range elem.NumField() {
+ field := newElem.Field(i)
+ fieldType := t.Field(i)
+
+ if !field.CanSet() {
+ continue
+ }
+
+ defaultVal := fieldType.Tag.Get("default")
+ if defaultVal == "" {
+ continue
+ }
+
+ setFieldDefault(field, defaultVal)
+ }
+
+ return newStruct.Interface().(*T)
+}
diff --git a/shrine/utils/env/parser.go b/shrine/utils/env/parser.go
new file mode 100644
index 0000000..c1fdb53
--- /dev/null
+++ b/shrine/utils/env/parser.go
@@ -0,0 +1,28 @@
+package env
+
+func Parse(config any) error {
+ elem, t, err := validateConfigInput(config)
+ if err != nil {
+ return err
+ }
+
+ for i := range elem.NumField() {
+ field := elem.Field(i)
+ fieldType := t.Field(i)
+
+ if !field.CanSet() {
+ continue
+ }
+
+ envKey := fieldType.Tag.Get("env")
+ defaultVal := fieldType.Tag.Get("default")
+
+ if envKey == "" {
+ continue
+ }
+
+ setFieldFromEnv(field, envKey, defaultVal)
+ }
+
+ return nil
+}
diff --git a/shrine/utils/env/setter.go b/shrine/utils/env/setter.go
new file mode 100644
index 0000000..8b53a1d
--- /dev/null
+++ b/shrine/utils/env/setter.go
@@ -0,0 +1,107 @@
+package env
+
+import (
+ "os"
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func setFieldFromEnv(field reflect.Value, envKey, defaultVal string) {
+ switch field.Kind() {
+ case reflect.String:
+ field.SetString(getEnv(envKey, defaultVal))
+ case reflect.Bool:
+ defaultBool, _ := strconv.ParseBool(defaultVal)
+ field.SetBool(getEnvBool(envKey, defaultBool))
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ defaultInt, _ := strconv.ParseInt(defaultVal, 10, 64)
+ field.SetInt(getEnvInt(envKey, defaultInt))
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ defaultUint, _ := strconv.ParseUint(defaultVal, 10, 64)
+ setUintField(field, envKey, defaultUint)
+ case reflect.Float32, reflect.Float64:
+ defaultFloat, _ := strconv.ParseFloat(defaultVal, 64)
+ field.SetFloat(getEnvFloat(envKey, defaultFloat))
+ case reflect.Slice:
+ setSliceField(field, envKey, defaultVal)
+ default:
+ setDurationField(field, envKey, defaultVal)
+ }
+}
+
+func setUintField(field reflect.Value, envKey string, defaultVal uint64) {
+ if value := os.Getenv(envKey); value != "" {
+ if parsed, err := strconv.ParseUint(value, 10, 64); err == nil {
+ field.SetUint(parsed)
+ return
+ }
+ }
+ field.SetUint(defaultVal)
+}
+
+func setDurationField(field reflect.Value, envKey, defaultVal string) {
+ if field.Type() == reflect.TypeFor[time.Duration]() {
+ defaultDuration, _ := time.ParseDuration(defaultVal)
+ field.Set(reflect.ValueOf(getEnvDuration(envKey, defaultDuration)))
+ }
+}
+
+func setSliceField(field reflect.Value, envKey, defaultVal string) {
+ if field.Type().Elem().Kind() == reflect.String {
+ var defaultSlice []string
+ if defaultVal != "" {
+ parts := strings.SplitSeq(defaultVal, ",")
+ for part := range parts {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ defaultSlice = append(defaultSlice, trimmed)
+ }
+ }
+ }
+ result := getEnvStringSlice(envKey, defaultSlice)
+ field.Set(reflect.ValueOf(result))
+ }
+}
+
+func setFieldDefault(field reflect.Value, defaultVal string) {
+ switch field.Kind() {
+ case reflect.String:
+ field.SetString(defaultVal)
+ case reflect.Bool:
+ if defaultBool, err := strconv.ParseBool(defaultVal); err == nil {
+ field.SetBool(defaultBool)
+ }
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ if defaultInt, err := strconv.ParseInt(defaultVal, 10, 64); err == nil {
+ field.SetInt(defaultInt)
+ }
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ if defaultUint, err := strconv.ParseUint(defaultVal, 10, 64); err == nil {
+ field.SetUint(defaultUint)
+ }
+ case reflect.Float32, reflect.Float64:
+ if defaultFloat, err := strconv.ParseFloat(defaultVal, 64); err == nil {
+ field.SetFloat(defaultFloat)
+ }
+ case reflect.Slice:
+ if field.Type().Elem().Kind() == reflect.String && defaultVal != "" {
+ parts := strings.Split(defaultVal, ",")
+ result := make([]string, 0, len(parts))
+ for _, part := range parts {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ result = append(result, trimmed)
+ }
+ }
+ field.Set(reflect.ValueOf(result))
+ }
+ default:
+ if field.Type() == reflect.TypeFor[time.Duration]() {
+ if defaultDuration, err := time.ParseDuration(defaultVal); err == nil {
+ field.Set(reflect.ValueOf(defaultDuration))
+ }
+ }
+ }
+}
diff --git a/shrine/utils/env/validator.go b/shrine/utils/env/validator.go
new file mode 100644
index 0000000..fa9b17f
--- /dev/null
+++ b/shrine/utils/env/validator.go
@@ -0,0 +1,15 @@
+package env
+
+import (
+ "fmt"
+ "reflect"
+)
+
+func validateConfigInput(config any) (reflect.Value, reflect.Type, error) {
+ v := reflect.ValueOf(config)
+ if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
+ return reflect.Value{}, nil, fmt.Errorf("config must be a pointer to struct")
+ }
+ elem := v.Elem()
+ return elem, elem.Type(), nil
+}
diff --git a/shrine/utils/logger/logger.go b/shrine/utils/logger/logger.go
new file mode 100644
index 0000000..9fc1bc7
--- /dev/null
+++ b/shrine/utils/logger/logger.go
@@ -0,0 +1,145 @@
+package logger
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+)
+
+const prefixWidth = 15
+
+var (
+ loggerInstance *zap.Logger
+ level zap.AtomicLevel
+ showTimestamp bool
+)
+
+func timeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
+ enc.AppendString(Gray + t.Format(time.RFC3339) + Reset)
+}
+
+func levelEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
+ switch l {
+ case zapcore.DebugLevel:
+ enc.AppendString(LevelColorDebug)
+ case zapcore.WarnLevel:
+ enc.AppendString(LevelColorWarn)
+ case zapcore.ErrorLevel:
+ enc.AppendString(LevelColorError)
+ default:
+ enc.AppendString(LevelColorInfo)
+ }
+}
+
+func formatPrefix(prefix string) string {
+ if prefix == "" {
+ return ""
+ }
+
+ padding := ""
+ if len(prefix) < prefixWidth {
+ padding = strings.Repeat(" ", prefixWidth-len(prefix))
+ }
+
+ return Cyan + "[" + prefix + "]" + Reset + padding
+}
+
+func colorMessage(level LogLevel, msg string) string {
+ switch level {
+ case Debug:
+ return MessageColorDebug + msg + Reset
+ case Warn:
+ return MessageColorWarn + msg + Reset
+ case Error:
+ return MessageColorError + msg + Reset
+ case Success:
+ return MessageColorSuccess + msg + Reset
+ default:
+ return MessageColorInfo + msg + Reset
+ }
+}
+
+func Init() {
+ level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
+
+ encoderCfg := zapcore.EncoderConfig{
+ LevelKey: "level",
+ MessageKey: "msg",
+ LineEnding: "\n",
+ EncodeLevel: levelEncoder,
+ }
+
+ if showTimestamp {
+ encoderCfg.TimeKey = "ts"
+ encoderCfg.EncodeTime = timeEncoder
+ }
+
+ encoder := zapcore.NewConsoleEncoder(encoderCfg)
+
+ stdout := zapcore.AddSync(os.Stdout)
+ stderr := zapcore.AddSync(os.Stderr)
+
+ core := zapcore.NewTee(
+ zapcore.NewCore(encoder, stdout, zap.LevelEnablerFunc(func(l zapcore.Level) bool {
+ return l < zapcore.WarnLevel && level.Enabled(l)
+ })),
+ zapcore.NewCore(encoder, stderr, zap.LevelEnablerFunc(func(l zapcore.Level) bool {
+ return l >= zapcore.WarnLevel && level.Enabled(l)
+ })),
+ )
+
+ loggerInstance = zap.New(core, zap.AddCaller())
+}
+
+func SetTimestamp(enabled bool) {
+ showTimestamp = enabled
+}
+
+func SetDebug(enabled bool) {
+ if enabled {
+ level.SetLevel(zapcore.DebugLevel)
+ } else {
+ level.SetLevel(zapcore.InfoLevel)
+ }
+}
+
+func Debugf(prefix, format string, args ...any) {
+ log(Debug, zapcore.DebugLevel, prefix, fmt.Sprintf(format, args...))
+}
+
+func Infof(prefix, format string, args ...any) {
+ log(Info, zapcore.InfoLevel, prefix, fmt.Sprintf(format, args...))
+}
+
+func Successf(prefix, format string, args ...any) {
+ log(Success, zapcore.InfoLevel, prefix, fmt.Sprintf(format, args...))
+}
+
+func Warnf(prefix, format string, args ...any) {
+ log(Warn, zapcore.WarnLevel, prefix, fmt.Sprintf(format, args...))
+}
+
+func Errorf(prefix, format string, args ...any) {
+ log(Error, zapcore.ErrorLevel, prefix, fmt.Sprintf(format, args...))
+}
+
+func Fatalf(prefix, format string, args ...any) {
+ log(Error, zapcore.ErrorLevel, prefix, fmt.Sprintf(format, args...))
+ os.Exit(1)
+}
+
+func log(levelLabel LogLevel, zapLevel zapcore.Level, prefix string, msg any) {
+ if loggerInstance == nil {
+ panic("logger.Init() was not called")
+ }
+
+ message := fmt.Sprint(msg)
+ colored := colorMessage(levelLabel, message)
+ fullMessage := formatPrefix(prefix) + colored
+
+ loggerInstance.Log(zapLevel, fullMessage)
+}
diff --git a/shrine/utils/logger/types.go b/shrine/utils/logger/types.go
new file mode 100644
index 0000000..9a92b01
--- /dev/null
+++ b/shrine/utils/logger/types.go
@@ -0,0 +1,29 @@
+package logger
+
+type LogLevel string
+
+const (
+ Debug LogLevel = "debug"
+ Info LogLevel = "info"
+ Warn LogLevel = "warn"
+ Error LogLevel = "error"
+ Success LogLevel = "success"
+)
+
+const (
+ Reset = "\033[0m"
+ Cyan = "\033[36m"
+ Gray = "\033[90m"
+
+ LevelColorInfo = "\033[34mINFO \033[0m"
+ LevelColorWarn = "\033[33mWARN \033[0m"
+ LevelColorError = "\033[31mERROR \033[0m"
+ LevelColorDebug = "\033[35mDEBUG \033[0m"
+ LevelColorSuccess = "\033[32mSUCCESS\033[0m"
+
+ MessageColorInfo = "\033[97m"
+ MessageColorWarn = "\033[33m"
+ MessageColorError = "\033[31m"
+ MessageColorDebug = "\033[90m"
+ MessageColorSuccess = "\033[32m"
+)
diff --git a/shrine/utils/meta/builder.go b/shrine/utils/meta/builder.go
new file mode 100644
index 0000000..11d9d24
--- /dev/null
+++ b/shrine/utils/meta/builder.go
@@ -0,0 +1,20 @@
+package meta
+
+import (
+ "shrine/types"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func BuildRequest(c *fiber.Ctx) types.Request {
+ return types.Request{
+ Path: c.Path(),
+ Method: c.Method(),
+ Query: buildQueryParams(c),
+ Params: buildRouteParams(c),
+ Headers: buildHeaders(c),
+ QueryString: string(c.Request().URI().QueryString()),
+ IP: c.IP(),
+ URL: c.OriginalURL(),
+ }
+}
diff --git a/shrine/utils/meta/functions.go b/shrine/utils/meta/functions.go
new file mode 100644
index 0000000..5bcc550
--- /dev/null
+++ b/shrine/utils/meta/functions.go
@@ -0,0 +1,40 @@
+package meta
+
+import (
+ "shrine/types"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func buildQueryParams(c *fiber.Ctx) []types.HTTPParam {
+ params := make([]types.HTTPParam, 0)
+ c.Request().URI().QueryArgs().VisitAll(func(k, v []byte) {
+ params = append(params, types.HTTPParam{
+ Key: string(k),
+ Value: string(v),
+ })
+ })
+ return params
+}
+
+func buildRouteParams(c *fiber.Ctx) []types.HTTPParam {
+ params := make([]types.HTTPParam, 0)
+ for k, v := range c.AllParams() {
+ params = append(params, types.HTTPParam{
+ Key: k,
+ Value: v,
+ })
+ }
+ return params
+}
+
+func buildHeaders(c *fiber.Ctx) []types.HTTPParam {
+ params := make([]types.HTTPParam, 0)
+ c.Request().Header.VisitAll(func(k, v []byte) {
+ params = append(params, types.HTTPParam{
+ Key: string(k),
+ Value: string(v),
+ })
+ })
+ return params
+}
diff --git a/shrine/utils/meta/request.go b/shrine/utils/meta/request.go
new file mode 100644
index 0000000..d2ae242
--- /dev/null
+++ b/shrine/utils/meta/request.go
@@ -0,0 +1,107 @@
+package meta
+
+import (
+ "shrine/types"
+ "shrine/utils/logger"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+const requestKey = "__request_ctx"
+
+func Request(c *fiber.Ctx) facade {
+ req, ok := c.Locals(requestKey).(types.Request)
+ if !ok {
+ logger.Errorf("META", "RequestContext missing in fiber locals")
+ return facade{}
+ }
+ return facade{req: req, ctx: c}
+}
+
+func (f facade) Param(key string) (string, bool) {
+ if f.ctx != nil {
+ val := f.ctx.Params(key)
+ if val != "" {
+ return val, true
+ }
+ }
+ return "", false
+}
+
+func (f facade) Query(key string) (string, bool) {
+ for _, q := range f.req.Query {
+ if q.Key == key {
+ return q.Value, true
+ }
+ }
+ return "", false
+}
+
+func (f facade) Header(key string) (string, bool) {
+ for _, h := range f.req.Headers {
+ if h.Key == key {
+ return h.Value, true
+ }
+ }
+ return "", false
+}
+
+func (r required) Param(key string) string {
+ // Access params directly from fiber context (available after route matching)
+ if r.ctx != nil {
+ val := r.ctx.Params(key)
+ if val != "" {
+ return val
+ }
+ }
+ logger.Errorf("META", "missing required param: %s", key)
+ return ""
+}
+
+func (r required) Query(key string) string {
+ for _, q := range r.req.Query {
+ if q.Key == key {
+ return q.Value
+ }
+ }
+ logger.Errorf("META", "missing required query: %s", key)
+ return ""
+}
+
+func (r required) Header(key string) string {
+ for _, h := range r.req.Headers {
+ if h.Key == key {
+ return h.Value
+ }
+ }
+ logger.Errorf("META", "missing required header: %s", key)
+ return ""
+}
+
+func (d withDefault) Param(key string) string {
+ if d.ctx != nil {
+ val := d.ctx.Params(key)
+ if val != "" {
+ return val
+ }
+ }
+ return d.def
+}
+
+func (d withDefault) Query(key string) string {
+ for _, q := range d.req.Query {
+ if q.Key == key {
+ return q.Value
+ }
+ }
+ return d.def
+}
+
+func (d withDefault) Header(key string) string {
+ for _, h := range d.req.Headers {
+ if h.Key == key {
+ return h.Value
+ }
+ }
+ return d.def
+}
diff --git a/shrine/utils/meta/types.go b/shrine/utils/meta/types.go
new file mode 100644
index 0000000..4d8be9b
--- /dev/null
+++ b/shrine/utils/meta/types.go
@@ -0,0 +1,23 @@
+package meta
+
+import (
+ "shrine/types"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type facade struct {
+ req types.Request
+ ctx *fiber.Ctx
+}
+
+type required struct {
+ req types.Request
+ ctx *fiber.Ctx
+}
+
+type withDefault struct {
+ req types.Request
+ ctx *fiber.Ctx
+ def string
+}
diff --git a/shrine/utils/meta/value.go b/shrine/utils/meta/value.go
new file mode 100644
index 0000000..59345d3
--- /dev/null
+++ b/shrine/utils/meta/value.go
@@ -0,0 +1,9 @@
+package meta
+
+func (f facade) MustHave() required {
+ return required{req: f.req, ctx: f.ctx}
+}
+
+func (f facade) Default(def string) withDefault {
+ return withDefault{req: f.req, ctx: f.ctx, def: def}
+}
diff --git a/shrine/utils/shortcuts/response.go b/shrine/utils/shortcuts/response.go
new file mode 100644
index 0000000..e90f16b
--- /dev/null
+++ b/shrine/utils/shortcuts/response.go
@@ -0,0 +1,16 @@
+package shortcuts
+
+import "github.com/gofiber/fiber/v2"
+
+func Response(ctx *fiber.Ctx, data any) *response {
+ return &response{
+ ctx: ctx,
+ data: data,
+ status: fiber.StatusOK,
+ }
+}
+
+func (r *response) As(status int) error {
+ r.status = status
+ return r.ctx.Status(status).JSON(r.data)
+}
diff --git a/shrine/utils/shortcuts/types.go b/shrine/utils/shortcuts/types.go
new file mode 100644
index 0000000..122ad13
--- /dev/null
+++ b/shrine/utils/shortcuts/types.go
@@ -0,0 +1,9 @@
+package shortcuts
+
+import "github.com/gofiber/fiber/v2"
+
+type response struct {
+ ctx *fiber.Ctx
+ data any
+ status int
+}
diff --git a/shrine/utils/urls/attach.go b/shrine/utils/urls/attach.go
new file mode 100644
index 0000000..e9b0a66
--- /dev/null
+++ b/shrine/utils/urls/attach.go
@@ -0,0 +1,38 @@
+package urls
+
+import (
+ "shrine/types"
+ "shrine/utils/logger"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+var methodBinders = map[types.HTTPMethod]func(fiber.Router, string, fiber.Handler) fiber.Router{
+ types.GET: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Get(path, h) },
+ types.POST: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Post(path, h) },
+ types.PUT: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Put(path, h) },
+ types.PATCH: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Patch(path, h) },
+ types.DELETE: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Delete(path, h) },
+ types.OPTIONS: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Options(path, h) },
+ types.HEAD: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Head(path, h) },
+}
+
+func Attach(app *fiber.App) {
+ namespaceGroups := make(map[string]fiber.Router)
+
+ for fullName, route := range registry.routes {
+ group, exists := namespaceGroups[route.namespace]
+ if !exists {
+ group = app.Group("/" + route.namespace)
+ namespaceGroups[route.namespace] = group
+ }
+
+ binder, ok := methodBinders[route.method]
+ if !ok {
+ logger.Fatalf("URLs", "Unsupported HTTP method: %s for route %s", route.method, fullName)
+ }
+
+ fiberRoute := binder(group, route.path, route.handler)
+ fiberRoute.Name(fullName)
+ }
+}
diff --git a/shrine/utils/urls/namespace.go b/shrine/utils/urls/namespace.go
new file mode 100644
index 0000000..7bb5311
--- /dev/null
+++ b/shrine/utils/urls/namespace.go
@@ -0,0 +1,7 @@
+package urls
+
+func SetNamespace(namespace string) {
+ registry.mutex.Lock()
+ defer registry.mutex.Unlock()
+ registry.currentNamespace = namespace
+}
diff --git a/shrine/utils/urls/path.go b/shrine/utils/urls/path.go
new file mode 100644
index 0000000..fe5219d
--- /dev/null
+++ b/shrine/utils/urls/path.go
@@ -0,0 +1,51 @@
+package urls
+
+import (
+ "shrine/types"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Path(method types.HTTPMethod, path string, handler fiber.Handler, name string) {
+ registry.mutex.Lock()
+ defer registry.mutex.Unlock()
+
+ namespace := registry.currentNamespace
+ fullName := name
+ fullPath := path
+
+ if namespace != "" {
+ if !strings.HasPrefix(path, "/") {
+ path = "/" + path
+ }
+
+ fullName = namespace + "." + name
+ fullPath = "/" + namespace + path
+ } else {
+ if !strings.HasPrefix(fullPath, "/") {
+ fullPath = "/" + fullPath
+ }
+ }
+
+ registry.routes[fullName] = registeredRoute{
+ method: method,
+ path: path,
+ handler: handler,
+ namespace: namespace,
+ name: name,
+ fullPath: fullPath,
+ }
+}
+
+func GetFullPath(routeName string) (string, bool) {
+ registry.mutex.Lock()
+ defer registry.mutex.Unlock()
+
+ route, ok := registry.routes[routeName]
+ if !ok {
+ return "", false
+ }
+
+ return route.fullPath, true
+}
diff --git a/shrine/utils/urls/registry.go b/shrine/utils/urls/registry.go
new file mode 100644
index 0000000..c8c0252
--- /dev/null
+++ b/shrine/utils/urls/registry.go
@@ -0,0 +1,5 @@
+package urls
+
+var registry = &routeRegistry{
+ routes: make(map[string]registeredRoute),
+}
diff --git a/shrine/utils/urls/types.go b/shrine/utils/urls/types.go
new file mode 100644
index 0000000..0913ff9
--- /dev/null
+++ b/shrine/utils/urls/types.go
@@ -0,0 +1,23 @@
+package urls
+
+import (
+ "shrine/types"
+ "sync"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type registeredRoute struct {
+ method types.HTTPMethod
+ path string
+ handler fiber.Handler
+ namespace string
+ name string
+ fullPath string
+}
+
+type routeRegistry struct {
+ mutex sync.Mutex
+ currentNamespace string
+ routes map[string]registeredRoute
+}