Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A production-ready Go project template following Clean Architecture and Domain-D
- **Health Checks** - Kubernetes-compatible readiness and liveness endpoints
- **Structured Logging** - JSON logs using slog
- **Configuration** - Environment variable based configuration with go-env
- **Input Validation** - Advanced validation with jellydator/validation library including password strength, email format, and custom rules
- **Password Hashing** - Secure password hashing with Argon2id via go-pwdhash
- **Docker Support** - Multi-stage Dockerfile for minimal container size
- **CI/CD** - GitHub Actions workflow for linting and testing
Expand Down Expand Up @@ -66,6 +67,9 @@ go-project-template/
│ │ │ └── user_repository.go
│ │ └── usecase/ # User business logic
│ │ └── user_usecase.go
│ ├── validation/ # Custom validation rules
│ │ ├── rules.go
│ │ └── rules_test.go
│ └── worker/ # Background workers
│ └── event_worker.go
├── migrations/
Expand Down Expand Up @@ -257,10 +261,28 @@ curl -X POST http://localhost:8080/api/users \
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "securepassword123"
"password": "SecurePass123!"
}'
```

**Password Requirements:**
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one number
- At least one special character

**Validation Errors:**

If validation fails, you'll receive a 422 Unprocessable Entity response with details:

```json
{
"error": "invalid_input",
"message": "email: must be a valid email address; password: password must contain at least one uppercase letter."
}
```

### CLI Commands

The binary supports three commands via urfave/cli:
Expand Down Expand Up @@ -737,6 +759,91 @@ func ToUserResponse(user *domain.User) UserResponse {
4. **Versioning** - Easy to maintain multiple API versions with different DTOs
5. **Validation** - Request validation happens at the DTO level before reaching domain logic

### Input Validation

The project uses the [jellydator/validation](https://github.com/jellydator/validation) library for comprehensive input validation at both the DTO and use case layers.

**Custom Validation Rules** (`internal/validation/rules.go`)

The project provides reusable validation rules:

```go
// Password strength validation
PasswordStrength{
MinLength: 8,
RequireUpper: true,
RequireLower: true,
RequireNumber: true,
RequireSpecial: true,
}

// Email format validation
Email

// No leading/trailing whitespace
NoWhitespace

// Not blank after trimming
NotBlank
```

**DTO Validation Example:**

```go
func (r *RegisterUserRequest) Validate() error {
err := validation.ValidateStruct(r,
validation.Field(&r.Name,
validation.Required.Error("name is required"),
appValidation.NotBlank,
validation.Length(1, 255).Error("name must be between 1 and 255 characters"),
),
validation.Field(&r.Email,
validation.Required.Error("email is required"),
appValidation.NotBlank,
appValidation.Email,
validation.Length(5, 255).Error("email must be between 5 and 255 characters"),
),
validation.Field(&r.Password,
validation.Required.Error("password is required"),
validation.Length(8, 128).Error("password must be between 8 and 128 characters"),
appValidation.PasswordStrength{
MinLength: 8,
RequireUpper: true,
RequireLower: true,
RequireNumber: true,
RequireSpecial: true,
},
),
)
return appValidation.WrapValidationError(err)
}
```

**Validation Layers:**

1. **DTO Layer** - Validates API request structure and basic constraints
2. **Use Case Layer** - Validates business logic rules and constraints
3. **Domain Layer** - Defines domain-specific error types

**Error Responses:**

Validation errors are automatically wrapped as `ErrInvalidInput` and return 422 Unprocessable Entity:

```json
{
"error": "invalid_input",
"message": "password: password must contain at least one uppercase letter."
}
```

**Benefits:**
- **Declarative** - Validation rules are clear and concise
- **Reusable** - Custom rules can be shared across the application
- **Type-Safe** - Compile-time validation of struct fields
- **Extensible** - Easy to add custom validation rules
- **Consistent** - Same validation logic at DTO and use case layers
- **User-Friendly** - Detailed error messages help API clients fix issues

### Transaction Management

The template implements a TxManager interface for handling database transactions:
Expand Down Expand Up @@ -869,6 +976,7 @@ go tool cover -html=coverage.out
- [godotenv](https://github.com/joho/godotenv) - Loads environment variables from .env files
- [go-pwdhash](https://github.com/allisson/go-pwdhash) - Password hashing with Argon2id
- [sqlutil](https://github.com/allisson/sqlutil) - SQL utilities for unified database access
- [validation](https://github.com/jellydator/validation) - Advanced input validation library
- [urfave/cli](https://github.com/urfave/cli) - CLI framework
- [golang-migrate](https://github.com/golang-migrate/migrate) - Database migrations

Expand All @@ -891,5 +999,6 @@ This template uses the following excellent Go libraries:
- github.com/allisson/go-env
- github.com/allisson/go-pwdhash
- github.com/allisson/sqlutil
- github.com/jellydator/validation
- github.com/urfave/cli
- github.com/golang-migrate/migrate
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/allisson/sqlutil v1.10.0
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jellydator/validation v1.2.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/stretchr/testify v1.11.1
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/allisson/sqlquery v1.5.0 h1:fPSpwWIelpSXcrVbQpX1qNjzmVcMZw7A3FUgntYnH
github.com/allisson/sqlquery v1.5.0/go.mod h1:PbwTeUaIvV3r+8Q50eBxx3ExgjhALczLoY+NZGCS4j4=
github.com/allisson/sqlutil v1.10.0 h1:MIqF/HpqDtLsXBhpTsEPGjymMwuvP3gyvcLlDV11MIk=
github.com/allisson/sqlutil v1.10.0/go.mod h1:mjhAiULaVhFs0FHH4/psiBXPN5Yv/bUIWTF1aW4GZnU=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/ccoveille/go-safecast/v2 v2.0.0 h1:+5eyITXAUj3wMjad6cRVJKGnC7vDS55zk0INzJagub0=
github.com/ccoveille/go-safecast/v2 v2.0.0/go.mod h1:JIYA4CAR33blIDuE6fSwCp2sz1oOBahXnvmdBhOAABs=
github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs=
Expand Down Expand Up @@ -73,6 +75,8 @@ github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8=
github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jellydator/validation v1.2.0 h1:z3P3Hk5kdT9epXDraWAfMZtOIUM7UQ0PkNAnFEUjcAk=
github.com/jellydator/validation v1.2.0/go.mod h1:AaCjfkQ4Ykdcb+YCwqCtaI3wDsf2UAGhJ06lJs0VgOw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
Expand Down
8 changes: 4 additions & 4 deletions internal/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ func TestUserHandler_Register_Success(t *testing.T) {
req := dto.RegisterUserRequest{
Name: "John Doe",
Email: "john@example.com",
Password: "securepassword123",
Password: "SecurePass123!",
}

input := userUsecase.RegisterUserInput{
Expand Down Expand Up @@ -304,15 +304,15 @@ func TestUserHandler_Register_ValidationError(t *testing.T) {
input: dto.RegisterUserRequest{
Name: "",
Email: "john@example.com",
Password: "password",
Password: "SecurePass123!",
},
},
{
name: "empty email",
input: dto.RegisterUserRequest{
Name: "John Doe",
Email: "",
Password: "password",
Password: "SecurePass123!",
},
},
{
Expand Down Expand Up @@ -352,7 +352,7 @@ func TestUserHandler_Register_UseCaseError(t *testing.T) {
req := dto.RegisterUserRequest{
Name: "John Doe",
Email: "john@example.com",
Password: "securepassword123",
Password: "SecurePass123!",
}

input := userUsecase.RegisterUserInput{
Expand Down
49 changes: 35 additions & 14 deletions internal/user/http/dto/request.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Package dto provides data transfer objects for the user HTTP layer.
package dto

import "github.com/allisson/go-project-template/internal/user/domain"
import (
validation "github.com/jellydator/validation"

appValidation "github.com/allisson/go-project-template/internal/validation"
)

// RegisterUserRequest represents the API request for user registration
type RegisterUserRequest struct {
Expand All @@ -10,18 +14,35 @@ type RegisterUserRequest struct {
Password string `json:"password"`
}

// Validate validates the RegisterUserRequest
// Note: This provides basic JSON structure validation.
// Detailed validation is handled by the use case layer.
// Validate validates the RegisterUserRequest using the jellydator/validation library
// This provides comprehensive validation including:
// - Required field checks
// - Email format validation
// - Password strength requirements (min 8 chars, uppercase, lowercase, number, special char)
func (r *RegisterUserRequest) Validate() error {
if r.Name == "" {
return domain.ErrNameRequired
}
if r.Email == "" {
return domain.ErrEmailRequired
}
if r.Password == "" {
return domain.ErrPasswordRequired
}
return nil
err := validation.ValidateStruct(r,
validation.Field(&r.Name,
validation.Required.Error("name is required"),
appValidation.NotBlank,
validation.Length(1, 255).Error("name must be between 1 and 255 characters"),
),
validation.Field(&r.Email,
validation.Required.Error("email is required"),
appValidation.NotBlank,
appValidation.Email,
validation.Length(5, 255).Error("email must be between 5 and 255 characters"),
),
validation.Field(&r.Password,
validation.Required.Error("password is required"),
validation.Length(8, 128).Error("password must be between 8 and 128 characters"),
appValidation.PasswordStrength{
MinLength: 8,
RequireUpper: true,
RequireLower: true,
RequireNumber: true,
RequireSpecial: true,
},
),
)
return appValidation.WrapValidationError(err)
}
48 changes: 33 additions & 15 deletions internal/user/usecase/user_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import (
"encoding/json"
"strings"

validation "github.com/jellydator/validation"

"github.com/allisson/go-project-template/internal/database"
apperrors "github.com/allisson/go-project-template/internal/errors"
outboxDomain "github.com/allisson/go-project-template/internal/outbox/domain"
"github.com/allisson/go-project-template/internal/user/domain"
appValidation "github.com/allisson/go-project-template/internal/validation"
"github.com/allisson/go-pwdhash"
)

Expand Down Expand Up @@ -69,22 +72,37 @@ func NewUserUseCase(
}, nil
}

// validateRegisterUserInput validates the registration input
// validateRegisterUserInput validates the registration input using jellydator/validation
// This provides comprehensive validation including:
// - Required field checks
// - Email format validation
// - Password strength requirements (min 8 chars, uppercase, lowercase, number, special char)
func (uc *UserUseCase) validateRegisterUserInput(input RegisterUserInput) error {
if strings.TrimSpace(input.Name) == "" {
return domain.ErrNameRequired
}
if strings.TrimSpace(input.Email) == "" {
return domain.ErrEmailRequired
}
if input.Password == "" {
return domain.ErrPasswordRequired
}
// Basic email validation
if !strings.Contains(input.Email, "@") || !strings.Contains(input.Email, ".") {
return domain.ErrInvalidEmail
}
return nil
err := validation.ValidateStruct(&input,
validation.Field(&input.Name,
validation.Required.Error("name is required"),
appValidation.NotBlank,
validation.Length(1, 255).Error("name must be between 1 and 255 characters"),
),
validation.Field(&input.Email,
validation.Required.Error("email is required"),
appValidation.NotBlank,
appValidation.Email,
validation.Length(5, 255).Error("email must be between 5 and 255 characters"),
),
validation.Field(&input.Password,
validation.Required.Error("password is required"),
validation.Length(8, 128).Error("password must be between 8 and 128 characters"),
appValidation.PasswordStrength{
MinLength: 8,
RequireUpper: true,
RequireLower: true,
RequireNumber: true,
RequireSpecial: true,
},
),
)
return appValidation.WrapValidationError(err)
}

// RegisterUser registers a new user and creates a user.created event
Expand Down
8 changes: 4 additions & 4 deletions internal/user/usecase/user_usecase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func TestUserUseCase_RegisterUser_Success(t *testing.T) {
input := RegisterUserInput{
Name: "John Doe",
Email: "john@example.com",
Password: "securepassword123",
Password: "SecurePass123!",
}

// Setup expectations
Expand Down Expand Up @@ -136,7 +136,7 @@ func TestUserUseCase_RegisterUser_CreateUserError(t *testing.T) {
input := RegisterUserInput{
Name: "John Doe",
Email: "john@example.com",
Password: "securepassword123",
Password: "SecurePass123!",
}

createError := errors.New("database error")
Expand Down Expand Up @@ -168,7 +168,7 @@ func TestUserUseCase_RegisterUser_CreateOutboxEventError(t *testing.T) {
input := RegisterUserInput{
Name: "John Doe",
Email: "john@example.com",
Password: "securepassword123",
Password: "SecurePass123!",
}

outboxError := errors.New("outbox error")
Expand Down Expand Up @@ -201,7 +201,7 @@ func TestUserUseCase_RegisterUser_VerifyOutboxPayload(t *testing.T) {
input := RegisterUserInput{
Name: "John Doe",
Email: "john@example.com",
Password: "securepassword123",
Password: "SecurePass123!",
}

// Setup expectations
Expand Down
Loading