🤖

【Go validator】エラーメッセージのフィールド名をJSONキーに変更+メッセージ内容をカスタマイズ

2024/03/15に公開

はじめに

Goでバリデーションを実装する際によく使われるgo-playground/validatorについての記事です。

デフォルトだと、バリデーションエラーが発生した時のメッセージは以下のような形式です。

"Key: 'PostUserParameter.Name' Error:Field validation for 'Name' failed on the 'required' tag"

バリデーションエラーが発生したフィールドの構造体(PostUserParameter)の名前が表示され、かつJSONのキーではなく構造体フィールド名(Name)が表示されます。
これをそのままクライアントに返したりするとちょっとイケていないので、カスタマイズする方法を紹介します。

最終的なエラーメッセージは、以下のようになります。

"'name' validation failed on the required tag"

環境

Go: 1.21.3
validator: 10.19.0

STEP0: 今回使用するサンプルコードを紹介

今回使用するサンプルコード(初期状態)です。

/userというエンドポイントを作成し、POSTメソッドを受け付けるハンドラを実装しております。
リクエストボディはnameage(どちらも必須)という構造です。

※コード量を減らすため、所々で返却されるエラーは無視するようにしてます

main.go
package main

import (
	"encoding/json"
	"io"
	"net/http"

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

type (
	// PostUserParameter /userへのPOSTリクエストボディ
	PostUserParameter struct {
		Name string `json:"name" validate:"required"`
		Age  int    `json:"age" validate:"required"`
	}

	// ErrorResponse エラー時のレスポンスボディ
	ErrorResponse struct {
		Message string `json:"message"` // ここにバリデーションエラー時のエラーメッセージを入れる
	}
)

func main() {
	// ルーティング定義
	http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost {
			// リクエストボディ
			reqBuf, _ := io.ReadAll(r.Body)
			body := &PostUserParameter{}
			_ = json.Unmarshal(reqBuf, body)

			// バリデーション
			if err := validator.New().Struct(body); err != nil {
				// バリデーションエラーが発生した場合は400レスポンスを返却する
				errorResponse := ErrorResponse{
					Message: err.Error(),
				}
				resBuf, _ := json.MarshalIndent(errorResponse, "", "  ")
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusBadRequest)
				_, _ = w.Write(resBuf)
				return
			}

			w.WriteHeader(http.StatusOK)
			return
		}
	})

	// サーバー起動
	http.ListenAndServe(":8080", nil)
}

こちらをgo runコマンド等を実行しサーバーを起動した後、下記のnameプロパティが欠損しているcurlコマンドを実行すると、Key: 'PostUserParameter.Name' Error:Field validation for 'Name' failed on the 'required' tagというメッセージが返却されます。

curl 'http://localhost:8080/user' \
  -H 'Content-Type: application/json' \
  -w '\nhttp_code: %{http_code}\n' \
  -d '{
    "age": 20
  }'

この状態から、下記の2STEPに分けてエラーメッセージを改良していきます。

STEP1: 表示するフィールド名をJSONキー名に変更する

validatorパッケージに用意されているRegisterTagNameFuncを用いて、表示するフィールド名をJSONのキー名に変更する実装を行います。

main.go
// ...変更無し部分は省略

// validatorインスタンスを作成
validation := validator.New()

// フィールド名にjsonタグの値を設定する
validation.RegisterTagNameFunc(func(field reflect.StructField) string {
    // jsonタグで指定されている最初の要素のみを取得する(omitempty等のカンマ以降の値は切り捨てる)
    jsonKey := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
    if jsonKey != "" && jsonKey != "-" {
        return jsonKey
    }
    // jsonタグが指定されていない要素はデフォルトの構造体フィールド名を設定する
    return field.Name
})


if err := validation.Struct(body); err != nil {
    // ...省略
}

// ...変更無し部分は省略

修正後に、上と同じcurlコマンドを実行すると、Key: 'PostUserParameter.name' Error:Field validation for 'name' failed on the 'required' tagというエラーメッセージが返却されます。
Nameという構造体フィールド名だったものが、nameというJSONキー名になってますね!

ただ、冒頭に構造体自体の名前(PostUserParameter.name)が入ってしまっているので、次のステップでこちらを修正していきます。

参考:
https://pkg.go.dev/github.com/go-playground/validator/v10#Validate.RegisterTagNameFunc

この時点でのコード全文はこちら
main.go
package main

import (
	"encoding/json"
	"io"
	"net/http"
	"reflect"
	"strings"

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

type (
	// PostUserParameter /userへのPOSTリクエストボディ
	PostUserParameter struct {
		Name string `json:"name" validate:"required"`
		Age  int    `json:"age" validate:"required"`
	}

	// ErrorResponse エラー時のレスポンスボディ
	ErrorResponse struct {
		Message string `json:"message"` // ここにバリデーションエラー時のエラーメッセージを入れる
	}
)

func main() {
	// ルーティング定義
	http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost {
			// リクエストボディ
			reqBuf, _ := io.ReadAll(r.Body)
			body := &PostUserParameter{}
			_ = json.Unmarshal(reqBuf, body)

			// validatorインスタンスを作成
			validation := validator.New()

			// フィールド名にjsonタグの値を設定する
			validation.RegisterTagNameFunc(func(field reflect.StructField) string {
				// jsonタグで指定されている最初の要素のみを取得する(omitempty等のカンマ以降の値は切り捨てる)
				jsonKey := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
				if jsonKey != "" && jsonKey != "-" {
					return jsonKey
				}
				// jsonタグが指定されていない要素はデフォルトの構造体フィールド名を設定する
				return field.Name
			})

			if err := validation.Struct(body); err != nil {
				// バリデーションエラーが発生した場合は400レスポンスを返却する
				errorResponse := ErrorResponse{
					Message: err.Error(),
				}
				resBuf, _ := json.MarshalIndent(errorResponse, "", "  ")
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusBadRequest)
				_, _ = w.Write(resBuf)
				return
			}

			w.WriteHeader(http.StatusOK)
			return
		}
	})

	// サーバー起動
	http.ListenAndServe(":8080", nil)
}

STEP2: エラーメッセージをカスタマイズする

このSTEPでは、エラーメッセージKey: 'PostUserParameter.name' Error:Field validation for 'name' failed on the 'required' tagから、構造体名(PostUserParameter)を削除し、良い感じにエラーメッセージをカスタマイズしていきます。

validatorには、バリデーションエラー時に発生する型としてValidationErrorsが用意されているので、型をチェックし、バリデーションエラーの場合はメッセージをカスタマイズします。

詳細はコードコメントを参照ください。

main.go
// ...変更無し部分は省略

if err := validation.Struct(body); err != nil {
    var errorMessage string
    // 発生したエラーがValidationErrorsであるかをチェックする
    if verrs, ok := err.(validator.ValidationErrors); ok {
        var msgs []string
        // ValidationErrorsは、複数フィールド分のエラーが格納されている(FieldErrorのスライス型)であるため、ループしてメッセージを整形する
        for _, ferr := range verrs {
            // Namespaceに'PostUserParameter.name'のような「構造体名.JSONキー」が格納されている
            // 構造体名を取り除くため「.」のインデックスを特定し、トリムしている
            i := strings.Index(ferr.Namespace(), ".")
            ns := ferr.Namespace()[i+1:]
            // msgsというエラーメッセージを格納するスライスに整形した文字を追加する
            // ferr.Tag()は、エラーとなった対象のvalidateタグ(required等)が入っている
            msgs = append(msgs, fmt.Sprintf("'%s' validation failed on the %s tag", ns, ferr.Tag()))
        }
        errorMessage = fmt.Sprint(strings.Join(msgs, "\n"))
    } else {
        errorMessage = err.Error()
    }

// ...変更無し部分は省略

再度、上と同じcurlコマンドを実行すると'name' validation failed on the required tagというような形で、無事にエラーメッセージから構造体フィールド名をJSONのキーに変更し、構造体名を完全に削除することができました。

コード全文はこちら
main.go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"reflect"
	"strings"

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

type (
	// PostUserParameter /userへのPOSTリクエストボディ
	PostUserParameter struct {
		Name string `json:"name" validate:"required"`
		Age  int    `json:"age" validate:"required"`
	}

	// ErrorResponse エラー時のレスポンスボディ
	ErrorResponse struct {
		Message string `json:"message"` // ここにバリデーションエラー時のエラーメッセージを入れる
	}
)

func main() {
	// ルーティング定義
	http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost {
			// リクエストボディ
			reqBuf, _ := io.ReadAll(r.Body)
			body := &PostUserParameter{}
			_ = json.Unmarshal(reqBuf, body)

			// validatorインスタンスを作成
			validation := validator.New()

			// フィールド名にjsonタグの値を設定する
			validation.RegisterTagNameFunc(func(field reflect.StructField) string {
				// jsonタグで指定されている最初の要素のみを取得する(omitempty等のカンマ以降の値は切り捨てる)
				jsonKey := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
				if jsonKey != "" && jsonKey != "-" {
					return jsonKey
				}
				// jsonタグが指定されていない要素はデフォルトの構造体フィールド名を設定する
				return field.Name
			})

			if err := validation.Struct(body); err != nil {
				var errorMessage string
				// 発生したエラーがValidationErrorsであるかをチェックする
				if verrs, ok := err.(validator.ValidationErrors); ok {
					var msgs []string
					// ValidationErrorsは、複数フィールド分のエラーが格納されている(FieldErrorのスライス型)であるため、ループしてメッセージを整形する
					for _, ferr := range verrs {
						// Namespaceに'PostUserParameter.name'のような「構造体名.JSONキー」が格納されている
						// 構造体名を取り除くため「.」のインデックスを特定し、トリムしている
						i := strings.Index(ferr.Namespace(), ".")
						ns := ferr.Namespace()[i+1:]
						// msgsというエラーメッセージを格納するスライスに整形した文字を追加する
						// ferr.Tag()は、エラーとなった対象のvalidateタグ(required等)が入っている
						msgs = append(msgs, fmt.Sprintf("'%s' validation failed on the %s tag", ns, ferr.Tag()))
					}
					errorMessage = fmt.Sprint(strings.Join(msgs, "\n"))
				} else {
					errorMessage = err.Error()
				}

				// バリデーションエラーが発生した場合は400レスポンスを返却する
				errorResponse := ErrorResponse{
					Message: errorMessage,
				}
				resBuf, _ := json.MarshalIndent(errorResponse, "", "  ")
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusBadRequest)
				_, _ = w.Write(resBuf)
				return
			}

			w.WriteHeader(http.StatusOK)
			return
		}
	})

	// サーバー起動
	http.ListenAndServe(":8080", nil)
}

終わりに

もっと簡単にカスタマイズできても良いのに..笑
最後まで読んでいただき、ありがとうございました。

Discussion