diff options
| author | Bobby <[email protected]> | 2026-03-03 16:02:28 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-03 16:02:28 +0530 |
| commit | c370e302edf99c5a230800b2ff6f4612642121d8 (patch) | |
| tree | 23eda9be6873e2663719981fae87d70aee3ef037 | |
| parent | eb332a9697e80d8ba138e51cc8de234dad59b0bd (diff) | |
| download | pagoda-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.
40 files changed, 1427 insertions, 6 deletions
@@ -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 +} |
