custom routes - Innovix-Matrix-Systems/ims-pocketbase-baas-starter GitHub Wiki

Custom Routes Guide

This document explains how to create custom API routes and endpoints in the IMS PocketBase BaaS Starter, including integration with API documentation.

Overview

While PocketBase provides automatic CRUD APIs for collections, you often need custom endpoints for specific business logic, data processing, or integrations. This guide shows how to create, organize, and document custom routes.

Project Structure for Custom Routes

internal/
├── handlers/
│   └── route/              # Custom route handlers
│       ├── user_handler.go
│       ├── stats_handler.go
│       └── cache_handler.go
├── routes/
│   └── routes.go          # Route registration
└── app/
    └── app.go             # App initialization with route registration

Creating Your First Custom Route

Step 1: Create a Route Handler

Create a new handler file in internal/handlers/route/:

// internal/handlers/route/hello_handler.go
package route

import (
    "ims-pocketbase-baas-starter/pkg/logger"
    "github.com/pocketbase/pocketbase/core"
)

// HelloResponse represents the response structure
type HelloResponse struct {
    Message   string `json:"message"`
    Timestamp string `json:"timestamp"`
    Version   string `json:"version"`
}

// HandleHello handles GET /api/v1/hello
func HandleHello(e *core.RequestEvent) error {
    logger := logger.GetLogger(e.App)
    logger.Info("Hello endpoint called")

    response := HelloResponse{
        Message:   "Hello from IMS PocketBase BaaS Starter!",
        Timestamp: time.Now().Format(time.RFC3339),
        Version:   "1.0.0",
    }

    return e.JSON(200, response)
}

// HandleHelloWithName handles GET /api/v1/hello/{name}
func HandleHelloWithName(e *core.RequestEvent) error {
    name := e.Request.PathValue("name")

    if name == "" {
        return e.JSON(400, map[string]string{
            "error": "Name parameter is required",
        })
    }

    response := map[string]interface{}{
        "message": fmt.Sprintf("Hello, %s!", name),
        "name":    name,
        "timestamp": time.Now().Format(time.RFC3339),
    }

    return e.JSON(200, response)
}

Step 2: Register the Route

Add your route to internal/routes/routes.go following the consistent pattern:

// internal/routes/routes.go
package routes

import (
    "ims-pocketbase-baas-starter/internal/handlers/route"
    "ims-pocketbase-baas-starter/internal/middlewares"
    "ims-pocketbase-baas-starter/pkg/logger"

    "github.com/pocketbase/pocketbase/core"
)

// Route represents a custom application route with its configuration
type Route struct {
    Method      string                           // HTTP method (GET, POST, PUT, DELETE, etc.)
    Path        string                           // Route path
    Handler     func(*core.RequestEvent) error   // Handler function to execute when route is called
    Middlewares []func(*core.RequestEvent) error // Middlewares to apply to this route
    Enabled     bool                             // Whether the route should be registered
    Description string                           // Human-readable description of what the route does
}

// RegisterCustom registers all custom routes with the PocketBase application
func RegisterCustom(e *core.ServeEvent) {
    authMiddleware := middlewares.NewAuthMiddleware()
    logger := logger.GetLogger(e.App)

    g := e.Router.Group("/api/v1")

    // Define all custom routes in a consistent array structure
    routes := []Route{
        {
            Method:      "GET",
            Path:        "/hello",
            Handler:     route.HandleHello,
            Middlewares: []func(*core.RequestEvent) error{},
            Enabled:     true,
            Description: "Public hello world route",
        },
        {
            Method:  "GET",
            Path:    "/hello/{name}",
            Handler: route.HandleHelloWithName,
            Middlewares: []func(*core.RequestEvent) error{
                authMiddleware.RequireAuthFunc(), // Apply authentication middleware
            },
            Enabled:     true,
            Description: "Personalized hello route (auth required)",
        },
    }

    // Register enabled routes
    for _, route := range routes {
        if !route.Enabled {
            continue
        }

        // Create the final handler with middlewares applied
        finalHandler := route.Handler
        for i := len(route.Middlewares) - 1; i >= 0; i-- {
            middleware := route.Middlewares[i]
            nextHandler := finalHandler
            finalHandler = func(e *core.RequestEvent) error {
                if err := middleware(e); err != nil {
                    return err
                }
                return nextHandler(e)
            }
        }

        // Register the route with the appropriate HTTP method
        switch route.Method {
        case "GET":
            g.GET(route.Path, finalHandler)
        case "POST":
            g.POST(route.Path, finalHandler)
        case "PUT":
            g.PUT(route.Path, finalHandler)
        case "DELETE":
            g.DELETE(route.Path, finalHandler)
        case "PATCH":
            g.PATCH(route.Path, finalHandler)
        }
    }

    logger.Info("Custom routes registered successfully")
}

Step 3: Register Routes in App

Ensure your routes are registered in internal/app/app.go:

// internal/app/app.go
func NewApp() *pocketbase.PocketBase {
    app := pocketbase.New()

    // ... other initialization code ...

    app.OnServe().BindFunc(func(se *core.ServeEvent) error {
        // Register custom routes
        routes.RegisterCustom(se)

        return se.Next()
    })

    return app
}

Advanced Route Patterns

1. Routes with Authentication

Create protected routes that require authentication:

// internal/handlers/route/protected_handler.go
func HandleProtectedEndpoint(e *core.RequestEvent) error {
    // Get the authenticated user
    authRecord := e.Auth
    if authRecord == nil {
        return e.JSON(401, map[string]string{
            "error": "Authentication required",
        })
    }

    response := map[string]interface{}{
        "message": "This is a protected endpoint",
        "user_id": authRecord.Id,
        "user_email": authRecord.Email(),
    }

    return e.JSON(200, response)
}

Register with authentication middleware:

// In routes.go
func RegisterCustomRoutes(e *core.ServeEvent) error {
    // Protected routes group
    e.Router.GET("/api/v1/protected/profile", route.HandleProtectedEndpoint,
        middlewares.RequireAuth())

    return nil
}

2. Routes with Request Validation

Handle POST requests with validation:

// internal/handlers/route/user_handler.go
type CreateUserRequest struct {
    Name  string `json:"name" validate:"required,min=2,max=50"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"min=18,max=120"`
}

type CreateUserResponse struct {
    ID      string `json:"id"`
    Message string `json:"message"`
}

func HandleCreateUser(e *core.RequestEvent) error {
    var req CreateUserRequest

    // Parse JSON body
    if err := e.BindBody(&req); err != nil {
        return e.JSON(400, map[string]string{
            "error": "Invalid JSON format",
        })
    }

    // Validate request
    if err := validateRequest(req); err != nil {
        return e.JSON(400, map[string]string{
            "error": err.Error(),
        })
    }

    // Create user logic here
    userID, err := createUser(e.App, req)
    if err != nil {
        return e.JSON(500, map[string]string{
            "error": "Failed to create user",
        })
    }

    response := CreateUserResponse{
        ID:      userID,
        Message: "User created successfully",
    }

    return e.JSON(201, response)
}

func validateRequest(req CreateUserRequest) error {
    if req.Name == "" {
        return errors.New("name is required")
    }
    if req.Email == "" {
        return errors.New("email is required")
    }
    if req.Age < 18 {
        return errors.New("age must be at least 18")
    }
    return nil
}

3. File Upload Routes

Handle file uploads:

// internal/handlers/route/upload_handler.go
func HandleFileUpload(e *core.RequestEvent) error {
    // Parse multipart form
    err := e.Request.ParseMultipartForm(10 << 20) // 10 MB max
    if err != nil {
        return e.JSON(400, map[string]string{
            "error": "Failed to parse multipart form",
        })
    }

    file, header, err := e.Request.FormFile("file")
    if err != nil {
        return e.JSON(400, map[string]string{
            "error": "No file provided",
        })
    }
    defer file.Close()

    // Validate file type
    if !isValidFileType(header.Header.Get("Content-Type")) {
        return e.JSON(400, map[string]string{
            "error": "Invalid file type",
        })
    }

    // Save file logic here
    fileID, err := saveFile(e.App, file, header)
    if err != nil {
        return e.JSON(500, map[string]string{
            "error": "Failed to save file",
        })
    }

    return e.JSON(200, map[string]interface{}{
        "file_id": fileID,
        "filename": header.Filename,
        "size": header.Size,
    })
}

Integration with API Documentation

Step 1: Register Custom Routes in API Docs

Add your custom routes to the API Docs generator:

// internal/app/app.go (in the OnServe handler)
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
    // Initialize API docs generator
    generator := apidoc.InitializeGenerator(se.App)

    // Register custom routes for documentation
    registerApiDocRoutes(generator)

    return se.Next()
})

func registerApiDocRoutes(generator *apidoc.Generator) {
    // Hello endpoint
    generator.AddCustomRoute(apidoc.CustomRoute{
        Method:      "GET",
        Path:        "/api/v1/hello",
        Summary:     "Hello World",
        Description: "Returns a simple greeting message",
        Tags:        []string{"General"},
        Protected:   false,
    })

    // Hello with name endpoint
    generator.AddCustomRoute(apidoc.CustomRoute{
        Method:      "GET",
        Path:        "/api/v1/hello/{name}",
        Summary:     "Personalized Hello",
        Description: "Returns a personalized greeting message",
        Tags:        []string{"General"},
        Protected:   false,
        Parameters: []apidoc.Parameter{
            {
                Name:        "name",
                In:          "path",
                Required:    true,
                Description: "Name for personalized greeting",
                Schema: map[string]interface{}{
                    "type": "string",
                },
            },
        },
    })

    // Protected endpoint
    generator.AddCustomRoute(apidoc.CustomRoute{
        Method:      "GET",
        Path:        "/api/v1/protected/profile",
        Summary:     "Get User Profile",
        Description: "Returns authenticated user's profile information",
        Tags:        []string{"User", "Protected"},
        Protected:   true, // Adds authentication requirement
    })

    // Create user endpoint
    generator.AddCustomRoute(apidoc.CustomRoute{
        Method:      "POST",
        Path:        "/api/v1/users",
        Summary:     "Create User",
        Description: "Creates a new user with validation",
        Tags:        []string{"User"},
        Protected:   false,
        RequestBody: &apidoc.RequestBody{
            Required:    true,
            Description: "User creation data",
            Content: map[string]apidoc.MediaType{
                "application/json": {
                    Schema: map[string]interface{}{
                        "type": "object",
                        "required": []string{"name", "email", "age"},
                        "properties": map[string]interface{}{
                            "name": map[string]interface{}{
                                "type":      "string",
                                "minLength": 2,
                                "maxLength": 50,
                            },
                            "email": map[string]interface{}{
                                "type":   "string",
                                "format": "email",
                            },
                            "age": map[string]interface{}{
                                "type":    "integer",
                                "minimum": 18,
                                "maximum": 120,
                            },
                        },
                    },
                },
            },
        },
    })

    // File upload endpoint
    generator.AddCustomRoute(apidoc.CustomRoute{
        Method:      "POST",
        Path:        "/api/v1/upload",
        Summary:     "Upload File",
        Description: "Uploads a file to the server",
        Tags:        []string{"Files"},
        Protected:   true,
        RequestBody: &apidoc.RequestBody{
            Required:    true,
            Description: "File to upload",
            Content: map[string]apidoc.MediaType{
                "multipart/form-data": {
                    Schema: map[string]interface{}{
                        "type": "object",
                        "properties": map[string]interface{}{
                            "file": map[string]interface{}{
                                "type":   "string",
                                "format": "binary",
                            },
                        },
                    },
                },
            },
        },
    })
}

Step 2: Access Your Documentation

After registering your routes, they will appear in:

  • API Docs: http://localhost:8090/api-docs
  • Scalar: http://localhost:8090/api-docs/scalar
  • ReDoc: http://localhost:8090/api-docs/redoc
  • OpenAPI JSON: http://localhost:8090/api-docs/openapi.json

Route Organization Best Practices

1. Group Related Routes

// internal/handlers/route/user_routes.go
func RegisterUserRoutes(router *echo.Group) {
    userGroup := router.Group("/users")

    userGroup.GET("", route.HandleListUsers)
    userGroup.POST("", route.HandleCreateUser)
    userGroup.GET("/:id", route.HandleGetUser)
    userGroup.PUT("/:id", route.HandleUpdateUser)
    userGroup.DELETE("/:id", route.HandleDeleteUser)
}

// internal/handlers/route/admin_routes.go
func RegisterAdminRoutes(router *echo.Group) {
    adminGroup := router.Group("/admin", middlewares.RequireAdmin())

    adminGroup.GET("/stats", route.HandleAdminStats)
    adminGroup.GET("/users", route.HandleAdminListUsers)
    adminGroup.POST("/maintenance", route.HandleMaintenanceMode)
}

2. Use Consistent Response Formats

// pkg/common/response.go
type APIResponse struct {
    Success bool        `json:"success"`
    Data    interface{} `json:"data,omitempty"`
    Error   string      `json:"error,omitempty"`
    Meta    *Meta       `json:"meta,omitempty"`
}

type Meta struct {
    Page       int `json:"page,omitempty"`
    PerPage    int `json:"per_page,omitempty"`
    TotalPages int `json:"total_pages,omitempty"`
    Total      int `json:"total,omitempty"`
}

func SuccessResponse(data interface{}) APIResponse {
    return APIResponse{
        Success: true,
        Data:    data,
    }
}

func ErrorResponse(message string) APIResponse {
    return APIResponse{
        Success: false,
        Error:   message,
    }
}

3. Implement Proper Error Handling

// internal/handlers/route/error_handler.go
func HandleWithErrorRecovery(handler func(*core.RequestEvent) error) func(*core.RequestEvent) error {
    return func(e *core.RequestEvent) error {
        defer func() {
            if r := recover(); r != nil {
                logger := logger.GetLogger(e.App)
                logger.Error("Route handler panic", "error", r)

                e.JSON(500, ErrorResponse("Internal server error"))
            }
        }()

        return handler(e)
    }
}

// Usage
e.Router.GET("/api/v1/risky-endpoint", HandleWithErrorRecovery(route.HandleRiskyOperation))

Testing Custom Routes

Unit Testing Route Handlers

// internal/handlers/route/hello_handler_test.go
func TestHandleHello(t *testing.T) {
    // Create test app
    app := pocketbase.NewWithConfig(pocketbase.Config{
        DefaultDebug: false,
    })

    // Create test request
    req := httptest.NewRequest("GET", "/api/v1/hello", nil)
    rec := httptest.NewRecorder()

    // Create request event
    e := &core.RequestEvent{
        App:      app,
        Request:  req,
        Response: rec,
    }

    // Call handler
    err := route.HandleHello(e)

    // Assertions
    assert.NoError(t, err)
    assert.Equal(t, 200, rec.Code)

    var response HelloResponse
    err = json.Unmarshal(rec.Body.Bytes(), &response)
    assert.NoError(t, err)
    assert.Equal(t, "Hello from IMS PocketBase BaaS Starter!", response.Message)
}

Integration Testing

// tests/integration/routes_test.go
func TestCustomRoutesIntegration(t *testing.T) {
    // Start test server
    app := setupTestApp()
    server := httptest.NewServer(app.Router())
    defer server.Close()

    // Test hello endpoint
    resp, err := http.Get(server.URL + "/api/v1/hello")
    assert.NoError(t, err)
    assert.Equal(t, 200, resp.StatusCode)

    // Test protected endpoint without auth
    resp, err = http.Get(server.URL + "/api/v1/protected/profile")
    assert.NoError(t, err)
    assert.Equal(t, 401, resp.StatusCode)
}

Performance Considerations

1. Use Caching for Expensive Operations

func HandleExpensiveStats(e *core.RequestEvent) error {
    cacheService := cache.GetInstance()
    cacheKey := "expensive_stats"

    // Try cache first
    if cachedData, found := cacheService.Get(cacheKey); found {
        return e.JSON(200, cachedData)
    }

    // Compute expensive stats
    stats, err := computeExpensiveStats(e.App)
    if err != nil {
        return e.JSON(500, ErrorResponse("Failed to compute stats"))
    }

    // Cache for 5 minutes
    cacheService.Set(cacheKey, stats, 5*time.Minute)

    return e.JSON(200, SuccessResponse(stats))
}

2. Implement Request Timeouts

func HandleLongRunningOperation(e *core.RequestEvent) error {
    ctx, cancel := context.WithTimeout(e.Request.Context(), 30*time.Second)
    defer cancel()

    result, err := performLongOperation(ctx)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            return e.JSON(408, ErrorResponse("Request timeout"))
        }
        return e.JSON(500, ErrorResponse("Operation failed"))
    }

    return e.JSON(200, SuccessResponse(result))
}

Security Best Practices

1. Input Validation

func validateAndSanitizeInput(input string) (string, error) {
    // Remove dangerous characters
    sanitized := strings.TrimSpace(input)

    // Validate length
    if len(sanitized) == 0 {
        return "", errors.New("input cannot be empty")
    }

    if len(sanitized) > 1000 {
        return "", errors.New("input too long")
    }

    return sanitized, nil
}

2. Rate Limiting

func rateLimitMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        // Implement rate limiting logic
        clientIP := c.RealIP()

        // Check rate limit using cache
        cacheService := cache.GetInstance()
        key := fmt.Sprintf("rate_limit:%s", clientIP)

        if _, found := cacheService.Get(key); found {
            return c.JSON(429, ErrorResponse("Rate limit exceeded"))
        }

        // Set rate limit
        cacheService.Set(key, true, 1*time.Minute)

        return next(c)
    }
}