Golang + Gin カスタムバリデーション

Golang+GinによるAPI構築で使いそうなフィーチャーを試してみるシリーズ。
今回はカスタムバリデーションを試してみる。

ルーティング

バリデーションを外部に移譲することで、ハンドラからロジック以外の冗長な処理を除くことができる。
Ginはカスタムバリデータを用意している。以下の例では、ユーザ登録を行うPOSTリクエストの例。
組み込みのバリデーション・バインディングと合わせて、パスワードバリデーションロジックの
追加を行っている。


package main

import (
	"github.com/gin-gonic/gin"
	"github.com/gin-gonic/gin/binding"
	"github.com/go-playground/validator/v10"
	"github.com/ikuty/golang-gin/handlers"
)

func main() {
	// Ginエンジンの初期化
	r := gin.Default()

	// カスタムバリデーターを登録
	if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
		handlers.InitCustomValidators(v)
	}

	// 7. カスタムバリデーション
	r.POST("/api/register", handlers.RegisterValidatorHandler)

	// サーバー起動
	r.Run(":8080")
}


ハンドラ

リクエストで受けたJSONをRegisterRequest構造体にバインディングする際に、組み込みの
バリデーションルールを定義するのとは別に、strongpassword というカスタムルールを定義している。

strongpasswordルールの実体は strongPassword() 。
例に出現するオブジェクトの使い方は、まぁこう使うのかぐらいで、ありがちな感じ。
カスタムバリデータ関数がチェック結果をTrue/Falseで返せばよさそう。

組み込みバリデータ、または、カスタムバリデータのバリデーション結果と文字列の対応を定義し、
その文字列をレスポンスに付与して返す、というのは良くあるパターンで、
Ginで実装する場合は、

また、カスタムバリデータのバリデーション結果と文字列の対応を定義しレスポンスに含める、
というパターンは良くありそうで、構造体へのバインディングで発生したエラー(err)を取得し、
errに対する型アサーションを行った上で、errを validator.ValidationErrors型として扱う。
動的型付けだと、発生したerrが本当に期待したオブジェクトなのか実行するまで分からなが、
全ての処理が静的型付けを通して、実行前に実行可能であることが確認される。


package handlers

import (
	"net/http"
	"regexp"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
)

// RegisterRequest はユーザー登録リクエストの構造体(高度なバリデーション付き)
type RegisterRequest struct {
	Username string `json:"username" binding:"required,min=3,max=20,alphanum"`
	Email    string `json:"email" binding:"required,email"`
	Password string `json:"password" binding:"required,min=8,max=50,strongpassword"`
	Age      int    `json:"age" binding:"required,gte=18,lte=100"`
	Website  string `json:"website" binding:"omitempty,url"`
	Phone    string `json:"phone" binding:"omitempty,e164"` // E.164 形式の電話番号
}

// カスタムバリデーター: 強力なパスワードチェック
func strongPassword(fl validator.FieldLevel) bool {
	password := fl.Field().String()

	// 最低1つの大文字、1つの小文字、1つの数字を含む
	hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
	hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
	hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)

	return hasUpper && hasLower && hasNumber
}

// RegisterValidatorHandler はカスタムバリデーターを使用するハンドラー
func RegisterValidatorHandler(c *gin.Context) {
	var req RegisterRequest

	// JSON をバインド
	if err := c.ShouldBindJSON(&req); err != nil {
		// バリデーションエラーを詳細に返す
		c.JSON(http.StatusBadRequest, gin.H{
			"error":   "Validation failed",
			"details": formatValidationError(err),
		})
		return
	}

	c.JSON(http.StatusCreated, gin.H{
		"message":  "Registration successful",
		"username": req.Username,
		"email":    req.Email,
	})
}

// formatValidationError はバリデーションエラーをわかりやすく整形
func formatValidationError(err error) []string {
	var errors []string

	if validationErrors, ok := err.(validator.ValidationErrors); ok {
		for _, e := range validationErrors {
			var message string
			switch e.Tag() {
			case "required":
				message = e.Field() + " is required"
			case "email":
				message = e.Field() + " must be a valid email address"
			case "min":
				message = e.Field() + " must be at least " + e.Param() + " characters"
			case "max":
				message = e.Field() + " must be at most " + e.Param() + " characters"
			case "alphanum":
				message = e.Field() + " must contain only letters and numbers"
			case "gte":
				message = e.Field() + " must be greater than or equal to " + e.Param()
			case "lte":
				message = e.Field() + " must be less than or equal to " + e.Param()
			case "url":
				message = e.Field() + " must be a valid URL"
			case "e164":
				message = e.Field() + " must be a valid phone number (E.164 format)"
			case "strongpassword":
				message = e.Field() + " must contain at least one uppercase letter, one lowercase letter, and one number"
			default:
				message = e.Field() + " is invalid"
			}
			errors = append(errors, message)
		}
	} else {
		errors = append(errors, err.Error())
	}

	return errors
}

// InitCustomValidators はカスタムバリデーターを登録する
func InitCustomValidators(v *validator.Validate) {
	v.RegisterValidation("strongpassword", strongPassword)
}


実行結果

リクエストに対してバリデーションが行われ、期待通りバリデーションエラーがアサートされていて、
アサートと対応するカスタム文字列がレスポンスに含まれていることが確認できる。


  $ curl -X POST http://localhost:8080/api/register \
    -H "Content-Type: application/json" \
    -d '{"username":"john123","email":"john@example.com","password":"SecurePass123","age":25,"website":"https://example.com"}'
  {"email":"john@example.com","message":"Registration successful","username":"john123"}

  2. ユーザー名が短すぎる

  {"details":["Username must be at least 3 characters"],"error":"Validation failed"}

  3. 弱いパスワード(カスタムバリデーター)

  {"details":["Password must contain at least one uppercase letter, one lowercase letter, and one number"],"error":"Validation failed"}

  4. 年齢が18歳未満

  {"details":["Age must be greater than or equal to 18"],"error":"Validation failed"}

まとめ

組み込みバリデーションの他に、カスタムバリデーションを追加できることを確認した。
静的型付けにより実行時エラーに頼ることのないある種の堅牢さがあることも見てとれた。