🙌

gqlgen + validatorでvalidation

2022/10/11に公開約6,800字

概要

gqlgen[1]でのバリデーションでなるべく辛くない方法を探してみましたのまとめです.
新しい知見が得られたら適宜更新していきます.

最初にまとめ

最初に結論から書きますが, gqlgen + go-playground/validator[2]でバリデーションを書いちゃうのが楽でした.

最初にちょっとだけ書いてしまえば, 以下の2stepで大体のバリデーションはできてしまいます.

  1. schemaのdirective修正
  2. コード生成

試しに書いたサンプルはこちら.
https://github.com/Sntree2mi8/gqlgen-validator-sample

途中で公式がやんわりと紹介していたのに気づきました. (もっと注意深く見ればよかった...)
modelgen-hook[3]で紹介されていた例はvalidatorを用いたバリデーションの例でした.
一例目がORMへのbindingのタグの例だったので, GraphQLのinput typeとORMのモデルを一緒にすることは自分はないだろうなとタグの中身はすっ飛ばして読んでいました.

validation評価ポイント & お気持ち

  • validationの実装
    • できる限り自分(達)で実装したくない.
    • 実装をミスなくできる自信がない.
  • validationの適用
    • できる限り自分(達)で実装したくない.
    • 大人数で開発していて抜け漏れを作らない自信も指摘できる自信もない.
  • バリデーションルールのクライアントへの伝え方
    • できる限りメンテナンスの少ない方法で表したい.
  • メンテナンス
    • ほとんどしたくない

validatorを用いた理由

https://github.com/go-playground/validator

ライブラリ自体の安定性と, tagを元に動作するライブラリとgqlgenの噛み合わせが良いことからの採用です.

具体的な理由はこちら

  • ライブラリのメンテナンスに関して
    • 開発が今も続いてる.
    • Star数11.5Kで認知もバッチリ.
    • そんなに厚いライブラリじゃないのでダメそうだったらすぐ捨てられそう.
  • 使いやすさに関して
    • 一通りのバリデーションサポートやカスタムのバリデーション, translateなど, 一通りのユースケースはカバーしている.
    • schemaからmodel生成するGraphQLと構造体のタグを元にバリデーションするvalidatorはmodel生成にタグ付けを組み込むことができたら相性が良さそう.
      • directiveを元にタグ付けできるのは確認済み.
      • validatorと同様に人気のあるバリデーションライブラリのgo-ozzo/ozzo-validation[4]を用いないでvalidatorを用いた理由がここ.
    • validate.Struct(input) を書くだけで適用できるので簡単.
  • クライアントへの見せ方に関して
    • directiveを用いてバリデーションルールを表せるため, フロントエンドの開発者も確認しやすい. Resolverの中で書かれてそこまで読みに行かないとルールを確認できないのはめんどくさい.
    • バリデーションルールの追加変更削除の際にはschemaの修正が必須になるので, descriptionにバリデーションルールも書く〜などのルールを設けていても忘れ辛い(PRでもchanged fileに出てくるので確認しやすい).

validatorを用いた実装

Schemaの用意

公式のmodelgen-hookサンプルを参考に進めていきます(参考にというかvalidationのサンプルだったのでもろそのままです).

題材として以下のようなSchemaを用意しました. ほとんどgqlgenのinitで生成されるやつです.

# Format must correspond to A.https://pkg.go.dev/github.com/go-playground/validator/v10
directive @validation(
  format: String
) on INPUT_FIELD_DEFINITION

type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
}

type User {
  id: ID!
  name: String!
}

input NewTodo {
  text: String! @validation(format: "required,len=10")
  userId: String! @validation(format: "required")
}

type Mutation {
  createTodo(input: NewTodo!): Todo!
}

次に, このdirectiveを処理してくれる関数を書きます
この関数がコード生成の時に動いてvalidation directiveを元にモデルのtagをゴニョゴニョしてくれます.

customhook/validation_field_hook.go
package customhook

import (
	"github.com/99designs/gqlgen/plugin/modelgen"
	"github.com/vektah/gqlparser/v2/ast"
)

func ValidationFieldHook(td *ast.Definition, fd *ast.FieldDefinition, f *modelgen.Field) (*modelgen.Field, error) {
	c := fd.Directives.ForName("validation")
	if c != nil {
		formatConstraint := c.Arguments.ForName("format")
		if formatConstraint != nil {
			f.Tag += " validate:" + formatConstraint.Value.String()
		}
	}
	return f, nil
}

最後にこのプラグインを適用してコード生成を行えば

cmd/gqlgenerate/main.go
package main

import (
	"fmt"
	"os"

	"github.com/99designs/gqlgen/api"
	"github.com/99designs/gqlgen/codegen/config"
	"github.com/99designs/gqlgen/plugin/modelgen"
	"github.com/Sntree2mi8/gqlgen-validator-sample/graph/customhook"
)

func main() {
	cfg, err := config.LoadConfigFromDefaultLocations()
	if err != nil {
		fmt.Fprintln(os.Stderr, "failed to load config", err.Error())
		os.Exit(2)
	}

	// Attaching the mutation function onto modelgen plugin
	p := modelgen.Plugin{
		FieldHook: customhook.ValidationFieldHook,
	}

	err = api.Generate(cfg, api.ReplacePlugin(&p))

	if err != nil {
		fmt.Fprintln(os.Stderr, err.Error())
		os.Exit(3)
	}
}
go run ./cmd/gqlgenerate

以下のようなモデルが生成されます

type NewTodo struct {
	Text           string          `json:"text" validate:"required,lte=10"`
	UserID         string          `json:"userId" validate:"required"`
}

後はresolverで以下のように書くだけでvalidationの処理が実行されます

// CreateTodo is the resolver for the createTodo field.
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
	if err := validate.Struct(input); err != nil {
		// ~~~ error handling ~~~
	}
	
	// ~~~ create new todo ~~~
}

簡単...

validatorを用いて実装してみて

実装でどれだけ手を抜けるか

最初に少しだけ実装しちゃえばその後はschemaの修正とコード生成だけでどんどん実装ができるのでとっても楽だし自分がやるより数倍安全に見える.

このSchemaを利用する人へルールをどう見せるか

クローズドなAPIで社外にintrospectionを公開していない場合

GraphQLを使っている以上 .graphql の形式はフロントエンドエンジニアもバックエンドエンジニアも習得しているとしてSchemaを見るで良さそう.

形式もとってもわかりやすいのでわからないなんてこともなさそう.

input NewTodo {
  text: String! @validation(format: "required,lte=10")
  userId: String! @validation(format: "required")
}

タグの種類に関してはどうしてもvalidatorに依存してしまうのでどこを見ればいいかは書いておいてあげた. が, 全ての開発者がここを見るかと言われるとビミョいので「ルールが読めません」といった質問は定期的にくるんだろうなと思う. ここを見てで済むのでそこまで問題としては上がらないかもしれないけども.

# Format must correspond to A.https://pkg.go.dev/github.com/go-playground/validator/v10
directive @validation(
  format: String
) on INPUT_FIELD_DEFINITION

パブリックなAPIで社外にintrospectionを公開している場合

schemaを見てが使えないので, 何かしら見える形にしてあげる必要がある.
せっかくschema駆動なバリデーションができてるのでここもschemaを元に自動生成されれば嬉しい.
directiveのruntimeをonにして, introspectionのクエリに関してformatの内容からdescriptionを生成してあげると自動で対応ができそう.

Errorの出力

validatorから返ってくるエラーをそのまま出力すると, 以下のように各fieldで失敗したバリデーションのタグが一行で返されることになるので

Key: 'NewTodo.Text' Error:Field validation for 'Text' failed on the 'lte' tag\nKey: 'NewTodo.UserID' Error:Field validation for 'UserID' failed on the 'required' tag

タグ毎に日本語をメッセージを用意してあげて返してあげる必要はありそうでした. また, フォームでそのまま表示できるように違反したfieldと理由をマッピングしてextensionで返してあげるとフロントエンド側でも次のアクションが取りやすいのでそのように加工して返しています.

func msgForTag(fe validator.FieldError) string {
	switch fe.Tag() {
	case "required":
		return "入力は必須です"
	case "len":
		return fmt.Sprintf("%s文字で入力してください", fe.Param())
	case "gte":
		return fmt.Sprintf("%s文字以上で入力してください", fe.Param())
	case "lte":
		return fmt.Sprintf("%s文字以下で入力してください", fe.Param())
	case "timezone":
		return "IANA Time Zone databaseの形式で入力してください"
	case "HH:mm":
		return "00:00 ~ 23:59の間で入力してください"
	}
	return fe.Error()
}

func ValidateModel(model any) (map[string]string, error) {
	if err := validate.Struct(model); err != nil {
		if _, ok := err.(*validator.InvalidValidationError); ok {
			return nil, err
		}

		errs := err.(validator.ValidationErrors)
		validationErrors := make(map[string]string, len(errs))
		for _, ve := range errs {
			validationErrors[ve.StructNamespace()] = msgForTag(ve)
		}

		return validationErrors, nil
	}
	return nil, nil
}

GraphQL clientで見るとこんな感じ.

まとめ

試してみた結果, gqlgenでバリデーションするなら今のところこれで確定かなーと感じた

今回はバリデーションでしたが, 他の操作でResolverの層(他のアーキテクチャで言うとControllerだったりHandlerの層)でやることでtagからできることがあればやれると開発体験上がっていくのかなー

脚注
  1. gqlgen ↩︎

  2. go-playground/validator ↩︎

  3. modelgen-hook ↩︎

  4. go-ozzo/ozzo-validation ↩︎

Discussion

ログインするとコメントできます