🐷

Goのozzo-validationでバリデーション処理を実装してみた

2023/09/06に公開

はじめに

Web アプリケーション開発で API を開発する際、リクエストパラメータのバリデーション処理を実装することが多いかと思います。
今回は、Go のozzo-validationを使用し、バリデーション処理を作ってみたサンプルコードや感想をまとめたいと思います。

なお、サンプル作成に使用する Go のバージョンやフレームワークは次の通りです。

  • Go Version: 1.21.0
  • Web フレームワーク: Gin

https://github.com/go-ozzo/ozzo-validation

なお、今回の記事について、コードから見たい方はこちらからどうぞ
https://github.com/yuuLab/go-validation

目標

POST リクエストの JSON ボディに対して各パラメータのバリデーション処理を行い、以下の仕様通りにエラーを返却できることをゴールにします。

  • バリデーションエラーの場合、400 Bad Requestが返却されること
  • バリデーションエラーの場合、パラメータ名とその理由が返却されること
  • 複数のエラーがある場合、エラーとなったパラメータがすべて返却されること

エラーレスポンスのボディはRFC 7807を参考に、以下の仕様で返却することにします。
typeはエラーごとに一意となるエラー定数、titleはエラー概要、invalid-paramsはエラーとなったパラメータおよび理由の配列です。

バリデーションエラー例
{
  "type": "VALIDATION_ERROR",
  "title": "Your request parameters didn't validate.",
  "invalid-params": [
    {
      "name": "age",
      "reason": "must be a positive integer"
    },
    {
      "name": "color",
      "reason": "must be 'green', 'red' or 'blue'"
    }
  ]
}

RFC 7807まわりについては、rry さんのぼくのかんがえたさいきょうの API エラーレスポンスのフォーマットが参考になりました!

使い方・解説

バリデーション内容の定義

ozzo-validationの基本的な使い方は、struct にValidate()メソッドを定義し、バリデーション内容をコードで書く形になります。
タグではなく、コードで記載できるので、タグが煩雑にならずに見やすいままに保てます。

book_request.go (struct コード例)
import validation "github.com/go-ozzo/ozzo-validation"

type Book struct {
	Title  string  `json:"title"`
	Author string  `json:"author"`
	Price  float64 `json:"price"`
	Genre  string  `json:"genre"`
}

func (b Book) Validate() error {
	//NOTE: 日本語のエラー文が不要で、デフォルトの英語のエラー文で必要十分である場合、`.Error("xxx")`は不要でOK
	return validation.ValidateStruct(&b,
		validation.Field(
			&b.Title,
			validation.Required.Error("タイトルは必須項目です。"),
			validation.RuneLength(1, 50).Error("タイトルは 1文字 以上 50文字 以内です。"),
		),
		validation.Field(
			&b.Author,
			validation.Required.Error("著者名は必須項目です。"),
			validation.RuneLength(1, 50).Error("著者名は 1文字 以上 50文字 以内です。"),
		),
		validation.Field(
			&b.Price,
			validation.Required.Error("価格は必須項目です。"),
			validation.Max(1000000.0).Error("価格は 1,000,000円 以下で指定してください。"),
			validation.Min(1.0).Error("価格は 1円 以上で指定してください。"),
		),
		validation.Field(
			&b.Genre,
			validation.In("NOVELS", "COMIC", "FICTION", "NON_FICTION").Error("値が正しくありません。"),
		),
	)
}

上記のようにパラメータごとにチェック内容を記述し、エラーメッセージを日本語に変えたい場合はError()の引数に渡すことで変更可能です。

バリデーションチェック

上記で作成した struct に対して、バリデーションチェックを行う実装例は次の通りです。
Validate()メソッドをもつ struct は、インターフェースvalidation.Validatableを満たすので、それを確認後、Validate()メソッドを実行してバリデーションチェックを行います。

ozzo_validation.go (バリデーションチェック実装)
// Validator performs validation on parameters.
type Validator struct{}

// Validate performs validation on any given interface.
// If the provided interface is valid or doesn't implement the validation.Validatable interface, it returns nil.
func (v Validator) Validate(obj any) error {
	if obj == nil {
		return nil
	}

	val, ok := obj.(validation.Validatable)
	if !ok {
		return nil
	}
	if err := val.Validate(); err != nil {
		if verr, ok := err.(validation.Errors); ok {
			var params []response.InvalidParams
			for key, val := range verr {
				params = append(params, response.InvalidParams{Name: key, Reason: val.Error()})
			}
			return v.newValidationError(params, err)
		}
		return v.newServerError(err)
	}
	return nil
}

// newValidationError creates an error that wraps a Internal Error.
func (v Validator) newServerError(err error) error {
	return ValidationError{
		response: response.ValidationError{Type: "SERVER_ERROR", Title: "unexpected errors"},
		status:   http.StatusInternalServerError,
		err:      err,
	}
}

// newValidationError creates an error that wraps a Validation Error.
func (v Validator) newValidationError(params []response.InvalidParams, err error) error {
	return ValidationError{
		response: response.ValidationError{Type: "VALIDATION_ERROR", Title: "Your request parameters didn't validate.", Pramas: params},
		status:   http.StatusBadRequest,
		err:      err,
	}
}

// validation/error.go
// ValidationError represents validation error.
type ValidationError struct {
	response response.ValidationError
	status   int
	err      error
}

// Error returns the error string of Errors.
func (v ValidationError) Error() string {
	return v.err.Error()
}

// Response returns the Response.
func (v ValidationError) Response() response.ValidationError {
	return v.response
}

// Status returns the Status Code.
func (v ValidationError) Status() int {
	return v.status
}

バリデーションエラーの場合、レスポンスに変換する処理は共通化しておきたかったので、今回はその処理もValidate()メソッド内で行っています。
validation.Errorsの実態は map であり、Keyがエラーとなったフィールド名、Valueがエラー理由として、発生したエラー情報が保持されています。
それをループで回して、バリデーションエラーのレスポンスを構築しています。
エラーレスポンスの構造体は次の通り。

error_response.go
type InvalidParams struct {
	Name   string `json:"name"`
	Reason string `json:"reason"`
}

type ValidationError struct {
	Type   string          `json:"type"`
	Title  string          `json:"title"`
	Pramas []InvalidParams `json:"invalid-params"`
}

バリデーションチェックの基本的な実装は以上で終了ですが、このバリデーションチェック処理をginの仕組みにのせて、バインディング時に実行されるようにさらに少し改修します。

gin へ組み込み

上述した通り、ginではデフォルトでgo-playgroundのバリデーションが動いてます。
そこで、ginbinding.Validatorを独自実装した構造体で上書きすれば、バインド時に動作するバリデーションを変更することができるようです。
binding.Validatorに登録するためには、インターフェースStructValidatorを満たしている必要があり、これを満たす独自 Validator の構造体を作成します。

※インターフェースはgithub.com/gin-gonic/gin/blob/master/binding/binding.go #StructValidator参照

先ほど作成した、ozzo_validation.goValidatorをラップする形で、StructValidatorを満たすように実装を追加します。

ozzo_validation.go (StructValidator の実装)
// +++追加部分+++
// ozzoValidator implements the binding.StructValidator.
type ozzoValidator struct {
	validator *Validator
}

func NewOzzoValidator() binding.StructValidator {
	return &ozzoValidator{validator: &Validator{}}
}

func (v *ozzoValidator) ValidateStruct(obj any) error {
	return v.validator.Validate(obj)
}

func (v *ozzoValidator) Engine() any {
	return v.validator
}
// +++追加部分+++

あとはmain.goozzoValidatorbinding.Validatorに指定すれば、独自実装したozzo-validationによるバリデーションが動くようになります。

main.go
func main() {
	r := gin.Default()
	// set validator
	binding.Validator = validation.NewOzzoValidator()
	r.POST("/books", handler.NewBookHandler().Create)
	r.Run()
}

このようにginValidator にセットしておくことで、バインド時(例えば、ShouldBindJSON()でのバインド時など)に実装したバリデーション処理を呼び出してくれます。

あとは、各ハンドラーでいつも通りリクエストの変換処理を行い、ValidationErrorだった場合は、生成されたエラーレスポンスを返すようにエラーハンドリングします。
以下は実装例です。

book_handler.go
type bookHandler struct {
}

// NewBookHandler creates a new BookHandler.
func NewBookHandler() *bookHandler {
	return &bookHandler{}
}

// Create creates a new book from request information.
func (h *bookHandler) Create(c *gin.Context) {
	var req request.Book
	if err := c.ShouldBindJSON(&req); err != nil {
		slog.Info(err.Error())
		if verr, ok := err.(validation.ValidationError); ok {
			c.JSON(verr.Status(), verr.Response())
			return
		}
		// json decord errors, etc.
		c.Status(http.StatusBadRequest)
		return
	}

	//TODO: create a new book...

	c.Status(http.StatusCreated)
}

ozzo-validation の良さそうな点

  • タグが煩雑にならない

  • タグを修正しないので OpenAPI などの自動生成系と相性良さそう

  • Go のコードで表現できるので、書き間違いなどのエラーにすぐに気がつける

  • README が個人的に見やすくて、すぐに使い始められる

  • 条件付き必須の表現が直感的かつ柔軟
    公式の README にも記載ある通り、以下のように直感的かつozzo-validationを初めてみる人でもすぐに意味がわかる。

    result := validation.ValidateStruct(&a,
        validation.Field(&a.Unit, validation.When(a.Quantity != "", validation.Required).Else(validation.Nil)),
        validation.Field(&a.Phone, validation.When(a.Email == "", validation.Required.Error('Either phone or Email is required.')),
        validation.Field(&a.Email, validation.When(a.Phone == "", validation.Required.Error('Either phone or Email is required.')),
    )
    
  • カスタムバリデーションをすぐに作れる
    こちらも公式にあるが、検証したい内容をチェックしてerrorを返す関数を作り、validation.By()に渡すだけで機能してくれます。

    func checkAbc(value interface{}) error {
      s, _ := value.(string)
      if s != "abc" {
        return errors.New("must be abc")
      }
      return nil
    }
    
    err := validation.Validate("xyz", validation.By(checkAbc))
    fmt.Println(err)
    // Output: must be abc
    

さいごに

ozzo-validationは直感的かつ柔軟性もあり、非常に使いやすそうに感じました。
私自身ずっと Java エンジニアで、まだまだ Go 歴が浅いので、ご指摘等あればいただけますと幸いです。

読んでいただきありがとうございました!

参考

https://github.com/go-ozzo/ozzo-validation
https://zenn.dev/mattn/articles/893f28eff96129
https://gin-gonic.com/ja/
https://zenn.dev/ryamakuchi/articles/d7c932afc57e30
https://datatracker.ietf.org/doc/html/rfc7807

GitHubで編集を提案

Discussion