【Go】GraphQL で directive を使ったバリデーション

Series: gqlgen
Tags: Go Golang GraphQL

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

今回実装したいのは

  1. 数値の最大値・最小値
  2. 文字列が整数にパースできるかどうか

である。

どうして 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 回書いているので、もう少し共通化はできると思っている。)

バリデーションの使用

server.go

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 に到達しなくなる。

以上