Files
honeyDueAPI/docs/GIN_TO_ECHO_MIGRATION.md
Trey t 6dac34e373 Migrate from Gin to Echo framework and add comprehensive integration tests
Major changes:
- Migrate all handlers from Gin to Echo framework
- Add new apperrors, echohelpers, and validator packages
- Update middleware for Echo compatibility
- Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column)
- Add 6 new integration tests:
  - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks
  - MultiUserSharing: Complex sharing with user removal
  - TaskStateTransitions: All state transitions and kanban column changes
  - DateBoundaryEdgeCases: Threshold boundary testing
  - CascadeOperations: Residence deletion cascade effects
  - MultiUserOperations: Shared residence collaboration
- Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.)
- Fix RemoveUser route param mismatch (userId -> user_id)
- Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:52:08 -06:00

9.7 KiB

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

// 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

// 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:

// 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

{
  "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)

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)

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

// 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

// 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)

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)

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

// 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

// 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

// 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

// 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