Goのozzo-validationでバリデーション処理を実装してみた
はじめに
Web アプリケーション開発で API を開発する際、リクエストパラメータのバリデーション処理を実装することが多いかと思います。
今回は、Go のozzo-validation
を使用し、バリデーション処理を作ってみたサンプルコードや感想をまとめたいと思います。
なお、サンプル作成に使用する Go のバージョンやフレームワークは次の通りです。
- Go Version: 1.21.0
- Web フレームワーク: Gin
なお、今回の記事について、コードから見たい方はこちらからどうぞ
目標
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
のバリデーションが動いてます。
そこで、gin
のbinding.Validator
を独自実装した構造体で上書きすれば、バインド時に動作するバリデーションを変更することができるようです。
binding.Validator
に登録するためには、インターフェースStructValidator
を満たしている必要があり、これを満たす独自 Validator の構造体を作成します。
※インターフェースはgithub.com/gin-gonic/gin/blob/master/binding/binding.go #StructValidator
参照
先ほど作成した、ozzo_validation.go
のValidator
をラップする形で、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.go
でozzoValidator
をbinding.Validator
に指定すれば、独自実装したozzo-validation
によるバリデーションが動くようになります。
main.go
func main() {
r := gin.Default()
// set validator
binding.Validator = validation.NewOzzoValidator()
r.POST("/books", handler.NewBookHandler().Create)
r.Run()
}
このようにgin
の Validator
にセットしておくことで、バインド時(例えば、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 歴が浅いので、ご指摘等あればいただけますと幸いです。
読んでいただきありがとうございました!
参考
Discussion