【Go】GraphQL で directive を使ったバリデーション
GraphQL で directive を使った入力値バリデーションを実装する。
開発環境
- go 1.17
- github.com/99designs/gqlgen v0.17.1
- github.com/graph-gophers/dataloader v5.0.0+incompatible
本文
概要
下記を参考にして入力値のバリデーションを実装した。
実装の完成品は先週と同じ場所に置いた。
https://gitlab.com/k1350/daybreak_sample/-/tree/graphql_paging_sample
今回実装したいのは
- 数値の最大値・最小値
- 文字列が整数にパースできるかどうか
である。
どうして 2 が欲しいかというと、GraphQL では ID が文字列と定められているが、私は内部的には整数値として取り扱いたいからである。
ID 自体を整数値として定義することが gqlgen では可能だが、スタンダードに乗っておくほうが賢いと思っているので定義自体は文字列のままにしたい。
directive 定義
graph/schema.graphqls に directive を下記のように定義する。
directive @numberValue(
min: Int
max: Int
) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
directive @stringValue(
isInt: Boolean
) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
同ファイル内で使用する側は下記のように指定する。(必要な部分のみ抜粋)
type Query {
blogById(id: ID! @stringValue(isInt: true)): Blog
articles(input: BlogByBlogKeyInput!, paginationInput: ArticlePaginationInput!): ArticleConnection
}
input ArticlePaginationInput {
first: Int @numberValue(min: 0, max: 100)
after: String @stringValue(isInt: true)
last: Int @numberValue(min: 0, max: 100)
before: String @stringValue(isInt: true)
}
これでコードを生成すると、graph/generated/generated.go に下記のように生成される。
type Config struct {
Resolvers ResolverRoot
Directives DirectiveRoot
Complexity ComplexityRoot
}
type DirectiveRoot struct {
NumberValue func(ctx context.Context, obj interface{}, next graphql.Resolver, min *int, max *int) (res interface{}, err error)
StringValue func(ctx context.Context, obj interface{}, next graphql.Resolver, isInt *bool) (res interface{}, err error)
}
directive 実装
生成された DirectiveRoot 型の中身を実装する。
internal/directive/directive.go に下記のようにバリデーションの中身を書く。
package directive
import (
"context"
"fmt"
"strconv"
"github.com/99designs/gqlgen/graphql"
)
func NumberValue(ctx context.Context, obj interface{}, next graphql.Resolver, min *int, max *int) (res interface{}, err error) {
v, err := next(ctx)
if err != nil {
return nil, err
}
switch v.(type) {
case *int:
value := v.(*int)
if min != nil {
if *value < *min {
return nil, fmt.Errorf(strconv.Itoa(*value) + ": is smaller than min value of " + strconv.Itoa(*min))
}
}
if max != nil {
if *max < *value {
return nil, fmt.Errorf(strconv.Itoa(*value) + ": is larger than max value of " + strconv.Itoa(*max))
}
}
case *float64:
value := v.(*float64)
if min != nil {
if *value < float64(*min) {
return nil, fmt.Errorf(strconv.FormatFloat(*value, 'f', -1, 64) + ": is smaller than min value of " + strconv.Itoa(*min))
}
}
if max != nil {
if float64(*max) < *value {
return nil, fmt.Errorf(strconv.FormatFloat(*value, 'f', -1, 64) + ": is larger than max value of " + strconv.Itoa(*max))
}
}
default:
return nil, fmt.Errorf("NumberValue directive is compatible only Int and Float")
}
return v, nil
}
func StringValue(ctx context.Context, obj interface{}, next graphql.Resolver, isInt *bool) (res interface{}, err error) {
v, err := next(ctx)
if err != nil {
return nil, err
}
switch v.(type) {
case *string:
value := v.(*string)
if isInt != nil && *isInt {
_, err := strconv.Atoi(*value)
if err != nil {
return nil, fmt.Errorf("Not Int")
}
}
case string:
if isInt != nil && *isInt {
_, err := strconv.Atoi(v.(string))
if err != nil {
return nil, fmt.Errorf("Not Int")
}
}
default:
return nil, fmt.Errorf("StringValue directive is compatible only String and ID")
}
return v, nil
}
StringValue
のほうで *string
のときと string
のときを分けている。
これはスキーマで
type Query {
blogById(id: ID! @stringValue(isInt: true)): Blog
}
このように必須入力の引数に直接 directive を指定したときは string
で値が渡ってくるのだが、
type Query {
articles(input: BlogByBlogKeyInput!, paginationInput: ArticlePaginationInput!): ArticleConnection
}
input ArticlePaginationInput {
first: Int @numberValue(min: 0, max: 100)
after: String @stringValue(isInt: true)
last: Int @numberValue(min: 0, max: 100)
before: String @stringValue(isInt: true)
}
このように別途 input を定義し、その input 内において必須ではないものに directive を指定すると *string
で渡ってきたからである。
(今はほぼ同じコードを 2 回書いているので、もう少し共通化はできると思っている。)
バリデーションの使用
c := generated.Config{
Resolvers: &graph.Resolver{
BlogRepository: b,
ArticleRepository: a,
},
}
c.Directives.NumberValue = directive.NumberValue
c.Directives.StringValue = directive.StringValue
srv := handler.NewDefaultServer(
generated.NewExecutableSchema(c),
)
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query",
auth.AuthMIddleware(
dataloader.DataloaderMIddleware(
&dataloader.Repository{
BlogLinkRepository: bl,
},
srv,
),
),
)
こんな感じで Config にセットする。
これで directive を通過したものしか Resolver に到達しなくなる。
以上