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>
9.7 KiB
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
- All handlers must return error - Echo uses return-based flow
- Trailing slashes - Use
middleware.AddTrailingSlash()to maintain API compatibility - Type assertions - Always add nil checks when using
c.Get() - CORS - Use Echo's built-in CORS middleware
- 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 |