Backend (Go API)

Middleware

Middleware functions intercept HTTP requests before they reach your handlers. Grit ships with four built-in middleware: Auth (JWT validation), CORS, Logger, and Cache. You can also create custom middleware.

Middleware Order & Registration

Middleware is registered in routes/routes.go. The order matters -- middleware executes in the order it is registered.

apps/api/internal/routes/routes.go
func Setup(db *gorm.DB, cfg *config.Config, svc *Services) *gin.Engine {
    r := gin.New()

    // ── Global middleware (applied to ALL routes) ────────
    r.Use(middleware.Logger())       // 1. Log every request
    r.Use(gin.Recovery())            // 2. Recover from panics
    r.Use(middleware.CORS(cfg.CORSOrigins)) // 3. Handle CORS

    // ── Public routes (no auth required) ────────────────
    auth := r.Group("/api/auth")
    {
        auth.POST("/register", authHandler.Register)
        auth.POST("/login", authHandler.Login)
    }

    // ── Protected routes (auth middleware applied) ──────
    protected := r.Group("/api")
    protected.Use(middleware.Auth(db, authService)) // 4. Require JWT
    {
        protected.GET("/posts", postHandler.List)
    }

    // ── Admin routes (auth + role check) ────────────────
    admin := r.Group("/api")
    admin.Use(middleware.Auth(db, authService))     // 4. Require JWT
    admin.Use(middleware.RequireRole("admin"))       // 5. Require admin role
    {
        admin.GET("/users", userHandler.List)
        admin.DELETE("/users/:id", userHandler.Delete)
    }

    return r
}

Execution order: Logger runs first, then Recovery, then CORS, then Auth (if on a protected route), then RequireRole (if on an admin route), then finally the handler itself.

Auth Middleware

The Auth middleware extracts the JWT token from the Authorization header, validates it, loads the user from the database, and stores the user in the Gin context for downstream handlers.

apps/api/internal/middleware/auth.go
func Auth(db *gorm.DB, authService *services.AuthService) gin.HandlerFunc {
    return func(c *gin.Context) {
        // 1. Extract the Authorization header
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.JSON(http.StatusUnauthorized, gin.H{
                "error": gin.H{
                    "code":    "UNAUTHORIZED",
                    "message": "Authorization header is required",
                },
            })
            c.Abort()
            return
        }

        // 2. Parse "Bearer <token>"
        parts := strings.SplitN(authHeader, " ", 2)
        if len(parts) != 2 || parts[0] != "Bearer" {
            c.JSON(http.StatusUnauthorized, gin.H{
                "error": gin.H{
                    "code":    "UNAUTHORIZED",
                    "message": "Invalid authorization header format",
                },
            })
            c.Abort()
            return
        }

        // 3. Validate the JWT token
        claims, err := authService.ValidateToken(parts[1])
        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{
                "error": gin.H{
                    "code":    "UNAUTHORIZED",
                    "message": "Invalid or expired token",
                },
            })
            c.Abort()
            return
        }

        // 4. Load user from database
        var user models.User
        if err := db.First(&user, claims.UserID).Error; err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{
                "error": gin.H{
                    "code":    "UNAUTHORIZED",
                    "message": "User not found",
                },
            })
            c.Abort()
            return
        }

        // 5. Check if the account is active
        if !user.Active {
            c.JSON(http.StatusForbidden, gin.H{
                "error": gin.H{
                    "code":    "ACCOUNT_DISABLED",
                    "message": "Your account has been disabled",
                },
            })
            c.Abort()
            return
        }

        // 6. Store user data in context for handlers
        c.Set("user", user)
        c.Set("user_id", user.ID)
        c.Set("user_role", user.Role)
        c.Next()
    }
}

After this middleware runs, handlers can access the authenticated user:

accessing user in handler
func (h *PostHandler) Create(c *gin.Context) {
    // Get the full user object
    user, _ := c.Get("user")
    currentUser := user.(models.User)

    // Or get individual fields
    userID, _ := c.Get("user_id")    // uint
    role, _ := c.Get("user_role")     // string
}

RequireRole Middleware

RequireRole checks if the authenticated user has one of the specified roles. It must be used after the Auth middleware (which sets user_rolein the context).

apps/api/internal/middleware/auth.go
// RequireRole checks if the user has one of the required roles.
func RequireRole(roles ...string) gin.HandlerFunc {
    return func(c *gin.Context) {
        userRole, exists := c.Get("user_role")
        if !exists {
            c.JSON(http.StatusUnauthorized, gin.H{
                "error": gin.H{
                    "code":    "UNAUTHORIZED",
                    "message": "Not authenticated",
                },
            })
            c.Abort()
            return
        }

        role, ok := userRole.(string)
        if !ok {
            c.JSON(http.StatusInternalServerError, gin.H{
                "error": gin.H{
                    "code":    "INTERNAL_ERROR",
                    "message": "Invalid user role",
                },
            })
            c.Abort()
            return
        }

        for _, r := range roles {
            if role == r {
                c.Next()
                return
            }
        }

        c.JSON(http.StatusForbidden, gin.H{
            "error": gin.H{
                "code":    "FORBIDDEN",
                "message": "You do not have permission to access this resource",
            },
        })
        c.Abort()
    }
}

Usage examples:

role_examples.go
// Admin only
admin.Use(middleware.RequireRole("admin"))

// Admin or editor
editors.Use(middleware.RequireRole("admin", "editor"))

// Any authenticated user (no RequireRole needed, just Auth middleware)

CORS Middleware

The CORS middleware allows your Next.js frontend (running on a different port) to make API requests to the Go backend. It reads the allowed origins from the CORS_ORIGINS environment variable.

apps/api/internal/middleware/cors.go
func CORS(allowedOrigins []string) gin.HandlerFunc {
    originsMap := make(map[string]bool)
    for _, origin := range allowedOrigins {
        originsMap[origin] = true
    }

    return func(c *gin.Context) {
        origin := c.GetHeader("Origin")

        if originsMap[origin] {
            c.Header("Access-Control-Allow-Origin", origin)
        }

        c.Header("Access-Control-Allow-Methods",
            "GET, POST, PUT, PATCH, DELETE, OPTIONS")
        c.Header("Access-Control-Allow-Headers",
            "Origin, Content-Type, Accept, Authorization")
        c.Header("Access-Control-Allow-Credentials", "true")
        c.Header("Access-Control-Max-Age", "86400")

        if c.Request.Method == http.MethodOptions {
            c.AbortWithStatus(http.StatusNoContent)
            return
        }

        c.Next()
    }
}

Configure allowed origins in your .env file:

.env
CORS_ORIGINS=http://localhost:3000,http://localhost:3001

Logger Middleware

The Logger middleware logs every HTTP request with its status code, method, path, client IP, and response latency. It runs before all other middleware so it captures the total request time.

apps/api/internal/middleware/logger.go
func Logger() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        path := c.Request.URL.Path
        query := c.Request.URL.RawQuery

        c.Next()

        latency := time.Since(start)
        status := c.Writer.Status()
        method := c.Request.Method
        clientIP := c.ClientIP()

        if query != "" {
            path = path + "?" + query
        }

        log.Printf("[%d] %s %s | %s | %v",
            status, method, path, clientIP, latency,
        )
    }
}

Example output:

terminal
[200] GET /api/users?page=1 | 127.0.0.1 | 3.241ms
[201] POST /api/posts | 127.0.0.1 | 12.507ms
[401] GET /api/auth/me | 127.0.0.1 | 0.128ms

Cache Middleware

The CacheResponse middleware caches the full HTTP response for GET requests in Redis. Subsequent identical requests are served from cache with an X-Cache: HIT header.

apps/api/internal/middleware/cache.go
func CacheResponse(
    cacheService *cache.Cache, ttl time.Duration,
) gin.HandlerFunc {
    return func(c *gin.Context) {
        // Only cache GET requests
        if cacheService == nil || c.Request.Method != http.MethodGet {
            c.Next()
            return
        }

        // Build cache key from the full URL (path + query)
        key := fmt.Sprintf("http:%x",
            sha256.Sum256([]byte(c.Request.URL.String())),
        )

        // Try to serve from cache
        var cached cachedResponse
        found, err := cacheService.Get(c.Request.Context(), key, &cached)
        if err == nil && found {
            c.Header("X-Cache", "HIT")
            c.Data(cached.Status, cached.ContentType, cached.Body)
            c.Abort()
            return
        }

        // Capture the response
        writer := &responseCapture{
            ResponseWriter: c.Writer,
            body: make([]byte, 0),
        }
        c.Writer = writer
        c.Header("X-Cache", "MISS")

        c.Next()

        // Cache successful responses
        if writer.status == http.StatusOK && len(writer.body) > 0 {
            resp := cachedResponse{
                Status:      writer.status,
                ContentType: writer.Header().Get("Content-Type"),
                Body:        writer.body,
            }
            _ = cacheService.Set(c.Request.Context(), key, resp, ttl)
        }
    }
}

Apply it to specific routes:

cache_usage.go
// Cache the posts list for 5 minutes
protected.GET("/posts",
    middleware.CacheResponse(svc.Cache, 5*time.Minute),
    postHandler.List,
)

// Cache individual post for 10 minutes
protected.GET("/posts/:id",
    middleware.CacheResponse(svc.Cache, 10*time.Minute),
    postHandler.GetByID,
)

Creating Custom Middleware

A Gin middleware is any function that returns gin.HandlerFunc. Here is the pattern for creating your own:

apps/api/internal/middleware/rate_limit.go
package middleware

import (
    "net/http"
    "sync"
    "time"

    "github.com/gin-gonic/gin"
)

// RateLimit creates a simple in-memory rate limiter.
// maxRequests is the maximum number of requests allowed
// within the given window duration per IP address.
func RateLimit(maxRequests int, window time.Duration) gin.HandlerFunc {
    type client struct {
        count    int
        lastSeen time.Time
    }

    var mu sync.Mutex
    clients := make(map[string]*client)

    // Clean up old entries periodically
    go func() {
        for {
            time.Sleep(window)
            mu.Lock()
            for ip, c := range clients {
                if time.Since(c.lastSeen) > window {
                    delete(clients, ip)
                }
            }
            mu.Unlock()
        }
    }()

    return func(c *gin.Context) {
        ip := c.ClientIP()

        mu.Lock()
        cl, exists := clients[ip]
        if !exists {
            clients[ip] = &client{count: 1, lastSeen: time.Now()}
            mu.Unlock()
            c.Next()
            return
        }

        if time.Since(cl.lastSeen) > window {
            cl.count = 1
            cl.lastSeen = time.Now()
            mu.Unlock()
            c.Next()
            return
        }

        cl.count++
        cl.lastSeen = time.Now()

        if cl.count > maxRequests {
            mu.Unlock()
            c.JSON(http.StatusTooManyRequests, gin.H{
                "error": gin.H{
                    "code":    "RATE_LIMITED",
                    "message": "Too many requests, please try again later",
                },
            })
            c.Abort()
            return
        }

        mu.Unlock()
        c.Next()
    }
}

Register it on routes that need rate limiting:

routes.go
// Limit auth endpoints to 10 requests per minute per IP
auth := r.Group("/api/auth")
auth.Use(middleware.RateLimit(10, 1*time.Minute))
{
    auth.POST("/login", authHandler.Login)
    auth.POST("/register", authHandler.Register)
}

Middleware Anatomy

Every Gin middleware follows the same structure:

middleware_anatomy.go
func MyMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // ── BEFORE the handler ──────────────────────
        // Check conditions, set context values, etc.

        // Option A: Continue to next middleware/handler
        c.Next()

        // ── AFTER the handler ───────────────────────
        // Log results, clean up, etc.

        // Option B: Stop the chain (use instead of c.Next())
        // c.Abort()
        // c.JSON(http.StatusForbidden, gin.H{...})
    }
}
  • c.Next() passes control to the next middleware or handler in the chain. Code after c.Next() runs after the handler returns.
  • c.Abort() stops the chain. No further middleware or handlers will execute.
  • c.Set(key, value) stores data in the context for downstream middleware and handlers.
  • c.Get(key) retrieves data stored by upstream middleware.

Built-in Middleware Summary

MiddlewareFilePurpose
Logger()middleware/logger.goLogs every request with status, method, path, latency
CORS(origins)middleware/cors.goSets CORS headers, handles preflight OPTIONS
Auth(db, svc)middleware/auth.goValidates JWT, loads user, sets context
RequireRole(roles...)middleware/auth.goChecks user role against allowed list
CacheResponse(cache, ttl)middleware/cache.goCaches GET responses in Redis