はじめに
個人的にNodeJsでGraphQLを実装する時にはTypeGraphQLを使用していますが、@InputType
にclass-validator
を使用したバリデーションは便利な反面返ってくるエラーの形式がそのままだとフロントエンドでは使いにくいため、エラーをフォーマットして使いやすくする方法を共有します。
TypeGraphql+class-validatorのバリデーション
TypeGraphqlでInputを実装する際はデコレータ@InputType
を付与した上でクラスを定義し、クラス内に入力項目として使いたい値を定義していきます。その際にclass-validator
と併用することで入力項目のバリデーションができるようになり、非常に便利です。
//例:新規ユーザーを作成する際の入力項目とバリデーションを定義
@InputType()
export class CreateUserInput {
@Field()
@IsNotEmpty({ message: 'ユーザー名は必須項目です!' })
@MaxLength(15, { message: '15文字以内に収めて下さい!' })
username: string;
@Field()
@IsNotEmpty({ message: 'Eメールは必須項目です!' })
@IsEmail({}, { message: '無効なEメールの形式です!' })
email: string;
@Field()
@IsNotEmpty({ message: 'パスワードは必須項目です!' })
@MaxLength(15, { message: '15文字以内に収めて下さい!' })
@MinLength(2, { message: '2文字以上に設定して下さい!' })
password: string;
@Field()
@IsNotEmpty({ message: '確認用パスワードは必須項目です!' })
@Match('password', { message: 'パスワードが一致しません!' })
confirmPassword: string;
}
例では新規ユーザーを作成する時の入力項目とバリデーション項目を定義しています。
こうすることで、入力項目に対してどのようなバリデーションが適用されるのかが一目瞭然になり、コードの見通しが非常に良くなります。
では実際にどのようにエラーが返ってくるか確認しましょう。
{
"errors": [
{
"message": "Argument Validation Error",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"createUser"
],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exception": {
"validationErrors": [
{
"target": {
"username": "",
"email": "",
"password": "1234",
"confirmPassword": "1234"
},
"value": "",
"property": "username",
"children": [],
"constraints": {
"isNotEmpty": "ユーザー名は必須項目です!"
}
},
{
"target": {
"username": "",
"email": "",
"password": "1234",
"confirmPassword": "1234"
},
"value": "",
"property": "email",
"children": [],
"constraints": {
"isEmail": "無効なEメールの形式です!",
"isNotEmpty": "Eメールは必須項目です!"
}
}
],
・・・・//以下省略
}
このようにとても長いエラーオブジェクトが返されてきました・・・・
このエラーオブジェクトはそのままでは使えなさそうです。
理由としては
1・エラー形式が「INTERNAL_SERVER_ERROR」となっていること(GraphQLErrorの形式ではない)
2・varidationErrors.constraints
オブジェクトのkeyがclass-validatorのバリデータの名称になっているため、複数ヶ所同じ名前のエラーが発生したらどの入力項目で発生したエラーなのか判定するのはこのままでは難しい(例ではisNotEmpty
が複数で発生しています)
なので、エラーをフロントエンドで扱いやすくなるようにフォーマットする必要がありそうです。
↑のエラーの理想としてはこんな形式ではないでしょうか↓。
//理想のエラーオブジェクト(例:入力項目「username」「email」が空白だった場合)
"errors": [
"validationErrors": [
{"username": "ユーザー名は必須項目です!"},
{"email": "Eメールは必須項目です!"}
]
]
このようにすると、どの項目でどのようなエラーが発生したのか一目瞭然となり、フロントエンドでも扱いやすいオブジェクトとなるのではないでしょうか。
ではApolloServer
の機能を使ってこのエラーをフォーマットしてみましょう。
ApolloServerのformatError
関数を使用してエラーをフォーマットする
ApolloServerにはformatError
関数が用意されており、この関数を使用することでエラーをフォーマットすることができます。
const apolloServer = new ApolloServer({
schema: await buildSchema({
resolvers: [UserResolver],
}),
formatError: (err: GraphQLError) => {
return err;
},
});
formatError
で受け取ったエラー(↑の例でいうと「err」)にはリゾルバーがスローしたエラーであるoriginalError
が含まれています。
このoriginalError
のインスタンスの種類によってエラー処理を変えることや、エラーを使いやすい形にフォーマットすることが可能になります。
const apolloServer = new ApolloServer({
schema: await buildSchema({
resolvers: [UserResolver],
}),
formatError: (err: GraphQLError) => {
if (err.originalError instanceof ArgumentValidationError) {
const errorMessage = err.extensions?.exception.validationErrors;
//validationErrors.propertyのvalueをkeyに、validationErrors.constraintsの
//valueをvalueに持つ新たな配列を作成
const object = errorMessage.map(
(e: { property: string; constraints: string }) => ({
[e.property]: Object.values(e.constraints),
})
);
//変数「object」は配列のため、reduceで値を取り出しオブジェクトにする
const formattedErrors = object.reduce(
(result: string[], current: string[]) => {
let key: any = Object.keys(current);
result[key] = current[key];
return result;
}
);
throw new UserInputError('Errors', { formattedErrors });
}
return err;
},
});
先程のエラーをフォーマットするコードはこのようになります。
まず、class-validator
でスローされたエラーはArgumentValidationError
インスタンスが含まれているため、その場合の処理をif
文で分岐させます。
その後validationErrors.property
のvalueをkeyに、validationErrors.constraints
のvalueをvalueに持つ新たな配列を作成し、さらにreduce
により配列→オブジェクトへと変換しています。
最後に作成したオブジェクトをUserInputError
でスローすれば完成です。
フォーマットされたエラーがどのようになったか確認してみましょう。
{
"errors": [
{
"message": "Errors",
"extensions": {
"formattedErrors": {
"username": [
"ユーザー名は必須項目です!"
],
"email": [
"無効なEメールの形式です!",
"Eメールは必須項目です!"
]
},
"code": "BAD_USER_INPUT",
//以下省略
}
大分扱いやすいデータになったのではないかと思います。
フロントエンドでこのエラーを使用する時は以下のようにすれば取得できます。
//例:React+ApolloClientでフォーマットしたエラーを取得する場合
const [error, setError] = useState<Record<string, string>>({});
const [createUser, { loading }] = useMutation(CREATE_USER,{
onError: (err) =>
setError(err.graphQLErrors[0].extensions?.formattedErrors),
onCompleted: () => router.push('/login'),
});
まとめ
ApolloServer
のformatError
を使用すれば扱いやすいデータへとエラーをフォーマットできることがわかったと思います。
今後も新しいことが分かり次第随時記事を更新していこうと思います。