# Gin to Echo Framework Migration Guide This document outlines the migration of the MyCrib Go API from Gin to Echo v4 with direct go-playground/validator integration. ## Overview | Aspect | Before | After | |--------|--------|-------| | Framework | Gin v1.10 | Echo v4.11 | | Validation | Gin's binding wrapper | Direct go-playground/validator | | Validation tags | `binding:"..."` | `validate:"..."` | | Error format | Inconsistent | Structured field-level | ## Scope - **56 files** requiring modification - **110+ routes** across public and admin APIs - **45 handlers** (17 core + 28 admin) - **5 middleware** files --- ## API Mapping Reference ### Context Methods | Gin | Echo | Notes | |-----|------|-------| | `c.ShouldBindJSON(&req)` | `c.Bind(&req)` | Bind only, no validation | | `c.ShouldBindJSON(&req)` | `c.Validate(&req)` | Validation only (call after Bind) | | `c.Param("id")` | `c.Param("id")` | Same | | `c.Query("name")` | `c.QueryParam("name")` | Different method name | | `c.DefaultQuery("k","v")` | Custom helper | No built-in equivalent | | `c.PostForm("field")` | `c.FormValue("field")` | Different method name | | `c.FormFile("file")` | `c.FormFile("file")` | Same | | `c.Get(key)` | `c.Get(key)` | Same | | `c.Set(key, val)` | `c.Set(key, val)` | Same | | `c.MustGet(key)` | `c.Get(key)` | No MustGet, add nil check | | `c.JSON(status, obj)` | `return c.JSON(status, obj)` | Must return | | `c.AbortWithStatusJSON()` | `return c.JSON()` | Return-based flow | | `c.Status(200)` | `return c.NoContent(200)` | Different method | | `c.GetHeader("X-...")` | `c.Request().Header.Get()` | Access via Request | | `c.ClientIP()` | `c.RealIP()` | Different method name | | `c.File(path)` | `return c.File(path)` | Must return | | `gin.H{...}` | `echo.Map{...}` | Or `map[string]any{}` | ### Handler Signature ```go // Gin - void return func (h *Handler) Method(c *gin.Context) { // ... c.JSON(200, response) } // Echo - error return (MUST return) func (h *Handler) Method(c echo.Context) error { // ... return c.JSON(200, response) } ``` ### Middleware Signature ```go // Gin func MyMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // before c.Next() // after } } // Echo func MyMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { // before err := next(c) // after return err } } } ``` --- ## Validation Changes ### Tag Migration Change all `binding:` tags to `validate:` tags: ```go // Before type LoginRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8"` } // After type LoginRequest struct { Email string `json:"email" validate:"required,email"` Password string `json:"password" validate:"required,min=8"` } ``` ### Supported Validation Tags | Tag | Description | |-----|-------------| | `required` | Field must be present and non-zero | | `required_without=Field` | Required if other field is empty | | `omitempty` | Skip validation if empty | | `email` | Must be valid email format | | `min=N` | Minimum length/value | | `max=N` | Maximum length/value | | `len=N` | Exact length | | `oneof=a b c` | Must be one of listed values | | `url` | Must be valid URL | | `uuid` | Must be valid UUID | ### New Error Response Format ```json { "error": "Validation failed", "fields": { "email": { "message": "Must be a valid email address", "tag": "email" }, "password": { "message": "Must be at least 8 characters", "tag": "min" } } } ``` **Mobile clients must update** error parsing to handle the `fields` object. --- ## Handler Migration Pattern ### Before (Gin) ```go func (h *AuthHandler) Login(c *gin.Context) { var req requests.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, responses.ErrorResponse{ Error: "Invalid request body", }) return } user := c.MustGet(middleware.AuthUserKey).(*models.User) response, err := h.authService.Login(&req) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, response) } ``` ### After (Echo) ```go func (h *AuthHandler) Login(c echo.Context) error { var req requests.LoginRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, responses.ErrorResponse{ Error: "Invalid request body", }) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } user := c.Get(middleware.AuthUserKey).(*models.User) response, err := h.authService.Login(&req) if err != nil { return c.JSON(http.StatusUnauthorized, echo.Map{"error": err.Error()}) } return c.JSON(http.StatusOK, response) } ``` --- ## Middleware Migration Examples ### Auth Middleware ```go // Before (Gin) func (m *AuthMiddleware) TokenAuth() gin.HandlerFunc { return func(c *gin.Context) { token, err := extractToken(c) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } user, err := m.validateToken(token) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) return } c.Set(AuthUserKey, user) c.Next() } } // After (Echo) func (m *AuthMiddleware) TokenAuth() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { token, err := extractToken(c) if err != nil { return c.JSON(http.StatusUnauthorized, echo.Map{"error": "Unauthorized"}) } user, err := m.validateToken(token) if err != nil { return c.JSON(http.StatusUnauthorized, echo.Map{"error": "Invalid token"}) } c.Set(AuthUserKey, user) return next(c) } } } ``` ### Timezone Middleware ```go // Before (Gin) func TimezoneMiddleware() gin.HandlerFunc { return func(c *gin.Context) { tzName := c.GetHeader(TimezoneHeader) loc := parseTimezone(tzName) c.Set(TimezoneKey, loc) c.Next() } } // After (Echo) func TimezoneMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { tzName := c.Request().Header.Get(TimezoneHeader) loc := parseTimezone(tzName) c.Set(TimezoneKey, loc) return next(c) } } } ``` --- ## Router Setup ### Before (Gin) ```go func SetupRouter(deps *Dependencies) *gin.Engine { r := gin.New() r.Use(gin.Recovery()) r.Use(gin.Logger()) r.Use(cors.New(cors.Config{...})) api := r.Group("/api") api.POST("/auth/login/", authHandler.Login) protected := api.Group("") protected.Use(authMiddleware.TokenAuth()) protected.GET("/residences/", residenceHandler.List) return r } ``` ### After (Echo) ```go func SetupRouter(deps *Dependencies) *echo.Echo { e := echo.New() e.HideBanner = true e.Validator = validator.NewCustomValidator() // Trailing slash handling e.Pre(middleware.AddTrailingSlash()) e.Use(middleware.Recover()) e.Use(middleware.Logger()) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{...})) api := e.Group("/api") api.POST("/auth/login/", authHandler.Login) protected := api.Group("") protected.Use(authMiddleware.TokenAuth()) protected.GET("/residences/", residenceHandler.List) return e } ``` --- ## Helper Functions ### DefaultQuery Helper ```go // internal/echohelpers/helpers.go func DefaultQuery(c echo.Context, key, defaultValue string) string { if val := c.QueryParam(key); val != "" { return val } return defaultValue } ``` ### Safe Context Get ```go // internal/middleware/helpers.go func GetAuthUser(c echo.Context) *models.User { val := c.Get(AuthUserKey) if val == nil { return nil } user, ok := val.(*models.User) if !ok { return nil } return user } func MustGetAuthUser(c echo.Context) (*models.User, error) { user := GetAuthUser(c) if user == nil { return nil, echo.NewHTTPError(http.StatusUnauthorized, "Authentication required") } return user, nil } ``` --- ## Testing Changes ### Test Setup ```go // Before (Gin) func SetupTestRouter() *gin.Engine { gin.SetMode(gin.TestMode) return gin.New() } // After (Echo) func SetupTestRouter() *echo.Echo { e := echo.New() e.Validator = validator.NewCustomValidator() return e } ``` ### Making Test Requests ```go // Before (Gin) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/auth/login/", body) router.ServeHTTP(w, req) // After (Echo) - Same pattern works rec := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/auth/login/", body) e.ServeHTTP(rec, req) ``` --- ## Important Notes 1. **All handlers must return error** - Echo uses return-based flow 2. **Trailing slashes** - Use `middleware.AddTrailingSlash()` to maintain API compatibility 3. **Type assertions** - Always add nil checks when using `c.Get()` 4. **CORS** - Use Echo's built-in CORS middleware 5. **Bind vs Validate** - Echo separates these; call both for full validation --- ## Files Modified | Category | Count | |----------|-------| | New files | 2 (`validator/`, `echohelpers/`) | | DTOs | 6 | | Middleware | 5 | | Core handlers | 14 | | Admin handlers | 28 | | Router | 2 | | Tests | 6 | | **Total** | **63** |