docs
api
validation
Request/Response Validation
Request/Response Validation
Planned feature
FastAPI-style schema validation is planned. Use pkg/utils validation helpers and manual checks in handlers today.
This page describes the planned validation system.
Schema Definition#
Request Schemas
// app/schemas/user_request.go
package schemas
import (
"time"
"github.com/google/uuid"
)
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email" example:"user@example.com"`
Password string `json:"password" validate:"required,min=8" example:"password123"`
Name string `json:"name" validate:"required,min=2,max=100" example:"John Doe"`
Age int `json:"age" validate:"min=18,max=120" example:"25"`
}
type UpdateUserRequest struct {
Name string `json:"name" validate:"omitempty,min=2,max=100" example:"John Doe"`
Age int `json:"age" validate:"omitempty,min=18,max=120" example:"25"`
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email" example:"user@example.com"`
Password string `json:"password" validate:"required" example:"password123"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" validate:"required" example:"oldpassword123"`
NewPassword string `json:"new_password" validate:"required,min=8" example:"newpassword123"`
}
Response Schemas
// app/schemas/user_response.go
package schemas
import (
"time"
"github.com/google/uuid"
)
type UserResponse struct {
ID uuid.UUID `json:"id" example:"123e4567-e89b-12d3-a456-426614174000"`
Email string `json:"email" example:"user@example.com"`
Name string `json:"name" example:"John Doe"`
Age int `json:"age" example:"25"`
CreatedAt time.Time `json:"created_at" example:"2024-01-01T00:00:00Z"`
UpdatedAt time.Time `json:"updated_at" example:"2024-01-01T00:00:00Z"`
}
type UserListResponse struct {
Data []UserResponse `json:"data"`
Pagination PaginationMeta `json:"pagination"`
}
type PaginationMeta struct {
CurrentPage int `json:"current_page" example:"1"`
PerPage int `json:"per_page" example:"15"`
Total int64 `json:"total" example:"100"`
LastPage int `json:"last_page" example:"7"`
From int `json:"from" example:"1"`
To int `json:"to" example:"15"`
}
type ErrorResponse struct {
Error string `json:"error" example:"Validation failed"`
Message string `json:"message" example:"The given data was invalid"`
Errors map[string]string `json:"errors,omitempty" example:"email:['The email field is required']"`
}
Validation System#
Core Validator
// pkg/validation/validator.go
package validation
import (
"fmt"
"reflect"
"strings"
"github.com/go-playground/validator/v10"
)
type Validator struct {
validator *validator.Validate
}
func NewValidator() *Validator {
v := validator.New()
// Register custom validators
v.RegisterValidation("password", validatePassword)
v.RegisterValidation("phone", validatePhone)
v.RegisterValidation("slug", validateSlug)
// Register custom tag name function
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
return &Validator{validator: v}
}
func (v *Validator) ValidateStruct(s interface{}) error {
return v.validator.Struct(s)
}
func (v *Validator) ValidateVar(field interface{}, tag string) error {
return v.validator.Var(field, tag)
}
// Custom validators
func validatePassword(fl validator.FieldLevel) bool {
password := fl.Field().String()
if len(password) < 8 {
return false
}
hasUpper := strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
hasLower := strings.ContainsAny(password, "abcdefghijklmnopqrstuvwxyz")
hasDigit := strings.ContainsAny(password, "0123456789")
hasSpecial := strings.ContainsAny(password, "!@#$%^&*()_+-=[]{}|;:,.<>?")
return hasUpper && hasLower && hasDigit && hasSpecial
}
func validatePhone(fl validator.FieldLevel) bool {
phone := fl.Field().String()
// Simple phone validation - can be enhanced
return len(phone) >= 10 && len(phone) <= 15
}
func validateSlug(fl validator.FieldLevel) bool {
slug := fl.Field().String()
if slug == "" {
return true // Allow empty for optional fields
}
// Check if slug contains only lowercase letters, numbers, and hyphens
for _, char := range slug {
if !((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-') {
return false
}
}
return true
}
Validation Middleware
// pkg/validation/middleware.go
package validation
import (
"encoding/json"
"github.com/gofiber/fiber/v2"
"reflect"
)
func ValidateRequest[T any](validator *Validator) fiber.Handler {
return func(c *fiber.Ctx) error {
var req T
// Parse JSON body
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{
"error": "Invalid JSON",
"message": err.Error(),
})
}
// Validate struct
if err := validator.ValidateStruct(&req); err != nil {
return c.Status(422).JSON(fiber.Map{
"error": "Validation failed",
"message": "The given data was invalid",
"errors": formatValidationErrors(err),
})
}
// Store validated data in context
c.Locals("validated_data", &req)
return c.Next()
}
}
func formatValidationErrors(err error) map[string][]string {
errors := make(map[string][]string)
if validationErrors, ok := err.(validator.ValidationErrors); ok {
for _, e := range validationErrors {
field := e.Field()
tag := e.Tag()
var message string
switch tag {
case "required":
message = "The " + field + " field is required"
case "email":
message = "The " + field + " field must be a valid email address"
case "min":
message = "The " + field + " field must be at least " + e.Param() + " characters"
case "max":
message = "The " + field + " field must not exceed " + e.Param() + " characters"
case "password":
message = "The " + field + " field must contain at least one uppercase letter, one lowercase letter, one digit, and one special character"
case "phone":
message = "The " + field + " field must be a valid phone number"
case "slug":
message = "The " + field + " field must contain only lowercase letters, numbers, and hyphens"
default:
message = "The " + field + " field is invalid"
}
errors[field] = append(errors[field], message)
}
}
return errors
}
Controller Integration#
Using Validation in Controllers
// app/controllers/user_controller.go
package controllers
import (
"github.com/gofiber/fiber/v2"
"my-app/app/schemas"
"my-app/pkg/validation"
)
type UserController struct {
userService *services.UserService
validator *validation.Validator
}
func NewUserController(userService *services.UserService, validator *validation.Validator) *UserController {
return &UserController{
userService: userService,
validator: validator,
}
}
func (c *UserController) Create(ctx *fiber.Ctx) error {
// Get validated data from context
req := ctx.Locals("validated_data").(*schemas.CreateUserRequest)
// Create user
user, err := c.userService.Create(*req)
if err != nil {
return ctx.Status(400).JSON(fiber.Map{
"error": "Failed to create user",
"message": err.Error(),
})
}
// Return response
return ctx.Status(201).JSON(schemas.UserResponse{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Age: user.Age,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
})
}
func (c *UserController) Update(ctx *fiber.Ctx) error {
id := ctx.Params("id")
req := ctx.Locals("validated_data").(*schemas.UpdateUserRequest)
user, err := c.userService.Update(id, *req)
if err != nil {
return ctx.Status(400).JSON(fiber.Map{
"error": "Failed to update user",
"message": err.Error(),
})
}
return ctx.JSON(schemas.UserResponse{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Age: user.Age,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
})
}
Route Setup with Validation
// routes/api.go
package routes
import (
"github.com/gofiber/fiber/v2"
"my-app/app/controllers"
"my-app/app/schemas"
"my-app/pkg/validation"
)
func SetupAPIRoutes(app *fiber.App) {
api := app.Group("/api")
validator := validation.NewValidator()
userController := controllers.NewUserController(userService, validator)
// Users routes with validation
api.Post("/users",
validation.ValidateRequest[schemas.CreateUserRequest](validator),
userController.Create,
)
api.Put("/users/:id",
validation.ValidateRequest[schemas.UpdateUserRequest](validator),
userController.Update,
)
api.Post("/auth/login",
validation.ValidateRequest[schemas.LoginRequest](validator),
authController.Login,
)
api.Post("/auth/change-password",
middleware.Auth(),
validation.ValidateRequest[schemas.ChangePasswordRequest](validator),
authController.ChangePassword,
)
}
Response Serialization#
Automatic Response Serialization
// pkg/validation/response.go
package validation
import (
"encoding/json"
"github.com/gofiber/fiber/v2"
"reflect"
)
func SerializeResponse[T any](data T) fiber.Handler {
return func(c *fiber.Ctx) error {
return c.JSON(data)
}
}
func SerializePaginatedResponse[T any](data []T, pagination schemas.PaginationMeta) fiber.Handler {
return func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"data": data,
"pagination": pagination,
})
}
}
Custom Response Serialization
// app/controllers/user_controller.go
func (c *UserController) Index(ctx *fiber.Ctx) error {
page := ctx.QueryInt("page", 1)
perPage := ctx.QueryInt("per_page", 15)
users, total, err := c.userService.GetPaginated(page, perPage)
if err != nil {
return ctx.Status(500).JSON(fiber.Map{
"error": "Failed to fetch users",
})
}
// Convert to response schemas
var userResponses []schemas.UserResponse
for _, user := range users {
userResponses = append(userResponses, schemas.UserResponse{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Age: user.Age,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
})
}
// Calculate pagination
lastPage := int((total + int64(perPage) - 1) / int64(perPage))
pagination := schemas.PaginationMeta{
CurrentPage: page,
PerPage: perPage,
Total: total,
LastPage: lastPage,
From: (page-1)*perPage + 1,
To: page * perPage,
}
return ctx.JSON(schemas.UserListResponse{
Data: userResponses,
Pagination: pagination,
})
}
Validation Rules#
Built-in Validation Tags
type UserRequest struct {
// Required fields
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
// String validation
Name string `json:"name" validate:"required,min=2,max=100"`
Bio string `json:"bio" validate:"max=500"`
// Numeric validation
Age int `json:"age" validate:"min=18,max=120"`
Score float64 `json:"score" validate:"min=0,max=100"`
// Custom validation
Password string `json:"password" validate:"required,password"`
Phone string `json:"phone" validate:"phone"`
Slug string `json:"slug" validate:"slug"`
// Conditional validation
Website string `json:"website" validate:"omitempty,url"`
Twitter string `json:"twitter" validate:"omitempty,startswith=@"`
// Array validation
Tags []string `json:"tags" validate:"dive,min=1,max=20"`
// Nested validation
Address Address `json:"address" validate:"required"`
}
type Address struct {
Street string `json:"street" validate:"required,min=5,max=100"`
City string `json:"city" validate:"required,min=2,max=50"`
Country string `json:"country" validate:"required,len=2"`
ZipCode string `json:"zip_code" validate:"required,len=5"`
}
Custom Validation Rules
// pkg/validation/custom_validators.go
package validation
import (
"regexp"
"strings"
"github.com/go-playground/validator/v10"
)
func RegisterCustomValidators(v *validator.Validate) {
// Username validation
v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
username := fl.Field().String()
if len(username) < 3 || len(username) > 20 {
return false
}
// Only allow alphanumeric and underscore
matched, _ := regexp.MatchString("^[a-zA-Z0-9_]+$", username)
return matched
})
// Strong password validation
v.RegisterValidation("strong_password", func(fl validator.FieldLevel) bool {
password := fl.Field().String()
if len(password) < 12 {
return false
}
hasUpper := strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
hasLower := strings.ContainsAny(password, "abcdefghijklmnopqrstuvwxyz")
hasDigit := strings.ContainsAny(password, "0123456789")
hasSpecial := strings.ContainsAny(password, "!@#$%^&*()_+-=[]{}|;:,.<>?")
return hasUpper && hasLower && hasDigit && hasSpecial
})
// Date validation
v.RegisterValidation("date", func(fl validator.FieldLevel) bool {
dateStr := fl.Field().String()
_, err := time.Parse("2006-01-02", dateStr)
return err == nil
})
// Future date validation
v.RegisterValidation("future_date", func(fl validator.FieldLevel) bool {
dateStr := fl.Field().String()
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return false
}
return date.After(time.Now())
})
}
Error Handling#
Validation Error Response
// pkg/validation/errors.go
package validation
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
type ValidationError struct {
Field string `json:"field"`
Tag string `json:"tag"`
Value string `json:"value"`
Message string `json:"message"`
}
func HandleValidationError(err error) error {
if validationErrors, ok := err.(validator.ValidationErrors); ok {
var errors []ValidationError
for _, e := range validationErrors {
errors = append(errors, ValidationError{
Field: e.Field(),
Tag: e.Tag(),
Value: fmt.Sprintf("%v", e.Value()),
Message: getValidationMessage(e),
})
}
return fiber.NewError(422, "Validation failed", errors)
}
return err
}
func getValidationMessage(e validator.FieldError) string {
switch e.Tag() {
case "required":
return "The " + e.Field() + " field is required"
case "email":
return "The " + e.Field() + " field must be a valid email address"
case "min":
return "The " + e.Field() + " field must be at least " + e.Param() + " characters"
case "max":
return "The " + e.Field() + " field must not exceed " + e.Param() + " characters"
case "len":
return "The " + e.Field() + " field must be exactly " + e.Param() + " characters"
case "url":
return "The " + e.Field() + " field must be a valid URL"
case "username":
return "The " + e.Field() + " field must contain only letters, numbers, and underscores"
case "strong_password":
return "The " + e.Field() + " field must be at least 12 characters with uppercase, lowercase, digit, and special character"
default:
return "The " + e.Field() + " field is invalid"
}
}
Testing Validation#
Unit Tests
// pkg/validation/validator_test.go
package validation
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidator_ValidateStruct(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
input interface{}
wantErr bool
}{
{
name: "valid user request",
input: CreateUserRequest{
Email: "user@example.com",
Password: "Password123!",
Name: "John Doe",
Age: 25,
},
wantErr: false,
},
{
name: "invalid email",
input: CreateUserRequest{
Email: "invalid-email",
Password: "Password123!",
Name: "John Doe",
Age: 25,
},
wantErr: true,
},
{
name: "weak password",
input: CreateUserRequest{
Email: "user@example.com",
Password: "123",
Name: "John Doe",
Age: 25,
},
wantErr: true,
},
{
name: "missing required field",
input: CreateUserRequest{
Email: "user@example.com",
Password: "Password123!",
Name: "",
Age: 25,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateStruct(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
Integration Tests
// pkg/testing/validation_test.go
package testing
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
)
func TestValidationMiddleware(t *testing.T) {
app := fiber.New()
validator := validation.NewValidator()
app.Post("/users",
validation.ValidateRequest[schemas.CreateUserRequest](validator),
func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "success"})
},
)
tests := []struct {
name string
requestBody interface{}
expectedStatus int
}{
{
name: "valid request",
requestBody: schemas.CreateUserRequest{
Email: "user@example.com",
Password: "Password123!",
Name: "John Doe",
Age: 25,
},
expectedStatus: 200,
},
{
name: "invalid email",
requestBody: schemas.CreateUserRequest{
Email: "invalid-email",
Password: "Password123!",
Name: "John Doe",
Age: 25,
},
expectedStatus: 422,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, tt.expectedStatus, resp.StatusCode)
})
}
}
Best Practices#
1. Schema Design
- Use descriptive field names
- Provide example values in tags
- Group related fields in nested structs
- Use appropriate validation tags
2. Error Messages
- Provide clear, user-friendly error messages
- Use consistent error format
- Include field names in error messages
- Avoid exposing sensitive information
3. Performance
- Cache validator instances
- Use struct tags efficiently
- Avoid complex validation logic in hot paths
- Consider validation at the service layer
4. Security
- Validate all input data
- Sanitize user input
- Use appropriate validation rules
- Implement rate limiting for validation endpoints