Handlers
> Organize HTTP request handling with Velocity handlers and Context-based handlers.
Handlers in Velocity handle HTTP requests and responses, providing a clean way to organize your application logic following the MVC pattern. Handlers use Context-based functions that receive a *router.Context and return an error.
Quick Start
Creating and using handlers in Velocity:
// internal/handlers/user_handler.go
package handlers
import (
"myapp/internal/models"
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/view"
)
type UserHandler struct{}
func NewUserHandler() *UserHandler {
return &UserHandler{}
}
func (c *UserHandler) Index(ctx *router.Context) error {
users, err := models.User{}.All()
if err != nil {
return ctx.JSON(500, map[string]string{"error": "Failed to load users"})
}
return view.Render(ctx, "users/index", view.Props{
"users": users,
})
}
func (c *UserHandler) Show(ctx *router.Context) error {
id := ctx.Param("id")
user, err := models.User{}.Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "User not found"})
}
return view.Render(ctx, "users/show", view.Props{
"user": user,
})
}Handler Structure
Basic Handler
package handlers
import (
"myapp/internal/models"
"github.com/velocitykode/velocity/auth"
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/view"
)
type PostHandler struct{}
func NewPostHandler() *PostHandler {
return &PostHandler{}
}
// Show all posts
func (c *PostHandler) Index(ctx *router.Context) error {
posts, err := models.Post{}.With("User").OrderBy("created_at", "DESC").Get()
if err != nil {
return ctx.JSON(500, map[string]string{"error": "Failed to load posts"})
}
return view.Render(ctx, "posts/index", view.Props{
"posts": posts,
})
}
// Show single post
func (c *PostHandler) Show(ctx *router.Context) error {
id := ctx.Param("id")
post, err := models.Post{}.With("User", "Comments.User").Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "Post not found"})
}
return view.Render(ctx, "posts/show", view.Props{
"post": post,
})
}
// Show create form
func (c *PostHandler) Create(ctx *router.Context) error {
return view.Render(ctx, "posts/create", view.Props{})
}
// Store new post
func (c *PostHandler) Store(ctx *router.Context) error {
// Bind and validate request
var input struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := ctx.Bind(&input); err != nil {
return ctx.JSON(400, map[string]string{"error": "Invalid input"})
}
// Get authenticated user
user := auth.FromContext(ctx).User(ctx.Request).(*models.User)
// Create post
post, err := models.Post{}.Create(map[string]any{
"title": input.Title,
"body": input.Body,
"user_id": user.ID,
})
if err != nil {
return ctx.JSON(500, map[string]string{"error": "Failed to create post"})
}
view.Location(ctx, fmt.Sprintf("/posts/%d", post.ID))
return nil
}
// Show edit form
func (c *PostHandler) Edit(ctx *router.Context) error {
id := ctx.Param("id")
post, err := models.Post{}.Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "Post not found"})
}
return view.Render(ctx, "posts/edit", view.Props{
"post": post,
})
}
// Update post
func (c *PostHandler) Update(ctx *router.Context) error {
id := ctx.Param("id")
post, err := models.Post{}.Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "Post not found"})
}
var input struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := ctx.Bind(&input); err != nil {
return ctx.JSON(400, map[string]string{"error": "Invalid input"})
}
post.Update(map[string]any{
"title": input.Title,
"body": input.Body,
})
view.Location(ctx, fmt.Sprintf("/posts/%d", post.ID))
return nil
}
// Delete post
func (c *PostHandler) Destroy(ctx *router.Context) error {
id := ctx.Param("id")
post, err := models.Post{}.Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "Post not found"})
}
if err := post.Delete(); err != nil {
return ctx.JSON(500, map[string]string{"error": "Failed to delete post"})
}
view.Location(ctx, "/posts")
return nil
}Base Handler
Create a base handler with common functionality:
// internal/handlers/base_handler.go
package handlers
import (
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/view"
"github.com/velocitykode/velocity/auth"
"myapp/internal/models"
)
type BaseHandler struct{}
// JSON response helper
func (c *BaseHandler) JSON(ctx *router.Context, data interface{}, status ...int) error {
statusCode := 200
if len(status) > 0 {
statusCode = status[0]
}
return ctx.JSON(statusCode, data)
}
// Error response helper
func (c *BaseHandler) Error(ctx *router.Context, message string, status ...int) error {
statusCode := 500
if len(status) > 0 {
statusCode = status[0]
}
return ctx.JSON(statusCode, map[string]string{
"error": message,
})
}
// Not found response
func (c *BaseHandler) NotFound(ctx *router.Context) error {
return c.Error(ctx, "Resource not found", 404)
}
// Forbidden response
func (c *BaseHandler) Forbidden(ctx *router.Context) error {
return c.Error(ctx, "Access forbidden", 403)
}
// Unauthorized response
func (c *BaseHandler) Unauthorized(ctx *router.Context) error {
return c.Error(ctx, "Authentication required", 401)
}
// Redirect helper
func (c *BaseHandler) Redirect(ctx *router.Context, url string, status ...int) error {
statusCode := 302
if len(status) > 0 {
statusCode = status[0]
}
return ctx.Redirect(statusCode, url)
}
// Render with validation errors
// ctx.WithErrors flashes the errors so they survive a redirect and are
// available to the next view render.
func (c *BaseHandler) WithErrors(ctx *router.Context, errors map[string][]string) {
ctx.WithErrors(errors)
}
// Authorization helper
func (c *BaseHandler) authorize(ctx *router.Context, action string, resource interface{}) bool {
user := auth.FromContext(ctx).User(ctx.Request)
if user == nil {
return false
}
// Implement your authorization logic here
// For example, check if user can perform action on resource
return true
}
// Get authenticated user
func (c *BaseHandler) user(ctx *router.Context) *models.User {
return auth.FromContext(ctx).User(ctx.Request).(*models.User)
}Resource Handlers
Velocity supports RESTful resource handlers:
// internal/handlers/api/user_handler.go
package api
import (
"strconv"
"myapp/internal/models"
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/auth"
)
type UserHandler struct {
BaseHandler
}
// GET /api/users
func (c *UserHandler) Index(ctx *router.Context) error {
page := ctx.Query("page")
if page == "" {
page = "1"
}
users, err := models.User{}.
With("Profile").
Paginate(page, 15)
if err != nil {
return c.Error(ctx, "Failed to load users")
}
return c.JSON(ctx, users)
}
// GET /api/users/{id}
func (c *UserHandler) Show(ctx *router.Context) error {
id := ctx.Param("id")
user, err := models.User{}.With("Profile", "Posts").Find(id)
if err != nil {
return c.NotFound(ctx)
}
return c.JSON(ctx, user)
}
// POST /api/users
func (c *UserHandler) Store(ctx *router.Context) error {
var input struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := ctx.Bind(&input); err != nil {
return ctx.JSON(400, map[string]string{"error": "Invalid input"})
}
if err := ctx.Validate(map[string][]string{
"name": {"required", "string", "max:255"},
"email": {"required", "email", "unique:users,email"},
"password": {"required", "string", "min:8"},
}); err != nil {
return ctx.JSON(422, map[string]interface{}{
"error": err.Error(),
})
}
hashedPassword, _ := auth.FromContext(ctx).Hash(input.Password)
user := models.User{
Name: input.Name,
Email: input.Email,
Password: hashedPassword,
}
if err := user.Save(); err != nil {
return c.Error(ctx, "Failed to create user")
}
return ctx.JSON(201, user)
}
// PUT /api/users/{id}
func (c *UserHandler) Update(ctx *router.Context) error {
id := ctx.Param("id")
user, err := models.User{}.Find(id)
if err != nil {
return c.NotFound(ctx)
}
var input struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := ctx.Bind(&input); err != nil {
return ctx.JSON(400, map[string]string{"error": "Invalid input"})
}
if err := ctx.Validate(map[string][]string{
"name": {"sometimes", "string", "max:255"},
"email": {"sometimes", "email", "unique:users,email," + strconv.Itoa(int(user.ID))},
}); err != nil {
return ctx.JSON(422, map[string]interface{}{
"error": err.Error(),
})
}
if err := user.Update(map[string]any{
"name": input.Name,
"email": input.Email,
}); err != nil {
return c.Error(ctx, "Failed to update user")
}
return c.JSON(ctx, user)
}
// DELETE /api/users/{id}
func (c *UserHandler) Destroy(ctx *router.Context) error {
id := ctx.Param("id")
user, err := models.User{}.Find(id)
if err != nil {
return c.NotFound(ctx)
}
if err := user.Delete(); err != nil {
return c.Error(ctx, "Failed to delete user")
}
ctx.Response.WriteHeader(204)
return nil
}Handler Middleware
Apply middleware to handlers:
// routes/web.go
package routes
import (
"myapp/internal/handlers"
"myapp/internal/middleware"
"github.com/velocitykode/velocity/router"
)
func WebRoutes(r router.Router) {
// Public routes
homeHandler := &handlers.HomeHandler{}
postHandler := &handlers.PostHandler{}
r.Get("/", homeHandler.Index)
r.Get("/posts", postHandler.Index)
r.Get("/posts/{id}", postHandler.Show)
// Protected routes. Group takes a path prefix ("" for none) and an
// optional closure that receives the group's router.
r.Group("", func(r router.Router) {
r.Use(middleware.Auth)
r.Get("/posts/create", postHandler.Create)
r.Post("/posts", postHandler.Store)
r.Get("/posts/{id}/edit", postHandler.Edit)
r.Put("/posts/{id}", postHandler.Update)
r.Delete("/posts/{id}", postHandler.Destroy)
})
}Form Handling
Handle form submissions:
func (c *PostHandler) Store(ctx *router.Context) error {
// Parse form data
if err := ctx.Request.ParseForm(); err != nil {
return c.Error(ctx, "Invalid form data")
}
// Manual validation
title := strings.TrimSpace(ctx.Request.FormValue("title"))
body := strings.TrimSpace(ctx.Request.FormValue("body"))
if title == "" {
c.WithErrors(ctx, map[string][]string{
"title": {"Title is required"},
})
return nil
}
if len(body) < 10 {
c.WithErrors(ctx, map[string][]string{
"body": {"Body must be at least 10 characters"},
})
return nil
}
// Create post
post := models.Post{
Title: title,
Body: body,
UserID: c.user(ctx).ID,
}
if err := post.Save(); err != nil {
return c.Error(ctx, "Failed to create post")
}
// Flash a success message and redirect. view.For binds the view
// engine to this request so flash and the terminal redirect chain.
view.For(ctx).
Flash("success", "Post created successfully").
Redirect(fmt.Sprintf("/posts/%d", post.ID))
return nil
}File Uploads
Handle file uploads with the Context helpers. ctx.FormFile parses the
multipart form (under the router’s body-size limit) and returns the
*multipart.FileHeader; ctx.SaveFile writes it under the router’s
configured FileRoot with kernel-enforced path containment, so a
traversal or symlinked dst cannot escape the upload directory. Pass
FileValidationOption values to enforce size, extension, and MIME limits
before any bytes are written:
func (c *UserHandler) UpdateAvatar(ctx *router.Context) error {
header, err := ctx.FormFile("avatar")
if err != nil {
return c.Error(ctx, "No file uploaded")
}
// Save under FileRoot, validating size and type before writing.
fileName := fmt.Sprintf("avatars/%d_%s", c.user(ctx).ID, header.Filename)
if err := ctx.SaveFile(header, fileName,
router.MaxFileSize(10<<20), // 10MB max
router.AllowedMIMETypes("image/jpeg", "image/png", "image/gif"),
); err != nil {
return c.Error(ctx, "Failed to save file")
}
// Update user avatar
user := c.user(ctx)
user.Update(map[string]any{
"avatar": fileName,
})
return c.JSON(ctx, map[string]string{
"message": "Avatar updated successfully",
"avatar": fileName,
})
}Request/Response Helpers
Useful helpers for handlers:
// Get query parameters. The framework also exposes ctx.QueryDefault,
// ctx.QueryInt, ctx.QueryInt64, ctx.QueryFloat64, and ctx.QueryBool for
// typed access.
func (c *BaseHandler) getQuery(ctx *router.Context, key string, defaultValue ...string) string {
if len(defaultValue) > 0 {
return ctx.QueryDefault(key, defaultValue[0])
}
return ctx.Query(key)
}
// Get form value
func (c *BaseHandler) getForm(ctx *router.Context, key string, defaultValue ...string) string {
value := ctx.FormValue(key)
if value == "" && len(defaultValue) > 0 {
return defaultValue[0]
}
return value
}
// Parse JSON body
func (c *BaseHandler) parseJSON(ctx *router.Context, v interface{}) error {
return ctx.Bind(v)
}
// Get client IP. Prefer ctx.IP(): it resolves the real client address
// through the router's trusted-proxy policy instead of blindly trusting
// X-Forwarded-For, which a client can spoof.
func (c *BaseHandler) getClientIP(ctx *router.Context) string {
return ctx.IP()
}Error Handling
Centralized error handling:
// internal/handlers/error_handler.go
package handlers
import (
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/view"
)
type ErrorHandler struct{}
func (c *ErrorHandler) NotFound(ctx *router.Context) error {
ctx.Response.WriteHeader(404)
return view.Render(ctx, "errors/404", view.Props{
"url": ctx.Request.URL.Path,
})
}
func (c *ErrorHandler) InternalError(ctx *router.Context, err error) error {
// ctx.Log() returns the request-scoped logger; it logs structured
// key/value pairs after the message.
ctx.Log().Error("Internal server error", "error", err, "url", ctx.Request.URL.Path)
ctx.Response.WriteHeader(500)
return view.Render(ctx, "errors/500", view.Props{
"error": err.Error(),
})
}
func (c *ErrorHandler) Forbidden(ctx *router.Context) error {
ctx.Response.WriteHeader(403)
return view.Render(ctx, "errors/403", view.Props{})
}Testing Handlers
Test your handlers:
// internal/handlers/user_handler_test.go
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"net/url"
"strings"
"github.com/stretchr/testify/assert"
"myapp/internal/models"
"github.com/velocitykode/velocity/router"
)
func TestUserHandler_Index(t *testing.T) {
// Setup test database
setupTestDB()
defer teardownTestDB()
// Create test user
user := models.User{
Name: "Test User",
Email: "test@example.com",
}
user.Save()
// Create request and response recorder
req := httptest.NewRequest("GET", "/users", nil)
w := httptest.NewRecorder()
// Create context
ctx := &router.Context{
Request: req,
Response: w,
}
// Call handler
handler := &UserHandler{}
err := handler.Index(ctx)
// Assert response
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Test User")
}
func TestUserHandler_Store(t *testing.T) {
setupTestDB()
defer teardownTestDB()
// Create form data
form := url.Values{}
form.Add("name", "New User")
form.Add("email", "new@example.com")
req := httptest.NewRequest("POST", "/users", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
// Create context
ctx := &router.Context{
Request: req,
Response: w,
}
// Call handler
handler := &UserHandler{}
err := handler.Store(ctx)
// Assert response
assert.NoError(t, err)
assert.Equal(t, http.StatusFound, w.Code)
// Verify user was created
user, err := models.User{}.FindBy("email", "new@example.com")
assert.NoError(t, err)
assert.Equal(t, "New User", user.Name)
}Best Practices
- Keep handlers thin - Move business logic to models or services
- Use base handler - Share common functionality across handlers
- Validate input - Always validate user input before processing
- Handle errors gracefully - Provide meaningful error messages
- Use middleware - Apply cross-cutting concerns like authentication
- Return appropriate status codes - Use correct HTTP status codes
- Test handlers - Write unit tests for handler methods
- Separate concerns - Keep API and web handlers separate