bube054 / ginvalidator

A Gin middleware for validating requests, similar to the nodejs package github.com/express-validator/express-validator
MIT License
5 stars 1 forks source link
gin gin-gonic go golang middleware modifier sanitization validation

ginvalidator

Overview

ginvalidator is a set of Gin middlewares that wraps the extensive collection of validators and sanitizers offered by my other open source package validatorgo. It also uses the popular open-source package gjson for JSON field syntax, providing efficient querying and extraction of data from JSON objects.

It allows you to combine them in many ways so that you can validate and sanitize your Gin requests, and offers tools to determine if the request is valid or not, which data was matched according to your validators.

It is based on the popular js/express library express-validator

Support

This version of ginvalidator requires that your application is running on Go 1.16+. It's also verified to work with Gin 1.x.x.

Rationale

Why not use?

Installation

Make sure you have Go installed on your machine.

Step 1: Create a New Go Module

  1. Create an empty folder with a name of your choice.
  2. Open a terminal, navigate (cd) into that folder, and initialize a new Go module:
go mod init example.com/learning

Step 2: Install Required Packages

Use go get to install the necessary packages.

  1. Install Gin:
go get -u github.com/gin-gonic/gin
  1. Install ginvalidator:
go get -u github.com/bube054/ginvalidator

Getting Started

One of the best ways to learn something is by example! So let's roll the sleeves up and get some coding happening.

Setup

The first thing that one needs is a Gin server running. Let's implement one that says hi to someone; for this, create a main.go then add the following code:

package main

import (
    "net/http"

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

func main() {
    r := gin.Default()

    r.GET("/hello", func(ctx *gin.Context) {
        person := ctx.Query("person")
        ctx.String(http.StatusOK, "Hello, %s!", person)
    })

    r.Run() // listen and serve on 0.0.0.0:8080
}

Now run this file by executing go run main.go on your terminal.

go run main.go

The HTTP server should be running, and you can open http://localhost:8080/hello?person=John to salute John!

💡 Tip: You can use Air with Go and Gin to implement live reload. These automatically restart the server whenever a file is changed, so you don't have to do this yourself!

Adding a validator

So the server is working, but there are problems with it. Most notably, you don't want to say hello to someone when the person's name is not set. For example, going to http://localhost:8080/hello will print "Hello, ".

That's where ginvalidator comes in handy. It provides validators, sanitizers and modifiers that are used to validate your request. Let's add a validator and a modifier that checks that the person query string cannot be empty, with the validator named Empty and modifier named Not:

package main

import (
    "net/http"

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

func main() {
    r := gin.Default()

    r.GET("/hello", gv.NewQuery("person", nil).
        Chain().
        Not().
        Empty(nil).
        Validate(), func(ctx *gin.Context) {
            person := ctx.Query("person")
            ctx.String(http.StatusOK, "Hello, %s!", person)
        })

    r.Run()
}

📝 Note:
For brevity, gv is used as an alias for ginvalidator in the code examples.

Now, restart your server, and go to http://localhost:8080/hello again. Hmm, it still prints "Hello, !"... why?

Handling validation errors

ginvalidator validation chain does not report validation errors to users automatically. The reason for this is simple: as you add more validators, or for more fields, how do you want to collect the errors? Do you want a list of all errors, only one per field, only one overall...?

So the next obvious step is to change the above code again, this time verifying the validation result with the ValidationResult function:

package main

import (
    "net/http"

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

func main() {
    r := gin.Default()

    r.GET("/hello",
        gv.NewQuery("person", nil).
            Chain().
            Not().
            Empty(nil).
            Validate(),
        func(ctx *gin.Context) {
            result, err := gv.ValidationResult(ctx)
            if err != nil {
                ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                    "message": "The server encountered an unexpected error.",
                })
                return
            }

            if len(result) != 0 {
                ctx.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
                    "errors": result,
                })
                return
            }

            person := ctx.Query("person")
            ctx.String(http.StatusOK, "Hello, %s!", person)
        })

    r.Run()
}

Now, if you access http://localhost:8080/hello again, you’ll see the following JSON content, formatted for clarity:

{
  "errors": [
    {
      "location": "queries",
      "message": "Invalid value",
      "field": "person",
      "value": ""
    }
  ]
}

Now, what this is telling us is that

This is a better scenario, but it can still be improved. Let's continue.

Creating better error messages

All request location validators accept an optional second argument, which is a function used to format the error message. If nil is provided, a default, generic error message will be used, as shown in the example above.

package main

import (
    "net/http"

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

func main() {
    r := gin.Default()

    r.GET("/hello",
        gv.NewQuery("person",
            func(initialValue, sanitizedValue, validatorName string) string {
                return "Please enter your name."
            },
        ).Chain().
            Not().
            Empty(nil).
            Validate(),
        func(ctx *gin.Context) {
            result, err := gv.ValidationResult(ctx)
            if err != nil {
                ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                    "message": "The server encountered an unexpected error.",
                })
                return
            }

            if len(result) != 0 {
                ctx.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
                    "errors": result,
                })
                return
            }

            person := ctx.Query("person")
            ctx.String(http.StatusOK, "Hello, %s!", person)
        })

    r.Run()
}

Now if you access http://localhost:8080/hello again, what you'll see is the following JSON content, with the new error message:

{
  "errors": [
    {
      "location": "queries",
      "message": "Please enter your name.",
      "field": "person",
      "value": ""
    }
  ]
}

Accessing validated/sanitized data

You can use GetMatchedData, which automatically collects all data that ginvalidator has validated and/or sanitized. This data can then be accessed using the Get method of MatchedData:

package main

import (
    "fmt"
    "net/http"

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

func main() {
    r := gin.Default()

    r.GET(
        "/hello",
        gv.NewQuery(
            "person",
            func(initialValue, sanitizedValue, validatorName string) string {
                return "Please enter your name."
            },
        ).Chain().
            Not().
            Empty(nil).
            Validate(),
        func(ctx *gin.Context) {
            result, err := gv.ValidationResult(ctx)
            if err != nil {
                ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                    "message": "The server encountered an unexpected error.",
                })
                return
            }

            if len(result) != 0 {
                ctx.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
                    "errors": result,
                })
                return
            }

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

            person, ok := data.Get(gv.QueryLocation, "person")
            if !ok {
                ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
                    "message": fmt.Sprintf(
                        "The server could not find 'person' in the expected location: %s. Also please ensure you're using the correct location, such as Body, Header, Cookie, Query, or Param.",
                        gv.QueryLocation,
                    ),
                })
                return
            }

            ctx.String(http.StatusOK, "Hello, %s!", person)
        },
    )

    r.Run()
}

open http://localhost:8080/hello?person=John to salute John!

Available Data Locations 🚩

The following are the valid data locations you can use:

Each of these locations includes a String method that returns the location where validated/sanitized data is stored.

Sanitizing inputs

While the user can no longer send empty person names, it can still inject HTML into your page! This is known as the Cross-Site Scripting vulnerability (XSS). Let's see how it works. Go to http://localhost:8080/hello?person=<b>John</b>, and you should see "Hello, John!". While this example is fine, an attacker could change the person query string to a \ Githubissues.

  • Githubissues is a development platform for aggregating issues.