From 2b9a75515d06fc3de71648910f89c11f4527fe33 Mon Sep 17 00:00:00 2001 From: "Faiz A. Farooqui" Date: Tue, 24 Sep 2024 14:39:59 +0530 Subject: [PATCH] chore: adding init setup --- .env.example | 14 ++ .gitignore | 45 +++++ Makefile | 17 ++ README.md | 133 ++++++++++++++ air.toml | 44 +++++ cmd/generate_validators/main.go | 98 +++++++++++ cmd/migrate/main.go | 54 ++++++ config/database.go | 40 +++++ controllers/auth_controller.go | 92 ++++++++++ controllers/item_controller.go | 55 ++++++ database/migrate.go | 165 ++++++++++++++++++ .../000001_create_items_table.down.sql | 2 + .../000001_create_items_table.up.sql | 5 + .../000002_create_users_table.down.sql | 2 + .../000002_create_users_table.up.sql | 9 + go.mod | 55 ++++++ main.go | 73 ++++++++ middlewares/error_handler.go | 101 +++++++++++ models/item.go | 6 + models/user.go | 13 ++ repositories/item_repository.go | 51 ++++++ repositories/user_repository.go | 43 +++++ routes/routes.go | 32 ++++ services/auth_service.go | 59 +++++++ services/item_service.go | 30 ++++ tools.go | 8 + utils/password.go | 14 ++ validators/auth_validator.go | 14 ++ validators/item_validator.go | 5 + validators/register.go | 10 ++ 30 files changed, 1289 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 air.toml create mode 100755 cmd/generate_validators/main.go create mode 100644 cmd/migrate/main.go create mode 100644 config/database.go create mode 100644 controllers/auth_controller.go create mode 100644 controllers/item_controller.go create mode 100644 database/migrate.go create mode 100644 database/migrations/000001_create_items_table.down.sql create mode 100644 database/migrations/000001_create_items_table.up.sql create mode 100644 database/migrations/000002_create_users_table.down.sql create mode 100644 database/migrations/000002_create_users_table.up.sql create mode 100644 go.mod create mode 100644 main.go create mode 100644 middlewares/error_handler.go create mode 100644 models/item.go create mode 100644 models/user.go create mode 100644 repositories/item_repository.go create mode 100644 repositories/user_repository.go create mode 100644 routes/routes.go create mode 100644 services/auth_service.go create mode 100644 services/item_service.go create mode 100644 tools.go create mode 100644 utils/password.go create mode 100644 validators/auth_validator.go create mode 100644 validators/item_validator.go create mode 100644 validators/register.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..986ec4d --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ + +# Available modes: debug, release or test +GIN_MODE=debug + +# The port that the application will run on +APP_PORT=3000 + +# Database configuration +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=goldtree9 +DB_NAME=postgres +DB_SSLMODE=disable diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23ad1dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib +*.bin + +# Output from Air (temporary binary and logs) +tmp/ +air.log + +# Go environment settings +.env + +# Dependency directories +vendor/ +Godeps/ + +# Logs and temporary files +*.log +*.out +*.tmp +*.swp +*.swo +*.bak + +# MacOS-specific files +.DS_Store + +# Ignore Go modules (if you're vendoring dependencies, this is not needed) +go.sum + +# IDE/Editor-specific files (optional, based on your IDE) +.idea/ +.vscode/ +*.sublime-workspace +*.sublime-project + +# Generated code (like auto-generated validators) +validators/auto_generated.go + +# Go build output +*.o +*.a +*.out diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1399617 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +generate: + go generate ./validators + +migrate-up: + go run cmd/migrate/main.go -up + +migrate-down: + go run cmd/migrate/main.go -down + +build: + go build -o ./tmp/main . + +run: + go run main.go + +develop: + air diff --git a/README.md b/README.md new file mode 100644 index 0000000..d382e61 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Go-Gin API Server + +This project is a simple API server built with [Go](https://golang.org/) using the [Gin Web Framework](https://github.com/gin-gonic/gin). The project supports live reloading during development using **Air** and has an integrated migration system. + +## Table of Contents +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Development](#development) +- [Database Migrations](#database-migrations) + - [Apply Migrations](#apply-migrations) + - [Rollback Migrations](#rollback-migrations) +- [Configuration](#configuration) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Prerequisites + +Before setting up the project, ensure you have the following installed: + +- [Go 1.17+](https://golang.org/dl/) +- [PostgreSQL](https://www.postgresql.org/) (or your preferred database) +- [Air](https://github.com/air-verse/air) for live-reloading (installed as a dependency) + +## Installation + +1. **Clone the repository**: + ```bash + git clone https://github.com/yourusername/go-gin-api-server.git + cd go-gin-api-server + ``` + +2. **Install dependencies**: Ensure all Go dependencies and tools (including **Air**) are installed. + ```bash + go mod tidy + go install github.com/air-verse/air@latest + ``` + +3. **Setup environment variables**: Create a `.env` file with your database and other environment variables. + ```bash + cp .env.example .env + ``` + + Example `.env` file: + ```ini + # Available modes: debug, release or test + GIN_MODE=debug + + # The port that the application will run on + APP_PORT=3000 + + # Database configuration + DB_HOST=localhost + DB_PORT=5432 + DB_USER=postgres + DB_PASSWORD=goldtree9 + DB_NAME=postgres + DB_SSLMODE=disable + ``` + +## Development + +To start the server in development mode with live-reloading (using **Air**): + +```bash +make develop +``` + +## Database Migrations + +The project includes a migration system for managing database schema changes: + +### Apply Migrations + +To apply all pending migrations, run: + +```bash +make migrate-up +``` + +### Rollback Migrations + +To rollback the last migration, run: + +```bash +make migrate-down +``` + +Migration files are located in the `database/migrations/` directory. +- **Up Migration**: Files ending in `.up.sql` are used for applying changes. +- **Down Migration**: Files ending in `.down.sql` are used for rolling back changes. + +## Configuration + +### air.toml (Development Mode) + +The `air.toml` file is used for configuring the **Air** live-reloading tool. It watches specific directories and file types, such as `.go` and `.html`, to automatically rebuild and restart the server during development. + +You can modify `air.toml` to suit your development workflow, for example: + +```toml +[build] + cmd = "go build -o ./tmp/main ." + bin = "./tmp/main" + delay = 1000 + tmp_dir = "tmp" + +[watch] + includes = ["./controllers", "./routes", "./services", "./validators", "./config"] + include_ext = ["go", "html", "tmpl", "tpl"] + exclude_dir = ["vendor", "tmp", "database/migrations"] + +[log] + level = "info" +``` + +## Contributing + +If you would like to contribute to the project: + +1. Fork the repository. +2. Create a new branch (git checkout -b feature-branch). +3. Commit your changes (git commit -am 'Add new feature'). +4. Push to the branch (git push origin feature-branch). +5. Create a new Pull Request. + +All contributions are welcome! + +## License + +This project is open-source and available under the [MIT License](LICENSE). + diff --git a/air.toml b/air.toml new file mode 100644 index 0000000..ce7c139 --- /dev/null +++ b/air.toml @@ -0,0 +1,44 @@ +# air.toml +[build] + # Command to build the app (default: "go build -o ./tmp/main .") + cmd = "go build -o ./tmp/main ." + + # Binary that will be built and run (default: "./tmp/main") + bin = "./tmp/main" + + # Delay before restarting the app after a change is detected (in milliseconds) + delay = 1000 # 1 second delay + + # Specify directories for the temporary binary files (default: "tmp") + tmp_dir = "tmp" + +[log] + # Log level (default: "info") + level = "info" + +[watch] + # Directories or files to include in watch (default: current directory) + # You can add specific folders you want to watch. + includes = [ + "./cmd", + "./config", + "./controllers", + "./database", + "./middlewares", + "./models", + "./repositories", + "./routes", + "./services", + "./utils", + "./validators" + ] + + # File extensions to watch (default: ["go"]) + include_ext = ["go", "html", "tmpl", "tpl", "sql"] + + # Exclude certain directories from being watched + exclude_dir = ["vendor", "tmp", "database/migrations"] + +[ignore] + # List directories or files to be ignored from watching + dirs = ["tmp", "vendor"] diff --git a/cmd/generate_validators/main.go b/cmd/generate_validators/main.go new file mode 100755 index 0000000..ade6173 --- /dev/null +++ b/cmd/generate_validators/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "html/template" + "log" + "os" + "path/filepath" +) + +// Template for the auto-generated registration code +const tpl = `// Code generated by go generate; DO NOT EDIT. +package validators + +import "reflect" + +func init() { + {{- range .Structs }} + RegisterValidator(reflect.TypeOf({{ . }}{}).Name(), {{ . }}{}) + {{- end }} +} +` + +func main() { + // Get the current working directory + wd, err := os.Getwd() + if err != nil { + log.Fatalf("Failed to get working directory: %v", err) + } + + // Resolve the absolute path to the validators directory + validatorsPath, err := filepath.Abs(filepath.Join(wd, "..", "validators")) + if err != nil { + log.Fatalf("Failed to resolve absolute path: %v", err) + } + + log.Printf("Current working directory: %s", wd) + log.Printf("Parsing validators directory at: %s", validatorsPath) + + // Parse the validators directory using the absolute path + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, validatorsPath, nil, parser.AllErrors) + if err != nil { + log.Fatalf("Failed to parse package at %s: %v", validatorsPath, err) + } + + // Collect all struct names + structs := []string{} + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + + for _, spec := range gen.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + // Check if the type is a struct + if _, ok := typeSpec.Type.(*ast.StructType); ok { + structs = append(structs, typeSpec.Name.Name) + } + } + } + } + } + + // Generate the output file using the absolute path + outputFilePath := filepath.Join(validatorsPath, "auto_generated.go") + file, err := os.Create(outputFilePath) + if err != nil { + log.Fatalf("Failed to create output file: %v", err) + } + defer file.Close() + + // Use the template to generate the file + tmpl, err := template.New("validators").Parse(tpl) + if err != nil { + log.Fatalf("Failed to parse template: %v", err) + } + + err = tmpl.Execute(file, struct { + Structs []string + }{ + Structs: structs, + }) + if err != nil { + log.Fatalf("Failed to execute template: %v", err) + } + + log.Printf("Auto-generated validator registration completed at: %s", outputFilePath) +} diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..a2b2b69 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "api-server/config" + "api-server/database" + "flag" + "log" + "path/filepath" +) + +func main() { + // Define CLI flags + migrateUp := flag.Bool("up", false, "Apply all pending migrations") + migrateDown := flag.Bool("down", false, "Rollback the last migration") + flag.Parse() + + // Load environment variables + if err := config.LoadEnv(); err != nil { + log.Fatalf("Error loading environment variables: %v", err) + } + + // Initialize the database connection + db, err := config.InitDB() + if err != nil { + log.Fatalf("Error initializing database: %v", err) + } + defer db.Close() + + // Set the migrations directory + migrationsDir := filepath.Join(".", "database", "migrations") + + // Apply migrations if the 'up' flag is set + if *migrateUp { + log.Println("Applying migrations...") + if err := database.ApplyMigrations(db, migrationsDir); err != nil { + log.Fatalf("Error applying migrations: %v", err) + } + log.Println("Migrations applied successfully") + return + } + + // Rollback the last migration if the 'down' flag is set + if *migrateDown { + log.Println("Rolling back the last migration...") + if err := database.RollbackLastMigration(db, migrationsDir); err != nil { + log.Fatalf("Error rolling back the migration: %v", err) + } + log.Println("Migration rolled back successfully") + return + } + + // If no flags are provided, print usage + flag.Usage() +} diff --git a/config/database.go b/config/database.go new file mode 100644 index 0000000..6be3edb --- /dev/null +++ b/config/database.go @@ -0,0 +1,40 @@ +package config + +import ( + "database/sql" + "fmt" + "os" + + "github.com/joho/godotenv" + _ "github.com/lib/pq" +) + +func LoadEnv() error { + err := godotenv.Load() + if err != nil { + return fmt.Errorf("error loading .env file: %w", err) + } + + return nil +} + +func InitDB() (*sql.DB, error) { + connStr := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=%s password=%s", + os.Getenv("DB_HOST"), + os.Getenv("DB_PORT"), + os.Getenv("DB_USER"), + os.Getenv("DB_NAME"), + os.Getenv("DB_SSLMODE"), + os.Getenv("DB_PASSWORD")) + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("error opening database: %w", err) + } + + if err = db.Ping(); err != nil { + return nil, fmt.Errorf("error connecting to database: %w", err) + } + + return db, nil +} diff --git a/controllers/auth_controller.go b/controllers/auth_controller.go new file mode 100644 index 0000000..03533a4 --- /dev/null +++ b/controllers/auth_controller.go @@ -0,0 +1,92 @@ +package controllers + +import ( + "database/sql" + "net/http" + "time" + + "api-server/models" + "api-server/utils" + "api-server/validators" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +type AuthController struct { + db *sql.DB +} + +// NewAuthController initializes AuthController with DB connection. +func NewAuthController(db *sql.DB) *AuthController { + return &AuthController{db: db} +} + +// Register handles user registration +func (ac *AuthController) Register(c *gin.Context) { + var input validators.RegisterUserValidator + + // Bind and validate the input JSON + if err := c.ShouldBindJSON(&input); err != nil { + // When there's a validation error, it will automatically caught by the middleware + c.Error(err).SetType(gin.ErrorTypeBind) // Set the error type to bind so it triggers the middleware + return + } + + // Check if the email already exists + var existingUser models.User + err := ac.db.QueryRow(`SELECT id FROM users WHERE email = $1`, input.Email).Scan(&existingUser.ID) + if err != sql.ErrNoRows { + c.JSON(http.StatusBadRequest, gin.H{"error": "email already in use"}) + return + } + + // Hash the password + hashedPassword, err := utils.HashPassword(input.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + + // Create the user + var newUser models.User + err = ac.db.QueryRow( + `INSERT INTO users (username, email, password_hash, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, username, email, created_at`, + input.Username, input.Email, hashedPassword, time.Now(), time.Now(), + ).Scan(&newUser.ID, &newUser.Username, &newUser.Email, &newUser.CreatedAt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) + return + } + + c.JSON(http.StatusCreated, newUser) +} + +// Login handles user login +func (ac *AuthController) Login(c *gin.Context) { + var input validators.LoginUserValidator + + // Bind and validate the input JSON + if err := c.ShouldBindJSON(&input); err != nil { + // When there's a validation error, it will automatically caught by the middleware + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) // Set the error type to bind so it triggers the middleware + return + } + + // Get the user by email + var user models.User + err := ac.db.QueryRow(`SELECT id, username, email, password_hash FROM users WHERE email = $1`, input.Email). + Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"}) + return + } + + // Check the password + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(input.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "login successful", "user": user}) +} diff --git a/controllers/item_controller.go b/controllers/item_controller.go new file mode 100644 index 0000000..aedeab2 --- /dev/null +++ b/controllers/item_controller.go @@ -0,0 +1,55 @@ +package controllers + +import ( + "database/sql" + "net/http" + + "api-server/services" + "api-server/validators" + + "github.com/gin-gonic/gin" +) + +type ItemController struct { + service *services.ItemService +} + +func NewItemController(db *sql.DB) *ItemController { + return &ItemController{ + service: services.NewItemService(db), + } +} + +func (ic *ItemController) GetItems(c *gin.Context) { + items, err := ic.service.GetAllItems() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, items) +} + +func (ic *ItemController) GetItem(c *gin.Context) { + id := c.Param("id") + item, err := ic.service.GetItemByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Item not found"}) + return + } + c.JSON(http.StatusOK, item) +} + +func (ic *ItemController) CreateItem(c *gin.Context) { + var input validators.CreateItemInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + item, err := ic.service.CreateItem(input.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, item) +} diff --git a/database/migrate.go b/database/migrate.go new file mode 100644 index 0000000..8fd22b0 --- /dev/null +++ b/database/migrate.go @@ -0,0 +1,165 @@ +package database + +import ( + "database/sql" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "strings" +) + +// ensureSchemaMigrationsTable ensures the schema_migrations table exists. +func ensureSchemaMigrationsTable(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY + ); + `) + return err +} + +// getAppliedMigrations returns the list of migrations already applied in the database. +func getAppliedMigrations(db *sql.DB) (map[string]bool, error) { + appliedMigrations := make(map[string]bool) + rows, err := db.Query(`SELECT id FROM schema_migrations`) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + appliedMigrations[id] = true + } + + return appliedMigrations, nil +} + +// getMigrationFiles reads all the migration files from the directory. +func getMigrationFiles(migrationsDir string) ([]string, error) { + var migrations []string + + err := filepath.WalkDir(migrationsDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasSuffix(d.Name(), ".up.sql") { + migrationID := strings.TrimSuffix(d.Name(), ".up.sql") + migrations = append(migrations, migrationID) + } + return nil + }) + + if err != nil { + return nil, err + } + + return migrations, nil +} + +// ApplyMigrations applies any pending migrations and checks for missing migration files. +func ApplyMigrations(db *sql.DB, migrationsDir string) error { + // Ensure the schema_migrations table exists + if err := ensureSchemaMigrationsTable(db); err != nil { + return fmt.Errorf("error ensuring schema_migrations table: %w", err) + } + + // Get applied migrations from the database + appliedMigrations, err := getAppliedMigrations(db) + if err != nil { + return fmt.Errorf("error fetching applied migrations: %w", err) + } + + // Get migration files from the directory + migrationFiles, err := getMigrationFiles(migrationsDir) + if err != nil { + return fmt.Errorf("error reading migration files: %w", err) + } + + // Check if any applied migration is missing from the directory + for appliedMigration := range appliedMigrations { + found := false + for _, migration := range migrationFiles { + if appliedMigration == migration { + found = true + break + } + } + if !found { + // If an applied migration is missing from the filesystem, throw an error and exit + return fmt.Errorf("critical error: migration %s is missing from the migrations directory", appliedMigration) + } + } + + // Apply pending migrations + for _, migrationID := range migrationFiles { + if !appliedMigrations[migrationID] { + upSQLPath := filepath.Join(migrationsDir, fmt.Sprintf("%s.up.sql", migrationID)) + + // Read the up migration SQL file + upSQL, err := os.ReadFile(upSQLPath) + if err != nil { + return fmt.Errorf("error reading migration file %s: %w", upSQLPath, err) + } + + // Apply the migration + log.Printf("Applying migration: %s", migrationID) + if _, err := db.Exec(string(upSQL)); err != nil { + return fmt.Errorf("error applying migration %s: %w", migrationID, err) + } + + // Record the applied migration + if _, err := db.Exec(`INSERT INTO schema_migrations (id) VALUES ($1)`, migrationID); err != nil { + return fmt.Errorf("error recording migration %s: %w", migrationID, err) + } + } + } + + log.Println("All migrations applied successfully") + return nil +} + +// RollbackLastMigration rolls back the last applied migration. +func RollbackLastMigration(db *sql.DB, migrationsDir string) error { + // Get the last applied migration + row := db.QueryRow(`SELECT id FROM schema_migrations ORDER BY id DESC LIMIT 1`) + var lastMigrationID string + if err := row.Scan(&lastMigrationID); err != nil { + if err == sql.ErrNoRows { + log.Println("No migrations found to rollback") + return nil + } + return err + } + + // Check if the migration file still exists + downSQLPath := filepath.Join(migrationsDir, fmt.Sprintf("%s.down.sql", lastMigrationID)) + if _, err := os.Stat(downSQLPath); os.IsNotExist(err) { + return fmt.Errorf("critical error: migration file %s is missing", downSQLPath) + } + + // Read the down migration SQL file + downSQL, err := os.ReadFile(downSQLPath) + if err != nil { + return fmt.Errorf("error reading migration file %s: %w", downSQLPath, err) + } + + // Rollback the migration + log.Printf("Rolling back migration: %s", lastMigrationID) + if _, err := db.Exec(string(downSQL)); err != nil { + return fmt.Errorf("error rolling back migration %s: %w", lastMigrationID, err) + } + + // Remove the migration from the schema_migrations table + if _, err := db.Exec(`DELETE FROM schema_migrations WHERE id = $1`, lastMigrationID); err != nil { + return fmt.Errorf("error deleting migration record %s: %w", lastMigrationID, err) + } + + log.Printf("Migration %s rolled back successfully", lastMigrationID) + return nil +} diff --git a/database/migrations/000001_create_items_table.down.sql b/database/migrations/000001_create_items_table.down.sql new file mode 100644 index 0000000..70db741 --- /dev/null +++ b/database/migrations/000001_create_items_table.down.sql @@ -0,0 +1,2 @@ + +DROP TABLE IF EXISTS items; diff --git a/database/migrations/000001_create_items_table.up.sql b/database/migrations/000001_create_items_table.up.sql new file mode 100644 index 0000000..2c920cc --- /dev/null +++ b/database/migrations/000001_create_items_table.up.sql @@ -0,0 +1,5 @@ + +CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL +); diff --git a/database/migrations/000002_create_users_table.down.sql b/database/migrations/000002_create_users_table.down.sql new file mode 100644 index 0000000..f2d43b8 --- /dev/null +++ b/database/migrations/000002_create_users_table.down.sql @@ -0,0 +1,2 @@ + +DROP TABLE IF EXISTS users; diff --git a/database/migrations/000002_create_users_table.up.sql b/database/migrations/000002_create_users_table.up.sql new file mode 100644 index 0000000..ea5bf2a --- /dev/null +++ b/database/migrations/000002_create_users_table.up.sql @@ -0,0 +1,9 @@ + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4ced977 --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module api-server + +go 1.23.1 + +require ( + github.com/air-verse/air v1.60.0 + github.com/gin-gonic/gin v1.10.0 + github.com/go-playground/validator/v10 v10.20.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.27.0 +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/bep/godartsass v1.2.0 // indirect + github.com/bep/godartsass/v2 v2.1.0 // indirect + github.com/bep/golibsass v1.2.0 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cli/safeexec v1.0.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/creack/pty v1.1.23 // indirect + github.com/fatih/color v1.17.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gohugoio/hugo v0.134.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/tdewolff/parse/v2 v2.7.15 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..3eb7975 --- /dev/null +++ b/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "log" + "os" + "strings" + + "api-server/config" + "api-server/middlewares" + "api-server/routes" + + "github.com/gin-gonic/gin" +) + +func main() { + // Load environment variables + if err := config.LoadEnv(); err != nil { + log.Fatalf("Error loading environment variables: %v", err) + } + + // Set GIN mode + ginMode := os.Getenv("GIN_MODE") + if ginMode == "" { + ginMode = gin.ReleaseMode // Defaults to release mode if not set + } else if ginMode == gin.DebugMode { + ginMode = gin.DebugMode // Set to debug mode + } else if ginMode == gin.ReleaseMode { + ginMode = gin.ReleaseMode // Set to release mode + } else if ginMode == gin.TestMode { + ginMode = gin.TestMode // Set to test mode + } else { + log.Fatalf("Invalid GIN_MODE: %s", ginMode) + } + gin.SetMode(ginMode) + + // Initialize database + db, err := config.InitDB() + if err != nil { + log.Fatalf("Error initializing database: %v", err) + } + defer db.Close() + + // Router: Initialize router + router := gin.Default() + + // Router: Configure trusted proxies + trustedProxies := os.Getenv("TRUSTED_PROXIES") + if trustedProxies == "" { + // Default to loopback addresses if not set + router.SetTrustedProxies([]string{"127.0.0.1", "::1"}) + } else { + proxyList := strings.Split(trustedProxies, ",") + router.SetTrustedProxies(proxyList) + } + + // Register middleware + router.Use(middlewares.ErrorHandler) + + // Router: Setup routes + routes.SetupRoutes(router, db) + + // Get port from environment variables or use default + port := os.Getenv("APP_PORT") + if port == "" { + port = "8080" // default port + } + + // Start the server + log.Printf("Server running on port %s", port) + if err := router.Run(":" + port); err != nil { + log.Fatalf("Error starting server: %v", err) + } +} diff --git a/middlewares/error_handler.go b/middlewares/error_handler.go new file mode 100644 index 0000000..dd7aa79 --- /dev/null +++ b/middlewares/error_handler.go @@ -0,0 +1,101 @@ +package middlewares + +import ( + "api-server/validators" + "net/http" + "reflect" + "strings" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +// getFieldMessage retrieves the custom error message from the 'message' struct tag. +func getFieldMessage(obj interface{}, fieldName string, tag string) string { + // Get the reflect.Type of the struct + rt := reflect.TypeOf(obj) + + // Loop through the fields to find the one that matches fieldName + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + if field.Name == fieldName { + // Return the value of the custom 'message' tag + return field.Tag.Get(tag) + } + } + return "" +} + +// ErrorHandler is a middleware to handle validation and binding errors. +func ErrorHandler(c *gin.Context) { + c.Next() // Process the request + + // Check if there were any errors in the context + if len(c.Errors) > 0 { + for _, err := range c.Errors { + if err.Type == gin.ErrorTypeBind { + handleBindingError(c, err) + return + } + } + } +} + +func handleBindingError(c *gin.Context, err *gin.Error) { + // Check if the error is a validation error + if validationErrs, ok := err.Err.(validator.ValidationErrors); ok { + handleValidationErrors(c, err, validationErrs) + return + } + + // For generic binding errors + c.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": "Invalid request body", + "error": err.Error(), + }) +} + +func handleValidationErrors(c *gin.Context, err *gin.Error, validationErrs validator.ValidationErrors) { + // Create a map to hold validation error details + errors := make(map[string]string) + + // Identify the validator struct dynamically + obj := identifyValidatorStruct(err) + + for _, fieldErr := range validationErrs { + field := fieldErr.StructField() // Field name (e.g., "Username") + + // Try to get a custom message from the 'message' tag + customMessage := getFieldMessage(obj, field, "message") + + if customMessage != "" { + errors[fieldErr.Field()] = customMessage + } else { + // Fallback to the default validation error message + errors[fieldErr.Field()] = fieldErr.Error() + } + } + + // Send a detailed validation error response + c.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": "Validation failed", + "errors": errors, + }) +} + +// identifyValidatorStruct dynamically resolves the validator struct using ValidatorRegistry. +func identifyValidatorStruct(err *gin.Error) interface{} { + for validatorName, obj := range validators.ValidatorRegistry { + if containsValidatorName(err.Error(), validatorName) { + return obj + } + } + return nil +} + +// containsValidatorName checks if the error string contains the validator name. +func containsValidatorName(errorStr string, validatorName string) bool { + return strings.Contains(errorStr, validatorName) +} diff --git a/models/item.go b/models/item.go new file mode 100644 index 0000000..d6ba168 --- /dev/null +++ b/models/item.go @@ -0,0 +1,6 @@ +package models + +type Item struct { + ID string `json:"id"` + Name string `json:"name"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..192321d --- /dev/null +++ b/models/user.go @@ -0,0 +1,13 @@ +package models + +import "time" + +// User represents a user in the system. +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/repositories/item_repository.go b/repositories/item_repository.go new file mode 100644 index 0000000..ef59112 --- /dev/null +++ b/repositories/item_repository.go @@ -0,0 +1,51 @@ +package repositories + +import ( + "database/sql" + + "api-server/models" +) + +type ItemRepository struct { + db *sql.DB +} + +func NewItemRepository(db *sql.DB) *ItemRepository { + return &ItemRepository{db: db} +} + +func (r *ItemRepository) GetAll() ([]models.Item, error) { + rows, err := r.db.Query("SELECT id, name FROM items") + if err != nil { + return nil, err + } + defer rows.Close() + + var items []models.Item + for rows.Next() { + var item models.Item + if err := rows.Scan(&item.ID, &item.Name); err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + +func (r *ItemRepository) GetByID(id string) (*models.Item, error) { + var item models.Item + err := r.db.QueryRow("SELECT id, name FROM items WHERE id = $1", id).Scan(&item.ID, &item.Name) + if err != nil { + return nil, err + } + return &item, nil +} + +func (r *ItemRepository) Create(name string) (*models.Item, error) { + var item models.Item + err := r.db.QueryRow("INSERT INTO items (name) VALUES ($1) RETURNING id, name", name).Scan(&item.ID, &item.Name) + if err != nil { + return nil, err + } + return &item, nil +} diff --git a/repositories/user_repository.go b/repositories/user_repository.go new file mode 100644 index 0000000..283f6f4 --- /dev/null +++ b/repositories/user_repository.go @@ -0,0 +1,43 @@ +package repositories + +import ( + "api-server/models" + "database/sql" +) + +type UserRepository struct { + db *sql.DB +} + +func NewUserRepository(db *sql.DB) *UserRepository { + return &UserRepository{db: db} +} + +// CreateUser creates a new user in the database. +func (r *UserRepository) CreateUser(user *models.User) error { + query := `INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at, updated_at` + err := r.db.QueryRow(query, user.Username, user.Email, user.PasswordHash).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) + return err +} + +// GetUserByEmail retrieves a user by email. +func (r *UserRepository) GetUserByEmail(email string) (*models.User, error) { + var user models.User + query := `SELECT id, username, email, password_hash, created_at, updated_at FROM users WHERE email = $1` + err := r.db.QueryRow(query, email).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt) + if err != nil { + return nil, err + } + return &user, nil +} + +// GetUserByUsername retrieves a user by username. +func (r *UserRepository) GetUserByUsername(username string) (*models.User, error) { + var user models.User + query := `SELECT id, username, email, password_hash, created_at, updated_at FROM users WHERE username = $1` + err := r.db.QueryRow(query, username).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt) + if err != nil { + return nil, err + } + return &user, nil +} diff --git a/routes/routes.go b/routes/routes.go new file mode 100644 index 0000000..7434d70 --- /dev/null +++ b/routes/routes.go @@ -0,0 +1,32 @@ +package routes + +import ( + "database/sql" + "net/http" + + "api-server/controllers" + + "github.com/gin-gonic/gin" +) + +func SetupRoutes(router *gin.Engine, db *sql.DB) { + authController := controllers.NewAuthController(db) + itemController := controllers.NewItemController(db) + + // Routes: Auth + router.POST("/register", authController.Register) + router.POST("/login", authController.Login) + + // Routes: Items + router.GET("/items", itemController.GetItems) + router.GET("/items/:id", itemController.GetItem) + router.POST("/items", itemController.CreateItem) + + // Routes: Custom 404 handler + router.NoRoute(func(c *gin.Context) { + c.JSON(http.StatusNotFound, gin.H{ + "message": http.StatusNotFound, + "status": "Sorry, the requested URL was not found on this server.", + }) + }) +} diff --git a/services/auth_service.go b/services/auth_service.go new file mode 100644 index 0000000..589d2b5 --- /dev/null +++ b/services/auth_service.go @@ -0,0 +1,59 @@ +package services + +import ( + "api-server/models" + "api-server/repositories" + "api-server/utils" + "errors" +) + +type AuthService struct { + userRepo *repositories.UserRepository +} + +func NewAuthService(userRepo *repositories.UserRepository) *AuthService { + return &AuthService{userRepo: userRepo} +} + +// RegisterUser registers a new user by hashing the password and saving the user. +func (s *AuthService) RegisterUser(username, email, password string) (*models.User, error) { + // Check if email is already in use + existingUser, _ := s.userRepo.GetUserByEmail(email) + if existingUser != nil { + return nil, errors.New("email already in use") + } + + // Hash the password + hashedPassword, err := utils.HashPassword(password) + if err != nil { + return nil, err + } + + // Create and save the user + user := &models.User{ + Username: username, + Email: email, + PasswordHash: hashedPassword, + } + if err := s.userRepo.CreateUser(user); err != nil { + return nil, err + } + + return user, nil +} + +// AuthenticateUser authenticates a user by checking the password and returning the user if valid. +func (s *AuthService) AuthenticateUser(email, password string) (*models.User, error) { + // Get user by email + user, err := s.userRepo.GetUserByEmail(email) + if err != nil { + return nil, errors.New("invalid email or password") + } + + // Check password + if err := utils.CheckPasswordHash(password, user.PasswordHash); err != nil { + return nil, errors.New("invalid email or password") + } + + return user, nil +} diff --git a/services/item_service.go b/services/item_service.go new file mode 100644 index 0000000..a5fcb66 --- /dev/null +++ b/services/item_service.go @@ -0,0 +1,30 @@ +package services + +import ( + "database/sql" + + "api-server/models" + "api-server/repositories" +) + +type ItemService struct { + repo *repositories.ItemRepository +} + +func NewItemService(db *sql.DB) *ItemService { + return &ItemService{ + repo: repositories.NewItemRepository(db), + } +} + +func (s *ItemService) GetAllItems() ([]models.Item, error) { + return s.repo.GetAll() +} + +func (s *ItemService) GetItemByID(id string) (*models.Item, error) { + return s.repo.GetByID(id) +} + +func (s *ItemService) CreateItem(name string) (*models.Item, error) { + return s.repo.Create(name) +} diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..8c4315b --- /dev/null +++ b/tools.go @@ -0,0 +1,8 @@ +//go:build tools +// +build tools + +package tools + +import ( + _ "github.com/air-verse/air" // Air for live-reloading +) diff --git a/utils/password.go b/utils/password.go new file mode 100644 index 0000000..3a54fb0 --- /dev/null +++ b/utils/password.go @@ -0,0 +1,14 @@ +package utils + +import "golang.org/x/crypto/bcrypt" + +// HashPassword hashes a plain-text password. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +// CheckPasswordHash compares a plain-text password with a hashed password. +func CheckPasswordHash(password, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} diff --git a/validators/auth_validator.go b/validators/auth_validator.go new file mode 100644 index 0000000..43959ed --- /dev/null +++ b/validators/auth_validator.go @@ -0,0 +1,14 @@ +package validators + +// RegisterUserValidator holds the fields for validating user registration input. +type RegisterUserValidator struct { + Username string `json:"username" binding:"required,min=3,max=50" message:"Username is required and must be between 3 and 50 characters"` + Email string `json:"email" binding:"required,email" message:"Email is required and must be a valid email address"` + Password string `json:"password" binding:"required,min=6" message:"Password is required and must be at least 6 characters long"` +} + +// LoginUserValidator holds the fields for validating user login input. +type LoginUserValidator struct { + Email string `json:"email" binding:"required,email" message:"Email is required and must be a valid email address"` + Password string `json:"password" binding:"required,min=6" message:"Password is required and must be at least 6 characters long"` +} diff --git a/validators/item_validator.go b/validators/item_validator.go new file mode 100644 index 0000000..20399a4 --- /dev/null +++ b/validators/item_validator.go @@ -0,0 +1,5 @@ +package validators + +type CreateItemInput struct { + Name string `json:"name" binding:"required,min=1,max=100"` +} diff --git a/validators/register.go b/validators/register.go new file mode 100644 index 0000000..063f18f --- /dev/null +++ b/validators/register.go @@ -0,0 +1,10 @@ +//go:generate go run ../cmd/generate_validators/main.go + +package validators + +var ValidatorRegistry = map[string]interface{}{} + +// RegisterValidator registers a validator by name dynamically. +func RegisterValidator(name string, v interface{}) { + ValidatorRegistry[name] = v +}