Gin基礎 #3 リクエストのバインディングとバリデーション

読了 5分

前回の記事ではルーティングを扱いました。今回の記事は、入ってくるリクエストデータをGoの構造体で受け取るバインディングと、その値が正しいか確認するバリデーションを整理します。POST/PUTリクエストをきちんと処理するうえで、必ず押さえておきたい内容です。

  • #1 はじめてのサーバー
  • #2 ルーティングとハンドラ
  • #3 リクエストのバインディングとバリデーション ← 今回の記事
  • #4 レスポンス処理 — JSON、ステータスコード、エラー
  • #5 ミドルウェア
  • #6 データベース連携 (GORM)
  • #7 プロジェクト構成とミニREST API

バインディングとは? #

クライアントが送ってきたJSONボディやフォームデータを、あらかじめ定義したGoの構造体に詰め込む作業をバインディングと呼びます。フィールドを1つずつ取り出して変換する代わりに、構造体1つで一度に受け取ります。

構造体の定義
type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
	Age   int    `json:"age"`
}

json:"name" タグは、JSONのどのキーをどのフィールドにマッピングするかを指定します。JSONキーが name なら Name フィールドに入ります。

JSONボディのバインディング — ShouldBindJSON #

c.ShouldBindJSON がリクエストボディを読んで構造体に詰めます。

JSONバインディング
func createUser(c *gin.Context) {
	var req CreateUserRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"received": req})
}
リクエスト
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"カーティス","email":"a@b.com","age":30}'
# {"received":{"name":"カーティス","email":"a@b.com","age":30}}

構造体のポインタを渡している点に注目してください。バインディングは構造体の値を埋めなければならないので、&req でアドレスを渡します。ボディの形式が間違っていたり型が合わなかったりするとエラーが返り、上のコードは400で応答してから return で処理を終えます。

バリデーション — bindingタグ #

バインディングだけでは「値が入ってきたか」「形式が合っているか」までは確認できません。Ginは構造体タグでバリデーションルールを宣言する機能を内蔵しています。binding タグにルールを書きます。

バリデーションルールの追加
type CreateUserRequest struct {
	Name  string `json:"name" binding:"required"`
	Email string `json:"email" binding:"required,email"`
	Age   int    `json:"age" binding:"gte=0,lte=130"`
}
  • required — 値が必ず存在しなければならない
  • email — メールアドレスの形式でなければならない
  • gte=0,lte=130 — 0以上130以下

これでルールに反するリクエストは ShouldBindJSON の段階でエラーになります。

バリデーションに失敗するリクエスト
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"not-an-email","age":200}'
# 400、バリデーションエラーメッセージ

よく使うルールをもう少し整理します。

タグ意味
required値が空であってはならない
emailメールアドレス形式
min=3 / max=20文字列の長さまたは数値の大きさの範囲
gte=0 / lte=100以上 / 以下
oneof=admin user列挙した値のいずれか
len=10正確な長さ

バリデーションエンジンは go-playground/validator で、さらに多くのルールはそのドキュメントで確認できます。

ShouldBind vs Bind #

Ginのバインディング関数は2つの系統に分かれます。

  • ShouldBindJSON — エラーを返すだけです。レスポンスは自分で書きます。
  • BindJSON — エラーが出ると自動的に400とともにリクエストを中断(abort)します。
2つの方式の比較
// エラーを自分で処理 — 推奨
if err := c.ShouldBindJSON(&req); err != nil {
	c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
	return
}

// 自動400 — レスポンス形式を制御しにくい
if err := c.BindJSON(&req); err != nil {
	return // Ginがすでに400レスポンスを送っている
}

APIのエラーレスポンス形式を一貫して制御したいなら、ShouldBind 系を使うほうがよいです。このシリーズは ShouldBind を基本に使います。

クエリとパスもバインディング #

ボディだけでなく、クエリストリングやパスパラメータも構造体で受け取れます。パラメータが多いときにハンドラがすっきりします。

クエリバインディング
type ListQuery struct {
	Page int    `form:"page,default=1" binding:"gte=1"`
	Size int    `form:"size,default=20" binding:"gte=1,lte=100"`
	Sort string `form:"sort"`
}

func listUsers(c *gin.Context) {
	var q ListQuery
	if err := c.ShouldBindQuery(&q); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"query": q})
}

クエリは form タグでキーを指定し、default= で初期値を与えられます。パスパラメータは uri タグと c.ShouldBindUri を使います。

パスバインディング
type UserURI struct {
	ID int `uri:"id" binding:"required"`
}

func getUser(c *gin.Context) {
	var u UserURI
	if err := c.ShouldBindUri(&u); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"userID": u.ID})
}

フォームデータのバインディング #

application/x-www-form-urlencodedmultipart/form-data のフォームも同じ方法で受け取ります。form タグを使って c.ShouldBind を呼べば、GinがContent-Typeを見て適切に処理します。

フォームバインディング
type LoginForm struct {
	Username string `form:"username" binding:"required"`
	Password string `form:"password" binding:"required"`
}

func login(c *gin.Context) {
	var form LoginForm
	if err := c.ShouldBind(&form); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"user": form.Username})
}

バリデーションエラーを整えて応答する #

err.Error() をそのまま出すと、メッセージが冗長でユーザーには読みにくいです。どのフィールドがなぜ失敗したかを整理して返すと、クライアントが処理しやすくなります。

フィールドごとのエラー整理
import "github.com/go-playground/validator/v10"

if err := c.ShouldBindJSON(&req); err != nil {
	var ve validator.ValidationErrors
	if errors.As(err, &ve) {
		out := make(map[string]string, len(ve))
		for _, fe := range ve {
			out[fe.Field()] = fe.Tag() // 例: "Email": "email"
		}
		c.JSON(http.StatusBadRequest, gin.H{"errors": out})
		return
	}
	c.JSON(http.StatusBadRequest, gin.H{"error": "不正なリクエスト"})
	return
}

エラーメッセージをさらに精密に整える作業は、中級シリーズのエラー処理編で続けます。

まとめ #

今回の記事で整理した内容です。

  • バインディングはリクエストデータをGoの構造体に詰める作業、json/form/uri タグでマッピング
  • ボディは c.ShouldBindJSON、クエリは c.ShouldBindQuery、パスは c.ShouldBindUri
  • 構造体のポインタ(&req)を渡してこそ値が埋まる
  • binding タグでバリデーションルールを宣言、required/email/gte/oneof など
  • ShouldBind 系はエラーだけ返し、Bind 系は自動400、制御力は ShouldBind のほうが大きい
  • バリデーションエラーは validator.ValidationErrors でフィールドごとに整理して応答できる

次の記事(#4 レスポンス処理)では、JSON以外の多様なレスポンス形式とステータスコード、そして一貫したエラーレスポンスの作り方を整理します。

X