42
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

GraphQLでスキーマファーストなForm Validation

Last updated at Posted at 2019-12-15

こんにちは、こちらはGraphQL Advent Calendar 16日目です。

タイトルにある「スキーマファーストなForm Validation」とはなんぞやという話ですが、短く説明するとこんな directive を書いたとして

input RegisterAddressInput {
  postalCode: String @constraint(minLength: 7, maxLength: 7)
  state: String @constraint(maxLength: 4)
  city: String @constraint(maxLength: 32)
  line1: String @constraint(maxLength: 32)
  line2: String @constraint(maxLength: 32)
}

サーバサイドはこの constraint を満たしていない値が来たら BadRequest で弾くし、フロントでは下記のようなFormの実装に使うバリデーションオブジェクトをこの定義に基づいて生成することを指します。

フロント側のバリデーションオブジェクト完成図
const RegisterAddressInputValidationSchema = yup.object().shape({
  postalCode: yup
    .string()
    .min(7)
    .max(7),
  state: yup.string().max(4),
  city: yup.string().max(32),
  line1: yup.string().max(32),
  line2: yup.string().max(32)
});

本記事ではそれをどのように実現したかを記していきます。

対応する constraint

初めに、今回満たしたい要件として次の値をサポートします。少ないですがとりあえず自分が必要だったものだけ最低限揃えた感じです。

name type description
minLength int min length of a string
maxLength int max length of a string
pattern string regex for a string
min int min value of a number
max int max value of a number

ネーミングはこちらのRFCのプロポーザルを参考にしています。
https://github.com/APIs-guru/graphql-constraints-spec

スキーマには次のように directive の定義を記述しておきます。

directive @constraint(
  minLength: Int
  maxLength: Int
  pattern: String
  min: Int
  max: Int
) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION

サーバ側のバリデーションを書く

コードは golang(のgqlgen) で書いてますが、多分他の言語のライブラリでも大抵 field 毎の directive に対して処理を行う方法はあると思うので実現可能かと思います。
これ一個書くだけで、サーバサイドではこの constraint でサポートしてる範囲のバリデーションは全部やってくれるようになります。

package directives

import (
    "context"
    "fmt"
    "regexp"
    "strconv"
    "unicode/utf8"

    "github.com/99designs/gqlgen/graphql"
)

func Constraint(ctx context.Context, obj interface{}, next graphql.Resolver, minLength *int, maxLength *int, pattern *string, min *int, max *int) (interface{}, error) {
        // フィールド毎に値を取得
    v, _ := next(ctx)

    // 文字列のルールの場合
    if maxLength != nil || minLength != nil || pattern != nil {
        value := v.(*string)

        if maxLength != nil && value != nil && utf8.RuneCountInString(*value) > *maxLength {
            return nil, fmt.Errorf(*value + ": is over maxLength of " + strconv.Itoa(*maxLength))
        }

        if minLength != nil && value != nil && utf8.RuneCountInString(*value) < *minLength {
            return nil, fmt.Errorf(*value + ": is shorter than minLength of " + strconv.Itoa(*minLength))
        }

        if pattern != nil && value != nil {
            r := regexp.MustCompile(*pattern)
            if !r.MatchString(*value) {
                return nil, fmt.Errorf(*value + ": dose not match pattern of " + *pattern)
            }
        }
    }

    // 数値のルールの場合
    if min != nil || max != nil {
        value := v.(*int)

        if max != nil && value != nil && *value > *max {
            return nil, fmt.Errorf(strconv.Itoa(*value) + ": is over maxLength of " + strconv.Itoa(*max))
        }

        if min != nil && value != nil && *value < *min {
            return nil, fmt.Errorf(strconv.Itoa(*value) + ": is lower than minLength of " + strconv.Itoa(*min))
        }
    }

    return v, nil
}

これで個々のMutationに対してバリデーション書かなくて良くなるのは中々に体験が良いです。みんなやりましょう。

フロント側でバリデーションオブジェクトを生成する

さて、バックエンドの方はちょちょいのちょいでしたが、フロントは一手間です。
理由としてはフロント側ではFormは個別に作る必要があり「汎用的なものが一個あれば済む」というものではないからです。

具体的にはFormは次のような感じで実装しています。

import * as React from 'react';
import useForm from 'react-hook-form';

// yup というライブラリを使ってバリデーションルール作成
const RegisterAddressInputValidationSchema = yup.object().shape({
  postalCode: yup
    .string()
    .min(7)
    .max(7),
  state: yup.string().max(4),
  city: yup.string().max(32),
  line1: yup.string().max(32),
  line2: yup.string().max(32)
});

export default function AddressForm(props: Props) {
  // formライブラリにvalidationSchemaを与える
  const { register, errors, getValues } = useForm<RegisterAddressForm>({
    validationSchema: RegisterAddressInputValidationSchema
  });

  return (
    <FormElement>
      {...}
    </ FormElement>
  )
}

なのでできるのは上記コード内ではバリデーションオブジェクトを作るまでかなーという感じです。

graphql-codegen で plugin 作成

と言うわけでバリデーションオブジェクトを自動生成できるツールはないものかと探してみたのですが、なかったのでgraphql-codegenの plugin として自分で作ってみました。一応 npm package として公開しています。
https://github.com/kazuyaseki/graphql-codegen-yup-schema

graphql-codegenとは


https://graphql-code-generator.com/

神ツールです。このツールの布教ができたらこの記事の目的は果たされたと言えるでしょう。schema に応じて型を自動生成してくれたり、私は今 apollo を使っているのですが、apollo のpluginを使えば定義した個々のQueryやMutationに対して実行可能なhooksを生成してくれます。生産性爆上がりです。

そして、ありがたいのがお手軽にそういった plugin が自作できることです。
こんな感じで関数を graphql-codegen のコマンドを用いて実行すると、引数にGraphQLスキーマの ast などを詰めて実行してくれます。返り値に文字列を用意すると、その文字列がgraphql-codegenの設定ファイルに指定したファイル名で出力されます。


module.exports = {
  plugin: (schema, documents, config) => {
    return 'Hi!';
  },
};

より詳しくは本家のチュートリアル記事を参考にしてみてください。
https://graphql-code-generator.com/docs/custom-codegen/write-your-plugin

作ったもの

READMEにも書いていますが、次のように graphql-codegen の設定ファイルに指定してあげると、constraint を指定した input object に対して yup のオブジェクトを export します。

schema: ./graphql/generated/schema.graphql
documents:
  - ./graphql/mutations/*.gql
generates:
  ./graphql/generated/validationSchemas.ts:
    - graphql-codegen-yup-schema

大分クソコードなんですが、一応中で何やってるか解説します。
https://github.com/kazuyaseki/graphql-codegen-yup-schema/blob/master/index.js

まずは constraint の directive が入った input object を抽出します。


function getNodesWithConstraintDirective(schema) {
  // filter only input object
  const inputFieldAstNodes = [];
  for (let key in schema._typeMap) {
    const node = schema._typeMap[key];

    if (node.astNode && node.astNode.kind === 'InputObjectTypeDefinition') {
      inputFieldAstNodes.push(node.astNode);
    }
  }

  // filter input objects with constraint directive
  return inputFieldAstNodes.filter(node => {
    let hasConstraint = false;

    node.fields.forEach(field => {
      field.directives.forEach(directive => {
        if (directive.name.value === 'constraint') {
          hasConstraint = true;
        }
      });
    });

    return hasConstraint;
  });
}

次に、その input object の field 毎に次の関数を実行して、その field に対する yup のオブジェクトを作ります。

function buildYupObjectStringByField(field) {
  const constraints = field.directives
    .filter(directive => directive.name.value === 'constraint')
    .reduce((prev, current) => {
      return [...prev, ...(current ? current.arguments : [])];
    }, []);

  if (constraints.length < 1) {
    return '';
  }

  const fieldName = ield.name.value;

  const propTypeString = getPropertyTypeString(constraints, fieldName);

  return `${fieldName}: yup${propTypeString}${constraints
    .map(
      constraint =>
        `.${getYupPropName(constraint.name.value)}(${constraint.value.value})`
    )
    .join('')}`;
}

そして input object 全体の yup object を作ります。ちなみにネーミングルール {input objectの名前}ValidationSchema という風にしています。


const result = inputFields
      .map(node => {
        // build string of yup object for each field
        const fieldYupObjectStrings = node.fields
          .map(buildYupObjectStringByField)
          .filter(str => str.length > 0);

        return `export const ${
          node.name.value
        }ValidationSchema = yup.object().shape({ ${fieldYupObjectStrings.join(
          ',\n'
        )}})`;
      })
      .join('\n\n');

最後にファイル全体のものを export してあげれば終わりです。

return `import * as yup from 'yup'
${result}`;

初見ではそこそこ作るのに時間かかったんですが、慣れてくれば色々高速で作れそうです。夢が広がります。
あとこれ上記のコード書いてから気づいたんですが、ASTを探索する時には visitor pattern というものがあるそうで、これ適用したらもう少し綺麗にコード書けるかもしれません。

この pluginはまだ実装し足りないのがあって、スキーマ上で ! があったら .required() 足すとかあと今のまんまだとエラーメッセージが yup がデフォルトで用意している英語の文言でしか出てこないので、カスタムメッセージ入れられるようにしたりとかはとりあえず追加したいなーと思っています。

あと暇ができたらちゃんとテストは書きたいですね。勢いで publish したらなぜか既に80ダウンロードくらいされてて、人間なのか使い続けてくれているのか分からないですが、迷惑かけないか心配になってきた…。

感想

GraphQLのスキーマを元にフォームのバリデーションを行う方法をご紹介しました。

サーバサイドは明確に利点があって、directiveに対する関数を一個書けばオールOKなのは最高なのでやらない理由がないです。

フロントは、わざわざ plugin まで作っておいてなんなんですが、やっぱりフォーム毎に作らなきゃいけないので、初手の作る手間がちょっと減るくらいでそこまで恩恵を強く感じないです。ただもう少しフォームの数が増えたり、他のプロジェクトでも横展開したらテンション上がる予感もしているので引き続き plugin の補強をしていきたいと思います。

それでは、お読みいただきありがとうございました。

42
25
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?