Echo Framework Guide¶
Applies to: Echo v4+, REST APIs, Microservices, High-Performance Web Applications Language Guide: @.claude/skills/go-guide/SKILL.md
Overview¶
Echo is a high-performance, extensible, minimalist Go web framework. It features an optimized HTTP router, middleware support, data binding, and rendering.
Use Echo when: - You need high performance with minimal overhead - You want a clean, intuitive API - Built-in middleware matters (JWT, CORS, Gzip, etc.) - You prefer automatic TLS via Let's Encrypt
Consider alternatives when: - You want the most popular framework (use Gin) - You need WebSocket support built-in (use Fiber) - Maximum community resources are needed (use Gin)
Project Structure¶
myproject/
├── cmd/
│ └── api/
│ └── main.go # Entry point
├── internal/
│ ├── config/
│ │ └── config.go # Configuration
│ ├── handler/
│ │ ├── handler.go # Handler container
│ │ ├── user.go # User handlers
│ │ └── auth.go # Auth handlers
│ ├── middleware/
│ │ ├── auth.go # JWT middleware
│ │ └── custom.go # Custom middleware
│ ├── model/
│ │ ├── user.go # User model
│ │ └── response.go # Response models
│ ├── repository/
│ │ ├── repository.go # Repository interface
│ │ └── user.go # User repository
│ ├── service/
│ │ ├── service.go # Service container
│ │ └── user.go # User service
│ └── validator/
│ └── validator.go # Custom validators
├── pkg/
│ └── response/
│ └── response.go # Response helpers
├── migrations/
├── .env.example
├── go.mod
├── go.sum
├── Makefile
└── README.md
Application Setup¶
cmd/api/main.go¶
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"time"
"myproject/internal/config"
"myproject/internal/handler"
"myproject/internal/middleware"
"myproject/internal/repository"
"myproject/internal/service"
"myproject/internal/validator"
"github.com/labstack/echo/v4"
echoMw "github.com/labstack/echo/v4/middleware"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize database
db, err := initDB(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Initialize Echo
e := echo.New()
e.HideBanner = true
e.Validator = validator.NewCustomValidator()
// Global middleware
e.Use(echoMw.Recover())
e.Use(echoMw.Logger())
e.Use(echoMw.RequestID())
e.Use(echoMw.CORSWithConfig(echoMw.CORSConfig{
AllowOrigins: cfg.CORSOrigins,
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
}))
// Initialize layers
repos := repository.NewRepositories(db)
services := service.NewServices(repos, cfg)
handlers := handler.NewHandlers(services)
authMiddleware := middleware.NewAuthMiddleware(services.Auth)
// Setup routes
setupRoutes(e, handlers, authMiddleware)
// Start server with graceful shutdown
go func() {
if err := e.Start(":" + cfg.Port); err != nil && err != http.ErrServerClosed {
e.Logger.Fatal("Shutting down the server")
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
e.Logger.Fatal(err)
}
}
func initDB(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(25)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
return db, nil
}
func setupRoutes(e *echo.Echo, h *handler.Handlers, authMw *middleware.AuthMiddleware) {
// Health check
e.GET("/health", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
// API v1
v1 := e.Group("/api/v1")
// Auth routes (public)
auth := v1.Group("/auth")
auth.POST("/login", h.Auth.Login)
auth.POST("/register", h.Auth.Register)
auth.POST("/refresh", h.Auth.Refresh)
// User routes
users := v1.Group("/users")
users.POST("", h.User.Create)
// Protected routes
users.Use(authMw.Authenticate)
users.GET("", h.User.GetAll)
users.GET("/me", h.User.GetCurrent)
users.GET("/:id", h.User.GetByID)
users.PUT("/:id", h.User.Update)
users.DELETE("/:id", h.User.Delete, authMw.RequireAdmin)
}
Configuration¶
internal/config/config.go¶
package config
import (
"os"
"strings"
"time"
"github.com/joho/godotenv"
)
type Config struct {
Environment string
Port string
DatabaseURL string
JWTSecret string
JWTAccessDuration time.Duration
JWTRefreshDuration time.Duration
CORSOrigins []string
}
func Load() (*Config, error) {
_ = godotenv.Load()
return &Config{
Environment: getEnv("ENVIRONMENT", "development"),
Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/myapp?sslmode=disable"),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
JWTAccessDuration: parseDuration(getEnv("JWT_ACCESS_DURATION", "15m")),
JWTRefreshDuration: parseDuration(getEnv("JWT_REFRESH_DURATION", "168h")),
CORSOrigins: strings.Split(getEnv("CORS_ORIGINS", "*"), ","),
}, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func parseDuration(s string) time.Duration {
d, err := time.ParseDuration(s)
if err != nil {
return 15 * time.Minute
}
return d
}
Custom Validator¶
internal/validator/validator.go¶
package validator
import (
"net/http"
"reflect"
"strings"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)
type CustomValidator struct {
validator *validator.Validate
}
func NewCustomValidator() *CustomValidator {
v := validator.New()
// Use JSON tag names for errors
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// Register custom validators
_ = v.RegisterValidation("password", validatePassword)
return &CustomValidator{validator: v}
}
func (cv *CustomValidator) Validate(i interface{}) error {
if err := cv.validator.Struct(i); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, formatValidationErrors(err))
}
return nil
}
func validatePassword(fl validator.FieldLevel) bool {
password := fl.Field().String()
// At least 8 characters
if len(password) < 8 {
return false
}
return true
}
func formatValidationErrors(err error) map[string]interface{} {
errors := make(map[string]string)
for _, err := range err.(validator.ValidationErrors) {
field := err.Field()
switch err.Tag() {
case "required":
errors[field] = field + " is required"
case "email":
errors[field] = field + " must be a valid email"
case "min":
errors[field] = field + " must be at least " + err.Param() + " characters"
case "max":
errors[field] = field + " must be at most " + err.Param() + " characters"
default:
errors[field] = field + " is invalid"
}
}
return map[string]interface{}{
"error": "Validation failed",
"fields": errors,
}
}
Models¶
internal/model/user.go¶
package model
import (
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
Password string `json:"-" gorm:"not null"`
FirstName string `json:"first_name" gorm:"not null"`
LastName string `json:"last_name" gorm:"not null"`
Role string `json:"role" gorm:"default:user"`
IsActive bool `json:"is_active" gorm:"default:true"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (u *User) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.Password = string(hash)
return nil
}
func (u *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
return err == nil
}
func (u *User) IsAdmin() bool {
return u.Role == "admin"
}
// Request DTOs
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
FirstName string `json:"first_name" validate:"required,min=1,max=100"`
LastName string `json:"last_name" validate:"required,min=1,max=100"`
}
type UpdateUserRequest struct {
FirstName *string `json:"first_name" validate:"omitempty,min=1,max=100"`
LastName *string `json:"last_name" validate:"omitempty,min=1,max=100"`
IsActive *bool `json:"is_active"`
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
// Response DTOs
type UserResponse struct {
ID uint `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `json:"role"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}
func (u *User) ToResponse() *UserResponse {
return &UserResponse{
ID: u.ID,
Email: u.Email,
FirstName: u.FirstName,
LastName: u.LastName,
Role: u.Role,
IsActive: u.IsActive,
CreatedAt: u.CreatedAt,
}
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}
internal/model/response.go¶
package model
type Response struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
type PaginatedResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data"`
Meta PageMeta `json:"meta"`
}
type PageMeta struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
TotalPages int `json:"total_pages"`
}
func SuccessResponse(data interface{}) *Response {
return &Response{Success: true, Data: data}
}
func ErrorResponse(message string) *Response {
return &Response{Success: false, Error: message}
}
func MessageResponse(message string) *Response {
return &Response{Success: true, Message: message}
}
Repository Layer¶
internal/repository/user.go¶
package repository
import (
"context"
"myproject/internal/model"
"gorm.io/gorm"
)
type UserRepository interface {
Create(ctx context.Context, user *model.User) error
GetByID(ctx context.Context, id uint) (*model.User, error)
GetByEmail(ctx context.Context, email string) (*model.User, error)
GetAll(ctx context.Context, page, perPage int) ([]model.User, int64, error)
Update(ctx context.Context, user *model.User) error
Delete(ctx context.Context, id uint) error
}
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *userRepository) GetByID(ctx context.Context, id uint) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetAll(ctx context.Context, page, perPage int) ([]model.User, int64, error) {
var users []model.User
var total int64
offset := (page - 1) * perPage
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil {
return nil, 0, err
}
if err := r.db.WithContext(ctx).
Offset(offset).
Limit(perPage).
Order("created_at DESC").
Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
func (r *userRepository) Update(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *userRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.User{}, id).Error
}
Service Layer¶
internal/service/user.go¶
package service
import (
"context"
"errors"
"myproject/internal/model"
"myproject/internal/repository"
"gorm.io/gorm"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
)
type UserService interface {
Create(ctx context.Context, req *model.CreateUserRequest) (*model.User, error)
GetByID(ctx context.Context, id uint) (*model.User, error)
GetAll(ctx context.Context, page, perPage int) ([]model.User, int64, error)
Update(ctx context.Context, id uint, req *model.UpdateUserRequest) (*model.User, error)
Delete(ctx context.Context, id uint) error
}
type userService struct {
userRepo repository.UserRepository
}
func NewUserService(userRepo repository.UserRepository) UserService {
return &userService{userRepo: userRepo}
}
func (s *userService) Create(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) {
// Check if user exists
existing, _ := s.userRepo.GetByEmail(ctx, req.Email)
if existing != nil {
return nil, ErrUserAlreadyExists
}
user := &model.User{
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
Role: "user",
}
if err := user.SetPassword(req.Password); err != nil {
return nil, err
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (s *userService) GetByID(ctx context.Context, id uint) (*model.User, error) {
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return user, nil
}
func (s *userService) GetAll(ctx context.Context, page, perPage int) ([]model.User, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
return s.userRepo.GetAll(ctx, page, perPage)
}
func (s *userService) Update(ctx context.Context, id uint, req *model.UpdateUserRequest) (*model.User, error) {
user, err := s.GetByID(ctx, id)
if err != nil {
return nil, err
}
if req.FirstName != nil {
user.FirstName = *req.FirstName
}
if req.LastName != nil {
user.LastName = *req.LastName
}
if req.IsActive != nil {
user.IsActive = *req.IsActive
}
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (s *userService) Delete(ctx context.Context, id uint) error {
if _, err := s.GetByID(ctx, id); err != nil {
return err
}
return s.userRepo.Delete(ctx, id)
}
internal/service/auth.go¶
package service
import (
"context"
"errors"
"time"
"myproject/internal/config"
"myproject/internal/model"
"myproject/internal/repository"
"github.com/golang-jwt/jwt/v5"
)
type AuthService interface {
Login(ctx context.Context, req *model.LoginRequest) (*model.TokenResponse, error)
Register(ctx context.Context, req *model.CreateUserRequest) (*model.User, error)
RefreshToken(ctx context.Context, refreshToken string) (*model.TokenResponse, error)
ValidateToken(tokenString string) (*Claims, error)
}
type Claims struct {
UserID uint `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type authService struct {
userRepo repository.UserRepository
cfg *config.Config
}
func NewAuthService(userRepo repository.UserRepository, cfg *config.Config) AuthService {
return &authService{
userRepo: userRepo,
cfg: cfg,
}
}
func (s *authService) Login(ctx context.Context, req *model.LoginRequest) (*model.TokenResponse, error) {
user, err := s.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
return nil, ErrInvalidCredentials
}
if !user.CheckPassword(req.Password) {
return nil, ErrInvalidCredentials
}
if !user.IsActive {
return nil, errors.New("account is deactivated")
}
return s.generateTokens(user)
}
func (s *authService) Register(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) {
existing, _ := s.userRepo.GetByEmail(ctx, req.Email)
if existing != nil {
return nil, ErrUserAlreadyExists
}
user := &model.User{
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
Role: "user",
}
if err := user.SetPassword(req.Password); err != nil {
return nil, err
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (s *authService) RefreshToken(ctx context.Context, refreshToken string) (*model.TokenResponse, error) {
claims, err := s.ValidateToken(refreshToken)
if err != nil {
return nil, err
}
user, err := s.userRepo.GetByID(ctx, claims.UserID)
if err != nil {
return nil, ErrUserNotFound
}
if !user.IsActive {
return nil, errors.New("account is deactivated")
}
return s.generateTokens(user)
}
func (s *authService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(s.cfg.JWTSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func (s *authService) generateTokens(user *model.User) (*model.TokenResponse, error) {
now := time.Now()
accessExp := now.Add(s.cfg.JWTAccessDuration)
refreshExp := now.Add(s.cfg.JWTRefreshDuration)
accessClaims := &Claims{
UserID: user.ID,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(accessExp),
IssuedAt: jwt.NewNumericDate(now),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString([]byte(s.cfg.JWTSecret))
if err != nil {
return nil, err
}
refreshClaims := &Claims{
UserID: user.ID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(refreshExp),
IssuedAt: jwt.NewNumericDate(now),
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString([]byte(s.cfg.JWTSecret))
if err != nil {
return nil, err
}
return &model.TokenResponse{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
TokenType: "Bearer",
ExpiresIn: int64(s.cfg.JWTAccessDuration.Seconds()),
}, nil
}
Handlers¶
internal/handler/user.go¶
package handler
import (
"errors"
"net/http"
"strconv"
"myproject/internal/model"
"myproject/internal/service"
"github.com/labstack/echo/v4"
)
type UserHandler struct {
userService service.UserService
}
func NewUserHandler(userService service.UserService) *UserHandler {
return &UserHandler{userService: userService}
}
// GetAll returns paginated users
func (h *UserHandler) GetAll(c echo.Context) error {
page, _ := strconv.Atoi(c.QueryParam("page"))
if page < 1 {
page = 1
}
perPage, _ := strconv.Atoi(c.QueryParam("per_page"))
if perPage < 1 {
perPage = 20
}
users, total, err := h.userService.GetAll(c.Request().Context(), page, perPage)
if err != nil {
return c.JSON(http.StatusInternalServerError, model.ErrorResponse(err.Error()))
}
responses := make([]*model.UserResponse, len(users))
for i, user := range users {
responses[i] = user.ToResponse()
}
totalPages := int(total) / perPage
if int(total)%perPage > 0 {
totalPages++
}
return c.JSON(http.StatusOK, &model.PaginatedResponse{
Success: true,
Data: responses,
Meta: model.PageMeta{
Page: page,
PerPage: perPage,
Total: total,
TotalPages: totalPages,
},
})
}
// GetByID returns a user by ID
func (h *UserHandler) GetByID(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, model.ErrorResponse("invalid user ID"))
}
user, err := h.userService.GetByID(c.Request().Context(), uint(id))
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
return c.JSON(http.StatusNotFound, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusInternalServerError, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusOK, model.SuccessResponse(user.ToResponse()))
}
// GetCurrent returns the authenticated user
func (h *UserHandler) GetCurrent(c echo.Context) error {
userID, ok := c.Get("user_id").(uint)
if !ok {
return c.JSON(http.StatusUnauthorized, model.ErrorResponse("unauthorized"))
}
user, err := h.userService.GetByID(c.Request().Context(), userID)
if err != nil {
return c.JSON(http.StatusNotFound, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusOK, model.SuccessResponse(user.ToResponse()))
}
// Create creates a new user
func (h *UserHandler) Create(c echo.Context) error {
var req model.CreateUserRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, model.ErrorResponse(err.Error()))
}
if err := c.Validate(&req); err != nil {
return err
}
user, err := h.userService.Create(c.Request().Context(), &req)
if err != nil {
if errors.Is(err, service.ErrUserAlreadyExists) {
return c.JSON(http.StatusConflict, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusInternalServerError, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusCreated, model.SuccessResponse(user.ToResponse()))
}
// Update updates a user
func (h *UserHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, model.ErrorResponse("invalid user ID"))
}
var req model.UpdateUserRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, model.ErrorResponse(err.Error()))
}
if err := c.Validate(&req); err != nil {
return err
}
user, err := h.userService.Update(c.Request().Context(), uint(id), &req)
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
return c.JSON(http.StatusNotFound, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusInternalServerError, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusOK, model.SuccessResponse(user.ToResponse()))
}
// Delete deletes a user
func (h *UserHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, model.ErrorResponse("invalid user ID"))
}
err = h.userService.Delete(c.Request().Context(), uint(id))
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
return c.JSON(http.StatusNotFound, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusInternalServerError, model.ErrorResponse(err.Error()))
}
return c.NoContent(http.StatusNoContent)
}
internal/handler/auth.go¶
package handler
import (
"net/http"
"myproject/internal/model"
"myproject/internal/service"
"github.com/labstack/echo/v4"
)
type AuthHandler struct {
authService service.AuthService
}
func NewAuthHandler(authService service.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
// Login authenticates a user
func (h *AuthHandler) Login(c echo.Context) error {
var req model.LoginRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, model.ErrorResponse(err.Error()))
}
if err := c.Validate(&req); err != nil {
return err
}
tokens, err := h.authService.Login(c.Request().Context(), &req)
if err != nil {
return c.JSON(http.StatusUnauthorized, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusOK, tokens)
}
// Register creates a new user
func (h *AuthHandler) Register(c echo.Context) error {
var req model.CreateUserRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, model.ErrorResponse(err.Error()))
}
if err := c.Validate(&req); err != nil {
return err
}
user, err := h.authService.Register(c.Request().Context(), &req)
if err != nil {
if err == service.ErrUserAlreadyExists {
return c.JSON(http.StatusConflict, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusInternalServerError, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusCreated, model.SuccessResponse(user.ToResponse()))
}
// Refresh refreshes the access token
func (h *AuthHandler) Refresh(c echo.Context) error {
var req struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, model.ErrorResponse(err.Error()))
}
tokens, err := h.authService.RefreshToken(c.Request().Context(), req.RefreshToken)
if err != nil {
return c.JSON(http.StatusUnauthorized, model.ErrorResponse(err.Error()))
}
return c.JSON(http.StatusOK, tokens)
}
Middleware¶
internal/middleware/auth.go¶
package middleware
import (
"net/http"
"strings"
"myproject/internal/model"
"myproject/internal/service"
"github.com/labstack/echo/v4"
)
type AuthMiddleware struct {
authService service.AuthService
}
func NewAuthMiddleware(authService service.AuthService) *AuthMiddleware {
return &AuthMiddleware{authService: authService}
}
// Authenticate validates JWT token
func (m *AuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
header := c.Request().Header.Get("Authorization")
if header == "" {
return c.JSON(http.StatusUnauthorized, model.ErrorResponse("missing authorization header"))
}
parts := strings.Split(header, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
return c.JSON(http.StatusUnauthorized, model.ErrorResponse("invalid authorization header"))
}
claims, err := m.authService.ValidateToken(parts[1])
if err != nil {
return c.JSON(http.StatusUnauthorized, model.ErrorResponse("invalid token"))
}
c.Set("user_id", claims.UserID)
c.Set("user_role", claims.Role)
return next(c)
}
}
// RequireAdmin requires admin role
func (m *AuthMiddleware) RequireAdmin(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
role, ok := c.Get("user_role").(string)
if !ok || role != "admin" {
return c.JSON(http.StatusForbidden, model.ErrorResponse("admin access required"))
}
return next(c)
}
}
Testing¶
internal/handler/user_test.go¶
package handler_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"myproject/internal/handler"
"myproject/internal/model"
"myproject/internal/service"
"myproject/internal/validator"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type MockUserService struct {
mock.Mock
}
func (m *MockUserService) Create(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) {
args := m.Called(ctx, req)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.User), args.Error(1)
}
func (m *MockUserService) GetByID(ctx context.Context, id uint) (*model.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.User), args.Error(1)
}
func (m *MockUserService) GetAll(ctx context.Context, page, perPage int) ([]model.User, int64, error) {
args := m.Called(ctx, page, perPage)
return args.Get(0).([]model.User), args.Get(1).(int64), args.Error(2)
}
func (m *MockUserService) Update(ctx context.Context, id uint, req *model.UpdateUserRequest) (*model.User, error) {
args := m.Called(ctx, id, req)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.User), args.Error(1)
}
func (m *MockUserService) Delete(ctx context.Context, id uint) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func setupEcho() *echo.Echo {
e := echo.New()
e.Validator = validator.NewCustomValidator()
return e
}
func TestCreateUser(t *testing.T) {
e := setupEcho()
mockService := new(MockUserService)
h := handler.NewUserHandler(mockService)
t.Run("successful creation", func(t *testing.T) {
expectedUser := &model.User{
ID: 1,
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
}
mockService.On("Create", mock.Anything, mock.Anything).Return(expectedUser, nil).Once()
body := `{"email":"test@example.com","password":"password123","first_name":"Test","last_name":"User"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
err := h.Create(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
mockService.AssertExpectations(t)
})
t.Run("duplicate email", func(t *testing.T) {
mockService.On("Create", mock.Anything, mock.Anything).
Return(nil, service.ErrUserAlreadyExists).Once()
body := `{"email":"existing@example.com","password":"password123","first_name":"Test","last_name":"User"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
err := h.Create(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusConflict, rec.Code)
mockService.AssertExpectations(t)
})
}
func TestGetUser(t *testing.T) {
e := setupEcho()
mockService := new(MockUserService)
h := handler.NewUserHandler(mockService)
t.Run("user found", func(t *testing.T) {
expectedUser := &model.User{
ID: 1,
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
}
mockService.On("GetByID", mock.Anything, uint(1)).Return(expectedUser, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
err := h.GetByID(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
mockService.AssertExpectations(t)
})
t.Run("user not found", func(t *testing.T) {
mockService.On("GetByID", mock.Anything, uint(999)).
Return(nil, service.ErrUserNotFound).Once()
req := httptest.NewRequest(http.MethodGet, "/users/999", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("999")
err := h.GetByID(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
mockService.AssertExpectations(t)
})
}
Commands Reference¶
# Initialize project
go mod init myproject
# Install dependencies
go mod tidy
# Run development server
go run cmd/api/main.go
# Build binary
go build -o bin/api cmd/api/main.go
# Run tests
go test ./...
go test -v -cover ./...
# Run with race detection
go test -race ./...
# Lint
golangci-lint run
# Database migrations (using golang-migrate)
migrate -path migrations -database "$DATABASE_URL" up
migrate -path migrations -database "$DATABASE_URL" down
Dependencies¶
// go.mod
module myproject
go 1.21
require (
github.com/labstack/echo/v4 v4.11.3
github.com/go-playground/validator/v10 v10.16.0
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.14.0
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5
)
require (
github.com/stretchr/testify v1.8.4 // testing
)
Best Practices¶
Echo-Specific Guidelines¶
- ✓ Use custom validator with go-playground/validator
- ✓ Use echo.Context for request handling
- ✓ Use middleware groups for route organization
- ✓ Use context for request-scoped values
- ✓ Return errors from handlers for centralized handling
- ✓ Use echo.Bind for request binding
- ✓ Configure proper timeouts and graceful shutdown
Performance Guidelines¶
- ✓ Use e.HideBanner = true in production
- ✓ Configure proper HTTP timeouts
- ✓ Use connection pooling for database
- ✓ Implement request logging middleware
- ✓ Use pagination for list endpoints