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>
This commit is contained in:
417
docs/GIN_TO_ECHO_MIGRATION.md
Normal file
417
docs/GIN_TO_ECHO_MIGRATION.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# 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** |
|
||||
Reference in New Issue
Block a user