A Beginner's Guide to Simplifying Go API Validation

A Beginner's Guide to Simplifying Go API Validation

When building large web applications, one of the common tasks developers face is validating request body parameters and query parameters in APIs. Writing validation code for every single API can quickly become tedious and error-prone. In this tutorial, we’ll explore how to make this process easier and more efficient using Go.

By the end of this guide, you'll know how to validate request body and query parameters in Go without repetitive coding. Let’s dive in!

Step 1: Setting Up the Project

First, let's get our project set up:

git clone https://github.com/suhaasya/simple-go-server.git

After cloning, create a .env file in your root directory:

PORT=8080  # or your preferred port

Step 2: Creating the Basic Server

Let's start with our main server file. Create cmd/api/main.go:

package main

import (
    "fmt"
    "net/http"
    "simple-go-server/internals/routes"
    "simple-go-server/pkg/constant"

    "github.com/go-chi/chi/middleware"
    "github.com/go-chi/chi/v5"
)

func main() {
    constant.LoadEnv()

    r := chi.NewRouter()
    // Basic middleware stack
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Mount("/", routes.Router())

    fmt.Println("Started the Go server on port " + constant.PORT)
    http.ListenAndServe(":"+constant.PORT, r)
}

💡 This sets up a basic Chi router with some helpful middleware.

Step 3: Setting Up Routes

Create internals/routes/router.go and internals/routes/user_routes.go:

package routes

import (
    "net/http"

    "github.com/go-chi/chi"
)

func Router() http.Handler {
    r := chi.NewRouter()
    r.Get("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("hi"))
    })
    r.Mount("/users", UserRoutes())
    return r
}
package routes

import (
    "net/http"
    "simple-go-server/internals/controllers"

    "github.com/go-chi/chi"
)

func UserRoutes() http.Handler {
    r := chi.NewRouter()
    r.Post("/", controllers.CreateUser)
    r.Patch("/{id}", controllers.EditUser)
    r.Get("/", controllers.GetUsers)
    return r
}

💡 We've defined three routes that we'll implement one by one.

Step 4: Creating Your First Validation - POST Request

4.1: Define the Validation Structure

Create pkg/structs/user.go:

package structs

type AddUser struct {
    Name     string `json:"name" validate:"required"`
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required"`
    Role     string `json:"role" validate:"required,oneof=admin user"`
}

4.2: Create the Validation Helper

Create pkg/helper/helper.go:

package helper

import (
    "encoding/json"
    "fmt"
    "net/http"
    "simple-go-server/pkg/constant"

    "github.com/go-playground/validator"
)

func ValidateBody(r *http.Request, reqData interface{}) error {
    // Decode the JSON request body into the provided struct
    if err := json.NewDecoder(r.Body).Decode(reqData); err != nil {
        return err
    }

    if fmt.Sprintf("%v", reqData) == "&{  }" {
        return constant.ErrBodyCannotBeEmpty
    }
    // Validate the decoded data using the validator
    validate := validator.New()
    if err := validate.Struct(reqData); err != nil {
        return err
    }

    // Return nil if both decoding and validation succeed
    return nil
}

4.3: Implement the Create User Controller

Update internals/controllers/user_controllers.go:

package controllers

import (
    "fmt"
    "net/http"
    "simple-go-server/pkg/helper"
    "simple-go-server/pkg/structs"
)

func CreateUser(w http.ResponseWriter, r *http.Request) {
    reqData := new(structs.AddUser)
    err := helper.ValidateBody(r, reqData)

    if err != nil {
        w.Write([]byte(err.Error()))
        return
    }

    fmt.Println(reqData.Email)
    fmt.Println(reqData.Name)
    fmt.Println(reqData.Password)
    fmt.Println(reqData.Role)

    w.Write([]byte("User created"))
}

4.4: Testing Create User

Let's test our first endpoint:

  1. Try with empty body:
{}

You should get validation errors.

  1. Try with valid data:
{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "secret123",
    "role": "user"
}

You should see the data printed in your console!

Step 5: Adding PATCH Request Validation

5.1: Add Edit User Structure

Add to pkg/structs/user.go:

type EditUser struct {
    Name     string `json:"name" validate:"omitempty"`
    Email    string `json:"email" validate:"omitempty,email"`
    Role     string `json:"role" validate:"omitempty,oneof=admin user"`
}

5.2: Implement Edit User Controller

Add to internals/controllers/user_controllers.go:

func EditUser(w http.ResponseWriter, r *http.Request) {
    reqData := new(structs.EditUser)
    err := helper.ValidateBody(r, reqData)

    if err != nil {
        w.Write([]byte(err.Error()))
        return
    }

    if reqData.Email != "" {
        fmt.Println(reqData.Email)
    }
    if reqData.Name != "" {
        fmt.Println(reqData.Name)
    }
    if reqData.Role != "" {
        fmt.Println(reqData.Role)
    }

    w.Write([]byte("User edited"))
}

5.3: Testing Edit User

Try these test cases:

  1. Partial update:
{
    "name": "Jane Doe"
}

  1. Invalid email:
{
    "email": "not-an-email"
}

Step 6: Adding Query Parameter Validation

6.1: Add Query Parameters Structure

Add to pkg/structs/user.go:

type QueryParams struct {
    Page    string `json:"page" validate:"omitempty,numeric,min=1"`
    Size    string `json:"size" validate:"omitempty,numeric,min=1"`
    Order   string `json:"order" validate:"omitempty,oneof=asc desc"`
    OrderBy string `json:"orderBy" validate:"omitempty,oneof=id name"`
}

6.2: Add Query Parameter Helpers

Add to pkg/helper/helper.go:

func QueryToJSON(query url.Values) (string, error) {
    // Create a map to store our processed query parameters
    queryMap := make(map[string]interface{})

    // Iterate through all query parameters
    for key, values := range query {
        // If there's only one value, store it directly
        if len(values) == 1 {
            queryMap[key] = values[0]
        } else {
            // If there are multiple values, store them as an array
            queryMap[key] = values
        }
    }

    // Convert map to JSON
    jsonBytes, err := json.Marshal(queryMap)
    if err != nil {
        return "", err
    }

    return string(jsonBytes), nil
}

func ValidateQueryParams(jsonData []byte, reqData interface{}) error {
    // Decode the JSON request body into the provided struct
    err := json.Unmarshal(jsonData, reqData)

    if err != nil {
        return err
    }

    // Validate the decoded data using the validator
    validate := validator.New()
    if err := validate.Struct(reqData); err != nil {
        return err
    }

    // Return nil if both decoding and validation succeed
    return nil
}

6.3: Implement Get Users Controller

Add to internals/controllers/user_controllers.go:

func GetUsers(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query()
    queryJson, _ := helper.QueryToJSON(query)
    queryParams := new(structs.QueryParams)

    err := helper.ValidateQueryParams([]byte(queryJson), queryParams)

    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    if queryParams.Page != "" {
        fmt.Println(queryParams.Page)
    }
    if queryParams.Order != "" {
        fmt.Println(queryParams.Order)
    }
    if queryParams.OrderBy != "" {
        fmt.Println(queryParams.OrderBy)
    }
    if queryParams.Size != "" {
        fmt.Println(queryParams.Size)
    }

    w.Write([]byte("User fetched"))
}

6.4: Testing Query Parameters

Try these URLs:

  1. Valid: /users?page=1&size=10&order=asc&orderBy=name

  2. Invalid: /users?page=a&size=10 (page not num)

  3. Invalid: /users?order=random (invalid order value)

Conclusion

Congratulations! 🎉 You've built a complete validation system for your Go API. We:

  1. Started with basic request body validation

  2. Added support for optional fields in PATCH requests

  3. Finished with query parameter validation

The best part? This system is completely reusable! Just create new structs with your validation rules, and you're good to go.