DEV Community

Cover image for Developing a Simple RESTful API with Gin, ginvalidator, and validatorgo
Gbubemi Attah
Gbubemi Attah

Posted on

Developing a Simple RESTful API with Gin, ginvalidator, and validatorgo

This tutorial introduces the basics of writing a RESTful web service API with Go and the Gin Web Framework (Gin). Which uses my two open source libraries: ginvalidator and validatorgo.

  • ginvalidator is a lightweight and expressive validation library for Go, designed specifically for the Gin web framework. It is based on the popular open source js library express-validator.
  • validatorgo is a versatile validation library for Go, offering a wide range of validators and sanitizers. It is based on the popular open source js library validator.js.

Please check out the above libraries and don’t forget to ⭐ star, 🔁 share, and 🍴 fork!

API endpoint design

You’ll build an API that provides access to a product inventory management system. So you’ll need to provide endpoints through which a HTTP client can create, read update and delete products.

When developing an API, you typically begin by designing the endpoints. Your API’s users will have more success if the endpoints are easy to understand.

Here are the endpoints you’ll create in this tutorial.

  • /products
    • GET - Get a list of all products, returned as JSON.
    • POST - Add a new product from request data and returned as JSON.
  • /products/:id
    • GET - Get a product by its id, returning the product data as JSON.
    • UPDATE - Get a product by its id, returning the newly updated data as JSON.
    • Delete - Deletes a product by its id.

To keep things simple for the tutorial, you’ll store data in memory. A more typical API would interact with a database. Note that storing data in memory means that the list of products will be lost each time you stop the server, then recreated when you start it.

Also all HTTP requests will be represented as .http code snippets. You can use any HTTP client, such as Postman, Insomnia, or the HTTP client in your IDE, to send your requests.

Writing the Code

  • Start by following the quick installation guide to set up the necessary dependencies.

  • Next, create a main.go file.

  • In the main.go file, define the following structs to model your product data: Dimensions, Supplier, and Product. The Dimensions and Supplier structs will be embedded within the Product struct.

  package main

  import (
    "time"
  )

  // Dimensions represents the physical dimensions of a product.
  type Dimensions struct {
    Length float64 `json:"length"` // Length of the product in centimeters
    Width  float64 `json:"width"`  // Width of the product in centimeters
    Height float64 `json:"height"` // Height of the product in centimeters
    Weight float64 `json:"weight"` // Weight of the product in kilograms
  }

  // Supplier represents information about the supplier of a product.
  type Supplier struct {
    Name    string `json:"name"`    // Name of the supplier
    Contact string `json:"contact"` // Contact details (e.g., email or phone number)
    Address string `json:"address"` // Address of the supplier
  }

  // Product represents data about an inventory product.
  type Product struct {
    ID             string     `json:"id"`             // Unique identifier for the product
    Name           string     `json:"name"`           // Name of the product
    Category       string     `json:"category"`       // Category the product belongs to
    Description    string     `json:"description"`    // Detailed description of the product
    Price          float64    `json:"price"`          // Price of the product in decimal format
    Stock          int        `json:"stock"`          // Quantity of the product available in stock
    Dimensions     Dimensions `json:"dimensions"`     // Physical dimensions of the product
    Supplier       Supplier   `json:"supplier"`       // Supplier details
    Tags           []string   `json:"tags"`           // Tags for searching and filtering
    Image          string     `json:"image"`          // URLs of product images
    ManufacturedAt time.Time  `json:"manufacturedAt"` // Timestamp when the product was manufactured
    CreatedAt      time.Time  `json:"createdAt"`      // Timestamp when the product was created
    UpdatedAt      time.Time  `json:"updatedAt"`      // Timestamp when the product was last updated
  }
Enter fullscreen mode Exit fullscreen mode
  • Then below add a slice products, with 2 items. This slice will be where our in memory database.
  var products = []Product{
    {
        ID:          "p1",
        Name:        "Wireless Mouse",
        Category:    "Electronics",
        Description: "A high-precision wireless mouse with ergonomic design.",
        Price:       29.99,
        Stock:       150,
        Dimensions: Dimensions{
            Length: 11.5,
            Width:  6.0,
            Height: 3.5,
            Weight: 0.2,
        },
        Supplier: Supplier{
            Name:    "Tech Supplies Inc.",
            Contact: "support@techsupplies.com",
            Address: "123 Tech Street, Silicon Valley, CA",
        },
        Tags:           []string{"wireless", "mouse", "electronics", "accessories"},
        Image:          "https://example.com/images/mouse1.jpg",
        ManufacturedAt: time.Now().AddDate(0, -30, 0),
        CreatedAt:      time.Now().AddDate(0, -1, 0),
        UpdatedAt:      time.Now().AddDate(0, -1, 0),
    },
    {
        ID:          "p2",
        Name:        "Gaming Keyboard",
        Category:    "Electronics",
        Description: "Mechanical keyboard with customizable RGB lighting.",
        Price:       79.99,
        Stock:       75,
        Dimensions: Dimensions{
            Length: 45.0,
            Width:  15.0,
            Height: 3.8,
            Weight: 1.1,
        },
        Supplier: Supplier{
            Name:    "Gaming Gear Ltd.",
            Contact: "info@gaminggear.com",
            Address: "456 Gaming Road, Austin, TX",
        },
        Tags:           []string{"keyboard", "gaming", "RGB", "electronics"},
        Image:          "https://example.com/images/keyboard2.jpg",
        ManufacturedAt: time.Now().AddDate(0, -20, 0),
        CreatedAt:      time.Now().AddDate(0, -2, 0),
        UpdatedAt:      time.Now().AddDate(0, -2, 0),
    },
  }
Enter fullscreen mode Exit fullscreen mode
  • Lets register a GET endpoint for /products in the main function. Here is the updated main function:
    func main() {
      router := gin.Default()
      router.GET("/products",
        append(productQueriesValidators(), getProducts)...,
      )
      router.Run(":8080")
    }
Enter fullscreen mode Exit fullscreen mode

Below the main function, define the productQueriesValidators and getProducts functions:

  • productQueriesValidators: This function returns a gin.HandlersChain (equivalent to []gin.HandlerFunc). It includes two query validators for the GET /products route:
    • q: An optional query parameter. It will be validated only if present in the request, ensuring it's trimmed of leading and trailing whitespace and is not empty.
    • order: An optional query parameter. It will be trimmed of whitespace and must be one of asc or desc.
  func productQueriesValidators() gin.HandlersChain {
    return gin.HandlersChain{
      gv.NewQuery("q", nil).Chain().Optional().Trim(" ").Not().Empty(nil).Validate(),
      gv.NewQuery("order", nil).Chain().Optional().Trim(" ").In([]string{"asc", "desc"}).Validate(),
    }
  }
Enter fullscreen mode Exit fullscreen mode
  • getProducts: This is the final request handler middleware for the GET /products route. It performs the following steps:

    • Checks for validation errors using gv.ValidationResult.
    • If validation errors exist, it returns an HTTP 422 Unprocessable Entity status with the errors.
    • If no validation errors occur, it returns the product data with an HTTP 200 OK status.
  func getProducts(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    if len(result) != 0 {
      c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
        "message": "The server encountered validation error",
        "data":    nil,
        "errors":  result,
      })
      return
    }

    c.AbortWithStatusJSON(http.StatusOK, gin.H{
      "message": "The products found successfully",
      "data":    products,
      "errors":  nil,
    })
  }
Enter fullscreen mode Exit fullscreen mode

Note we don't actually use the the search params (q, order) in the getProducts handler for the sake of simplicity.

Update the dependencies at the top of your Go file to include both net/http, gin and ginvalidator (aliased as gv):

  package main

  import (
    "fmt"
    "net/http"
    "time"

      gv "github.com/bube054/ginvalidator"
    "github.com/gin-gonic/gin"
  )
Enter fullscreen mode Exit fullscreen mode
  • Run and test the code for the GET /products endpoint.
  go run main.go
Enter fullscreen mode Exit fullscreen mode

Here are examples of valid and invalid requests for testing:

  • A valid request.

    GET http://localhost:8080/products?q=electronics&order=asc HTTP/1.1
    content-type: application/json
    
  • An Invalid Request: order is neither asc nor desc.

    GET http://localhost:8080/products?q=&order=xyz HTTP/1.1
    content-type: application/json
    
  • An Invalid Request: q is empty

    GET http://localhost:8080/products?q=   &order=asc HTTP/1.1
    content-type: application/json
    
  • An Invalid Request: q is empty and order is neither asc nor desc

    GET http://localhost:8080/products?q=   &order=xyz HTTP/1.1
    content-type: application/json
    
    • Lets register a GET endpoint for /products/:id in the main function. Here is the updated main function:
    func main() {
      router := gin.Default()
      router.GET("/products",
        append(productQueriesValidators(), getProducts)...,
      )
      router.GET("/products/:id",
        append(productParamIdValidators(), getProduct)...,
      )
      router.Run(":8080")
    }
Enter fullscreen mode Exit fullscreen mode

Below the main function, define the productParamIdValidators and getProduct functions:

  • productParamIdValidators: It includes a param validator for id in /products/:id route:
    • id is simply the id for each product.
  func productParamIdValidators() gin.HandlersChain {
    return gin.HandlersChain{gv.NewParam("id", nil).Chain().Trim(" ").Alphanumeric(nil).Validate()}
  }
Enter fullscreen mode Exit fullscreen mode
  • getProducts: This is the final request handler middleware for the /products route. It performs the following steps

    • Checks for validation errors using gv.ValidationResult.
    • If validation errors exist, it returns an HTTP 422 Unprocessable Entity status with the errors.
    • Retrieves matched data using gv.GetMatchedData.
    • Searches for the product by its id in the products list.
    • Return HTTP 404 if the product is not found and return HTTP 200 if the product is successfully found.
  func getProduct(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    if len(result) != 0 {
      c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
        "message": "The server encountered validation error",
        "data":    nil,
        "errors":  result,
      })
      return
    }

    data, err := gv.GetMatchedData(c)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    id, _ := data.Get(gv.ParamLocation, "id")

    var product *Product

    for _, p := range products {
      if p.ID == id {
        product = &p
        break
      }
    }

    if product == nil {
      c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
        "message": fmt.Sprintf("Product with id %s, not found", id),
        "data":    nil,
        "errors":  nil,
      })
      return
    } else {
      c.AbortWithStatusJSON(http.StatusOK, gin.H{
        "message": "The product found successfully",
        "data":    product,
        "errors":  nil,
      })
      return
    }
  }
Enter fullscreen mode Exit fullscreen mode
  • Run and test the code for GET on /products/:id

Quit the previous process and rerun:

  go run main.go
Enter fullscreen mode Exit fullscreen mode

A valid request.

  GET http://localhost:8080/products/p2 HTTP/1.1
  content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Invalid request: id which is p2 is not alphanumeric

  GET http://localhost:8080/products/@#! HTTP/1.1
  content-type: application/json
Enter fullscreen mode Exit fullscreen mode
  • Lets register a DELETE endpoint for /products/:id in the main function. Here is the updated main function:
    func main() {
      router := gin.Default()
      router.GET("/products",
        append(productQueriesValidators(), getProducts)...,
      )
      router.GET("/products/:id",
        append(productParamIdValidators(), getProduct)...,
      )
      router.DELETE("/products/:id",
        append(productParamIdValidators(), deleteProduct)...,
      )
      router.Run(":8080")
    }
Enter fullscreen mode Exit fullscreen mode

productParamIdValidators function will be reused.

  • deleteProduct: This is the final request handler middleware for the DELETE /products/:id route. It performs the following steps:
    • Validates the request parameters using gv.ValidationResult.
    • Returns an HTTP 422 status if validation errors are present.
    • Retrieves matched data using gv.GetMatchedData.
    • Searches for the product by its id in the products list.
    • If the product exists, its removed from the list and returns HTTP 200 with the deleted product details.
    • If the product does not exist, returns HTTP 404.
  func deleteProduct(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    if len(result) != 0 {
      c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
        "message": "The server encountered validation error",
        "data":    nil,
        "errors":  result,
      })
      return
    }

    data, err := gv.GetMatchedData(c)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": "The server encountered an unexpected error.",
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    id, _ := data.Get(gv.ParamLocation, "id")

    var deletedProduct *Product
    var filteredProducts = []Product{}

    for _, prod := range products {
      if prod.ID == id {
        deletedProduct = &prod
        break
      } else {
        filteredProducts = append(filteredProducts, prod)
      }
    }

    if deletedProduct == nil {
      c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
        "message": fmt.Sprintf("Product with id %s, not found", id),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    products = filteredProducts
    c.AbortWithStatusJSON(http.StatusOK, gin.H{
      "message": fmt.Sprintf("Product with id %s, deleted", id),
      "data":    deletedProduct,
      "errors":  nil,
    })
  }
Enter fullscreen mode Exit fullscreen mode
  • Run and test the code for DELETE /products/:id

Quit the previous process and rerun:

  go run main.go
Enter fullscreen mode Exit fullscreen mode

A valid request.

  DELETE http://localhost:8080/products/p2 HTTP/1.1
  content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Invalid request: id which is p2 is not alphanumeric

  DELETE http://localhost:8080/products/@#! HTTP/1.1
  content-type: application/json
Enter fullscreen mode Exit fullscreen mode
  • Lets register a POST endpoint for /products in the main function. Here is the updated main function:
    func main() {
      router := gin.Default()
      router.GET("/products",
        append(productQueriesValidators(), getProducts)...,
      )
      router.GET("/products/:id",
        append(productParamIdValidators(), getProduct)...,
      )
      router.DELETE("/products/:id",
        append(productParamIdValidators(), deleteProduct)...,
      )
      router.POST("/products",
        append(productBodyValidators(), postProduct)...,
      )
      router.Run(":8080")
    }
Enter fullscreen mode Exit fullscreen mode
  • productBodyValidators: This function validates the request body for POST and PUT requests to the /products route. It validates:

    • name: Must not be empty.
    • category: Must belong to predefined categories (Electronics, Apparels, Groceries, Home-Appliances).
    • description: Length should be between 5 and 100 characters.
    • price: Must have at least 2 decimal places and use the en-US locale.
    • stock: Should be an integer, with a minimum value of 0.
    • dimensions:
      • length, width, height, weight: Must be numeric.
    • supplier:
      • name: Trimmed and not empty.
      • contact: Must be a valid email address.
      • address: Must match the regex format (number street, city, state abbreviation).
    • tags: Should be an array of strings.
    • image: Must be a valid URL.
    • manufacturedAt: Should follow the ISO8601 format and be after 2020-05-10T00:00:00Z.
  func productBodyValidators() gin.HandlersChain {
    var (
      minDesc         uint     = 5
      maxDesc         uint     = 100
      minStock        int      = 0
      validCategories []string = []string{"Electronics", "Apparels", "Groceries", "Home-Appliances"}
    )

    return gin.HandlersChain{
      gv.NewBody("name", nil).Chain().Not().Empty(nil).Validate(),
      gv.NewBody("category", nil).Chain().In(validCategories).Validate(),
      gv.NewBody("description", nil).Chain().Length(&vgo.IsLengthOpts{Min: uint(minDesc), Max: &maxDesc}).Validate(),
      gv.NewBody("price", nil).Chain().Decimal(&vgo.IsDecimalOpts{DecimalDigits: vgo.DecimalDigits{Min: 2}, ForceDecimal: false, Locale: "en-US"}).Validate(),
      gv.NewBody("stock", nil).Chain().Int(&vgo.IsIntOpts{Min: &minStock}).Validate(),
      gv.NewBody(`dimensions.length`, nil).Chain().Numeric(nil).Validate(),
      gv.NewBody("dimensions.width", nil).Chain().Numeric(nil).Validate(),
      gv.NewBody("dimensions.height", nil).Chain().Numeric(nil).Validate(),
      gv.NewBody("dimensions.weight", nil).Chain().Numeric(nil).Validate(),
      gv.NewBody("supplier.name", nil).Chain().Trim(" ").Not().Empty(nil).Validate(),
      gv.NewBody("supplier.contact", nil).Chain().Email(nil).Validate(),
      gv.NewBody("supplier.address", nil).Chain().Matches(regexp.MustCompile(`^\d+\s[\w\s]+,\s[\w\s]+,\s[A-Z]{2}$`)).Validate(),
      gv.NewBody("tags", nil).Chain().Array(nil).Validate(),
      gv.NewBody("image", nil).Chain().URL(nil).Validate(),
      gv.NewBody("manufacturedAt", nil).Chain().Date(&vgo.IsDateOpts{Format: vgo.ISO8601ZuluLayout, StrictMode: true}).After(&vgo.IsAfterOpts{ComparisonDate: "2020-05-10T00:00:00Z"}).Validate(),
    }
  }
Enter fullscreen mode Exit fullscreen mode
  • postProduct: This is the final request handler middleware for the POST /products route. It performs the following steps:
    • Validates the request body using gv.ValidationResult.
    • Returns an HTTP 422 status if validation errors are present.
    • Binds the JSON request body to a Product struct.
    • Adds a unique ID, CreatedAt, and UpdatedAt fields to the new product.
    • Appends the new product to the products slice.
    • Returns HTTP 200 with the created product details.
  func postProduct(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    if len(result) != 0 {
      c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
        "message": "The server encountered validation error",
        "data":    nil,
        "errors":  result,
      })
      return
    }

    var newProduct Product

    if err := c.BindJSON(&newProduct); err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    newProduct.ID = fmt.Sprintf("p%d", len(products)+1)
    newProduct.CreatedAt = time.Now()
    newProduct.UpdatedAt = newProduct.CreatedAt

    products = append(products, newProduct)
    c.AbortWithStatusJSON(http.StatusOK, gin.H{
      "message": fmt.Sprintf("Product with id %s, has been created", newProduct.ID),
      "data":    newProduct,
      "errors":  nil,
    })
  }
Enter fullscreen mode Exit fullscreen mode

Update the dependencies at the top of your Go file to include validatorgo (aliased as vgo):

  package main

  import (
    "fmt"
    "net/http"
      "regexp"
    "time"

      gv "github.com/bube054/ginvalidator"
      vgo "github.com/bube054/validatorgo"
    "github.com/gin-gonic/gin"
  )
Enter fullscreen mode Exit fullscreen mode
  • Run and test the code for POST /products

Quit the previous process and rerun:

  go run main.go
Enter fullscreen mode Exit fullscreen mode

A valid request.

  POST http://localhost:8080/products HTTP/1.1
  content-type: application/json

  {
    "name": "Ultra HD Monitor",
    "category": "Electronics",
    "description": "27-inch 4K Ultra HD monitor with HDR support and ultra-slim bezels.",
    "price": 345.99,
    "stock": 30,
    "dimensions": {
      "length": 61.0,
      "width": 36.0,
      "height": 5.0,
      "weight": 4.5
    },
    "supplier": {
      "name": "VisionTech Co.",
      "contact": "sales@visiontech.com",
      "address": "123 Display Lane, San Francisco, CA"
    },
    "tags": ["monitor", "4K", "HDR", "electronics", "display"],
    "image": "https://example.com/images/monitor1.jpg",
    "manufacturedAt": "2023-09-11T11:34:56Z"
  }
Enter fullscreen mode Exit fullscreen mode

An invalid request.

  POST http://localhost:8080/products HTTP/1.1
  content-type: application/json

  {
    "name": "",
    "category": "Equipment",
    "description": "four",
    "price": 345.99,
    "stock": 30,
    "dimensions": {},
    "supplier": {
      "name": "",
      "contact": "123 Display Lane, San Francisco, CA",
      "address": "sales@visiontech.com",
    },
    "tags": null,
    "image": "image-here",
    "manufacturedAt": "2019-09-11T11:34:56Z"
  }
Enter fullscreen mode Exit fullscreen mode
  • Lets register a PUT endpoint for /products/:id in the main function. Here is the updated main function:
    func main() {
      router := gin.Default()
      router.GET("/products",
        append(productQueriesValidators(), getProducts)...,
      )
      router.GET("/products/:id",
        append(productParamIdValidators(), getProduct)...,
      )
      router.DELETE("/products/:id",
        append(productParamIdValidators(), deleteProduct)...,
      )
      router.POST("/products",
        append(productBodyValidators(), postProduct)...,
      )
      router.PUT("/products/:id",
        append(append(productParamIdValidators(), productBodyValidators()...), putProducts)...,
      )
      router.Run(":8080")
    }
Enter fullscreen mode Exit fullscreen mode

We will re-use productParamIdValidators and productBodyValidators functions.

  • putProduct: This is the final request handler middleware for the POST /products route. It performs the following steps:
    • Validates the request parameters and body: Uses gv.ValidationResult to check for validation errors in both the route parameters and the request body.
    • Retrieves matched data using gv.GetMatchedData.
    • Finds the product: Matches the provided id with the existing products.
    • Updates the product: If the product is found, it updates its details and sets a new UpdatedAt timestamp.
    • It returns HTTP 200 with the updated product details if successful.
    • It returns HTTP 404 if the product is not found.
    • It returns HTTP 422 for validation errors.
  func putProducts(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    if len(result) != 0 {
      c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
        "message": "The server encountered validation error",
        "data":    nil,
        "errors":  result,
      })
      return
    }

    var newProduct Product

    if err := c.BindJSON(&newProduct); err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    data, err := gv.GetMatchedData(c)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "message": "The server encountered an unexpected error.",
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    id, _ := data.Get(gv.ParamLocation, "id")

    var EditedProduct *Product
    var newProducts = make([]Product, len(products))

    for ind, prod := range products {
      if prod.ID == id {
        newProduct.ID = id
        newProduct.CreatedAt = prod.CreatedAt
        newProduct.UpdatedAt = time.Now()
        newProducts[ind] = newProduct
        EditedProduct = &newProduct
      } else {
        newProducts[ind] = prod
      }
    }

    if EditedProduct == nil {
      c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
        "message": fmt.Sprintf("Product with id %s, not found", id),
        "data":    nil,
        "errors":  nil,
      })
      return
    }

    products = newProducts
    c.AbortWithStatusJSON(http.StatusOK, gin.H{
      "message": fmt.Sprintf("Product with id %s, has been edited", id),
      "data":    EditedProduct,
      "errors":  nil,
    })
  }
Enter fullscreen mode Exit fullscreen mode

Run and test the code for PUT /products

Quit the previous process and rerun:

  go run main.go
Enter fullscreen mode Exit fullscreen mode

A valid request.

  PUT http://localhost:8080/products HTTP/1.1
  content-type: application/json

  {
    "name": "Ultra 8k Monitor",
    "category": "Electronics",
    "description": "27-inch 8K Ultra HD monitor with HDR support and ultra-slim bezels.",
    "price": 390.99,
    "stock": 15,
    "dimensions": {
      "length": 16.0,
      "width": 63.0,
      "height": 0.5,
      "weight": 4.5
    },
    "supplier": {
      "name": "VisionTech Co.",
      "contact": "sales@visiontech.com",
      "address": "123 Display Lane, San Francisco, CA"
    },
    "tags": ["8K", "HDR", "electronics", "display"],
    "image": "https://example.com/images/monitor1-edited.jpg",
    "manufacturedAt": "2023-09-11T11:34:56Z"
  }
Enter fullscreen mode Exit fullscreen mode

An invalid request.

  PUT http://localhost:8080/products HTTP/1.1
  content-type: application/json

  {
    "name": "",
    "category": "Equipment",
    "description": "four",
    "price": 345.99,
    "stock": 30,
    "dimensions": null,
    "supplier": {
      "name": "",
      "contact": "123 Display Lane, San Francisco, CA",
      "address": "sales@visiontech.com",
    },
    "tags": null,
    "image": "image-here",
    "manufacturedAt": "2019-09-11T11:34:56Z"
  }
Enter fullscreen mode Exit fullscreen mode

Final thoughts

Now you can see how straightforward it is to validate your HTTP requests in Gin using both ginvalidator and validatorgo.

For more details on customizing error messages, check out this guide. To learn more about field syntax extraction, refer to this section.

If you prefer not to use the append method in the route registration handlers, you can break down the middleware validator handlers into smaller chunks and add them individually to the registration handler.

Thanks for reading, and see you next time!

Full Code

package main

import (
    "fmt"
    "net/http"
    "regexp"
    "time"

    gv "github.com/bube054/ginvalidator"
    vgo "github.com/bube054/validatorgo"
    "github.com/gin-gonic/gin"
)

// Dimensions represents the physical dimensions of a product.
type Dimensions struct {
    Length float64 `json:"length"` // Length of the product in centimeters
    Width  float64 `json:"width"`  // Width of the product in centimeters
    Height float64 `json:"height"` // Height of the product in centimeters
    Weight float64 `json:"weight"` // Weight of the product in kilograms
}

// Supplier represents information about the supplier of a product.
type Supplier struct {
    Name    string `json:"name"`    // Name of the supplier
    Contact string `json:"contact"` // Contact details (e.g., email or phone number)
    Address string `json:"address"` // Address of the supplier
}

// Product represents data about an inventory product.
type Product struct {
    ID             string     `json:"id"`             // Unique identifier for the product
    Name           string     `json:"name"`           // Name of the product
    Category       string     `json:"category"`       // Category the product belongs to
    Description    string     `json:"description"`    // Detailed description of the product
    Price          float64    `json:"price"`          // Price of the product in decimal format
    Stock          int        `json:"stock"`          // Quantity of the product available in stock
    Dimensions     Dimensions `json:"dimensions"`     // Physical dimensions of the product
    Supplier       Supplier   `json:"supplier"`       // Supplier details
    Tags           []string   `json:"tags"`           // Tags for searching and filtering
    Image          string     `json:"image"`          // URLs of product images
    ManufacturedAt time.Time  `json:"manufacturedAt"` // Timestamp when the product was manufactured
    CreatedAt      time.Time  `json:"createdAt"`      // Timestamp when the product was created
    UpdatedAt      time.Time  `json:"updatedAt"`      // Timestamp when the product was last updated
}

var products = []Product{
    {
        ID:          "p1",
        Name:        "Wireless Mouse",
        Category:    "Electronics",
        Description: "A high-precision wireless mouse with ergonomic design.",
        Price:       29.99,
        Stock:       150,
        Dimensions: Dimensions{
            Length: 11.5,
            Width:  6.0,
            Height: 3.5,
            Weight: 0.2,
        },
        Supplier: Supplier{
            Name:    "Tech Supplies Inc.",
            Contact: "support@techsupplies.com",
            Address: "123 Tech Street, Silicon Valley, CA",
        },
        Tags:           []string{"wireless", "mouse", "electronics", "accessories"},
        Image:          "https://example.com/images/mouse1.jpg",
        ManufacturedAt: time.Now().AddDate(0, -30, 0),
        CreatedAt:      time.Now().AddDate(0, -1, 0),
        UpdatedAt:      time.Now().AddDate(0, -1, 0),
    },
    {
        ID:          "p2",
        Name:        "Gaming Keyboard",
        Category:    "Electronics",
        Description: "Mechanical keyboard with customizable RGB lighting.",
        Price:       79.99,
        Stock:       75,
        Dimensions: Dimensions{
            Length: 45.0,
            Width:  15.0,
            Height: 3.8,
            Weight: 1.1,
        },
        Supplier: Supplier{
            Name:    "Gaming Gear Ltd.",
            Contact: "info@gaminggear.com",
            Address: "456 Gaming Road, Austin, TX",
        },
        Tags:           []string{"keyboard", "gaming", "RGB", "electronics"},
        Image:          "https://example.com/images/keyboard2.jpg",
        ManufacturedAt: time.Now().AddDate(0, -20, 0),
        CreatedAt:      time.Now().AddDate(0, -2, 0),
        UpdatedAt:      time.Now().AddDate(0, -2, 0),
    },
}

func main() {
    router := gin.Default()
    router.GET("/products",
        append(productQueriesValidators(), getProducts)...,
    )
    router.GET("/products/:id",
        append(productParamIdValidators(), getProduct)...,
    )
    router.DELETE("/products/:id",
        append(productParamIdValidators(), deleteProduct)...,
    )
    router.POST("/products",
        append(productBodyValidators(), postProduct)...,
    )
    router.PUT("/products/:id",
        append(append(productParamIdValidators(), productBodyValidators()...), putProducts)...,
    )
    router.Run(":8080")
}

func productQueriesValidators() gin.HandlersChain {
    return gin.HandlersChain{
        gv.NewQuery("q", nil).Chain().Optional().Trim(" ").Not().Empty(nil).Validate(),
        gv.NewQuery("order", nil).Chain().Optional().Trim(" ").In([]string{"asc", "desc"}).Validate(),
    }
}

func productParamIdValidators() gin.HandlersChain {
    return gin.HandlersChain{gv.NewParam("id", nil).Chain().Trim(" ").Alphanumeric(nil).Validate()}
}

func productBodyValidators() gin.HandlersChain {
    var (
        minDesc         uint     = 5
        maxDesc         uint     = 100
        minStock        int      = 0
        validCategories []string = []string{"Electronics", "Apparels", "Groceries", "Home-Appliances"}
    )

    return gin.HandlersChain{
        gv.NewBody("name", nil).Chain().Not().Empty(nil).Validate(),
        gv.NewBody("category", nil).Chain().In(validCategories).Validate(),
        gv.NewBody("description", nil).Chain().Length(&vgo.IsLengthOpts{Min: uint(minDesc), Max: &maxDesc}).Validate(),
        gv.NewBody("price", nil).Chain().Decimal(&vgo.IsDecimalOpts{DecimalDigits: vgo.DecimalDigits{Min: 2}, ForceDecimal: false, Locale: "en-US"}).Validate(),
        gv.NewBody("stock", nil).Chain().Int(&vgo.IsIntOpts{Min: &minStock}).Validate(),
        gv.NewBody(`dimensions.length`, nil).Chain().Numeric(nil).Validate(),
        gv.NewBody("dimensions.width", nil).Chain().Numeric(nil).Validate(),
        gv.NewBody("dimensions.height", nil).Chain().Numeric(nil).Validate(),
        gv.NewBody("dimensions.weight", nil).Chain().Numeric(nil).Validate(),
        gv.NewBody("supplier.name", nil).Chain().Trim(" ").Not().Empty(nil).Validate(),
        gv.NewBody("supplier.contact", nil).Chain().Email(nil).Validate(),
        gv.NewBody("supplier.address", nil).Chain().Matches(regexp.MustCompile(`^\d+\s[\w\s]+,\s[\w\s]+,\s[A-Z]{2}$`)).Validate(),
        gv.NewBody("tags", nil).Chain().Array(nil).Validate(),
        gv.NewBody("image", nil).Chain().URL(nil).Validate(),
        gv.NewBody("manufacturedAt", nil).Chain().Date(&vgo.IsDateOpts{Format: vgo.ISO8601ZuluLayout, StrictMode: true}).After(&vgo.IsAfterOpts{ComparisonDate: "2020-05-10T00:00:00Z"}).Validate(),
    }
}

func getProducts(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    if len(result) != 0 {
        c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
            "message": "The server encountered validation error",
            "data":    nil,
            "errors":  result,
        })
        return
    }

    c.AbortWithStatusJSON(http.StatusOK, gin.H{
        "message": "The products found successfully",
        "data":    products,
        "errors":  nil,
    })
}

func getProduct(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    if len(result) != 0 {
        c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
            "message": "The server encountered validation error",
            "data":    nil,
            "errors":  result,
        })
        return
    }

    data, err := gv.GetMatchedData(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    id, _ := data.Get(gv.ParamLocation, "id")

    var product *Product

    for _, p := range products {
        if p.ID == id {
            product = &p
            break
        }
    }

    if product == nil {
        c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
            "message": fmt.Sprintf("Product with id %s, not found", id),
            "data":    nil,
            "errors":  nil,
        })
        return
    } else {
        c.AbortWithStatusJSON(http.StatusOK, gin.H{
            "message": "The product found successfully",
            "data":    product,
            "errors":  nil,
        })
        return
    }
}

func deleteProduct(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    if len(result) != 0 {
        c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
            "message": "The server encountered validation error",
            "data":    nil,
            "errors":  result,
        })
        return
    }

    data, err := gv.GetMatchedData(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": "The server encountered an unexpected error.",
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    id, _ := data.Get(gv.ParamLocation, "id")

    var deletedProduct *Product
    var filteredProducts = []Product{}

    for _, prod := range products {
        if prod.ID == id {
            deletedProduct = &prod
            break
        } else {
            filteredProducts = append(filteredProducts, prod)
        }
    }

    if deletedProduct == nil {
        c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
            "message": fmt.Sprintf("Product with id %s, not found", id),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    products = filteredProducts
    c.AbortWithStatusJSON(http.StatusOK, gin.H{
        "message": fmt.Sprintf("Product with id %s, deleted", id),
        "data":    deletedProduct,
        "errors":  nil,
    })
}

func postProduct(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    if len(result) != 0 {
        c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
            "message": "The server encountered validation error",
            "data":    nil,
            "errors":  result,
        })
        return
    }

    var newProduct Product

    if err := c.BindJSON(&newProduct); err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    newProduct.ID = fmt.Sprintf("p%d", len(products)+1)
    newProduct.CreatedAt = time.Now()
    newProduct.UpdatedAt = newProduct.CreatedAt

    products = append(products, newProduct)
    c.AbortWithStatusJSON(http.StatusOK, gin.H{
        "message": fmt.Sprintf("Product with id %s, has been created", newProduct.ID),
        "data":    newProduct,
        "errors":  nil,
    })
}

func putProducts(c *gin.Context) {
    result, err := gv.ValidationResult(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    if len(result) != 0 {
        c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
            "message": "The server encountered validation error",
            "data":    nil,
            "errors":  result,
        })
        return
    }

    var newProduct Product

    if err := c.BindJSON(&newProduct); err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": fmt.Sprintf("The server encountered an unexpected error: %v", err),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    data, err := gv.GetMatchedData(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "message": "The server encountered an unexpected error.",
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    id, _ := data.Get(gv.ParamLocation, "id")

    var EditedProduct *Product
    var newProducts = make([]Product, len(products))

    for ind, prod := range products {
        if prod.ID == id {
            newProduct.ID = id
            newProduct.CreatedAt = prod.CreatedAt
            newProduct.UpdatedAt = time.Now()
            newProducts[ind] = newProduct
            EditedProduct = &newProduct
        } else {
            newProducts[ind] = prod
        }
    }

    if EditedProduct == nil {
        c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
            "message": fmt.Sprintf("Product with id %s, not found", id),
            "data":    nil,
            "errors":  nil,
        })
        return
    }

    products = newProducts
    c.AbortWithStatusJSON(http.StatusOK, gin.H{
        "message": fmt.Sprintf("Product with id %s, has been edited", id),
        "data":    EditedProduct,
        "errors":  nil,
    })
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)