EventHubのバリデーションを支えるclass-validatorについて

はじめに

こんにちは!! EventHub の Web エンジニアの須田です 🐱

今回は、EventHub で採用している技術の 1 つである class-validator についてと、その活用事例についてご紹介していきます 🐱

class-validator とは?

class-validator は、TypeScript および JavaScript 向けのデコレーターベースの検証ライブラリです。

主にクラスのプロパティにデコレーターを追加することで、データのバリデーションを簡単に実装できます。

ちなみに、TypeScript におけるデコレーターは、クラスやそのメンバー(メソッド、プロパティ、アクセサ、パラメータ)に特別な機能を追加するための構文(修飾子)で、@記号で始まります。

例えば、次のようなコードでは、@Contains@IsEmailなどのデコレーターを使用して、インスタンスのプロパティに対してバリデーションを実行しています。

import { Contains, IsEmail } from "class-validator";

export class PostReq {
  @Contains("EventHub") // textに'EventHub'という文字列が含まれているかをチェックする。
  text: string;

  @IsEmail() // emailが正しいメールアドレス形式かをチェックする。
  email: string;
}

github.com

class-validator のデコレーターについて

class-validator に標準搭載されているバリデーション・デコレーターの種類は、README.md に整理されているとおり、8 種類に分けられます 📝

これらの検証デコレーターを組み合わせることで、フォーム入力や API リクエストなどに対して強固なバリデーションを実装し、入力データの安全性や整合性を確保することができます。

詳細は、README.md の次のセクションをご覧ください。 https://github.com/typestack/class-validator?tab=readme-ov-file#validation-decorators

class-validator の特徴・どんな場面で利用するのか?

class-validator の特徴は、クラスの検証したいプロパティにデコレーターを付与して、バリデーションルールを定義するスタイルにあるかと思います。

既存の Class のプロパティに対して、バリデーション機能を追加できるため、Class を使ったフォーム入力や API リクエストなどのバリデーションに適しています。

EventHub で採用しているフレームワークである NestJS では、公式にも class-validator を利用していることが記載されています。

(Class ベースのプロジェクトと、class-validator は相性がいいため、NestJS では class-validator を利用しているのかと思われます。)

docs.nestjs.com

class-validator と class-transformer の関係性

class-transformer は、class-validator とセットで使用されることが多いので、その関係性について整理しておきます。

class-transformer は、プレーンオブジェクトをクラスインスタンスに変換したり、その逆を行ったりするためのライブラリです。

class-validator でバリデーションをするには、Class である必要があります。

そこで、プレーンオブジェクトをクラスインスタンスに変換するツールである class-transformer をセットで利用することが多いです。

また、変換先の Class にバリデーションデコレーターを付与していれば、class-transformer で Class 変換する際にバリデーションも合わせて実施するようにすることもできます。

上記のような組み合わせて使用できるため、EventHub では class-transformer と class-validator をセットで利用しており、 Form のプレーンオブジェクトを Request のクラスに変換する際や、API のレスポンスを Response のクラスに変換する際などに使用しています。

ちなみに NestJS 公式でも、class-validator と class-transformer はセットで紹介されています。

github.com

EventHub での class-validator の使用ルールについて 📝

次の引用にもある通り、class-validator は、ブラウザと node.js どちらの環境でも動作するので、フロントエンド でも バックエンド でも共通して使用することができます 👍

Allows use of decorator and non-decorator based validation. Internally uses validator.js to perform validation. Class-validator works on both browser and node.js platforms. 引用元: GitHub class-validator

フロントエンド でも バックエンド でも共通して使用することで、フロントエンドとバックエンドの間でデータの型を一致させることができるため、データの整合性を保ちやすいです。

そして、EventHub では、フロントエンドとバックエンド両方で TypeScript を採用しているため、どちらでも class-validator の恩恵を受けることができます。

また、EventHub のコーディング規約には、次のような記載があります。

バリデーター

  • バリデーションのロジックが class-validator に用意されているもので不十分な場合は、カスタムバリデーターとして切り出す。
  • 基本的にバリデーションはサーバー・フロントエンドの両方で実行する。何か特別な理由がある場合は都度判断してサーバーのみは許容する。フロントエンドのみでのバリデーションは基本はなしにする。(サーバーに関連がなくフロントエンドのみで完結する場合はフロントエンドのみで問題ない)

バリデーション(値の検証)はフロントエンド/バックエンドの両方で実行して、受け渡すデータの保証をしていく必要があるので、

そういう意味でも、フロントエンド でも バックエンド でも動作して共通利用できる class-validator はありがたい存在なわけです。

また、class-validator の標準デコレーターで対応できないバリデーションに関しては、独自のバリデーションルールも実装しています。

カスタム・バリデーターに関しては、後述します 📝

EventHub での class-validator の活用ポイント 📝

EventHub での class-validator の主な活用場面は、リクエストのバリデーションです。 (他にも活用場面はありますが、割愛させていただきます 🙏)

例えば、以下のようなリクエストの定義ファイルがあったとします。

import { IsNotEmpty, IsString, ValidateIf } from "class-validator";

import DataValidationError from "$shared/constants/DataValidationError";
import Spec from "$shared/constants/Spec";

// アイテム作成のリクエストクラス
export class CreateItemReq {
  @ValidateIf((_, v) => v != null)
  @IsString()
  @IsNotEmpty({ message: DataValidationError.DATA_IS_EMPTY })
  readonly nameJa: string | null;

  @ValidateIf((_, v) => v != null)
  @IsString()
  @IsNotEmpty({ message: DataValidationError.DATA_IS_EMPTY })
  readonly nameEn: string | null;

  constructor(arg: { nameJa: string | null; nameEn: string | null }) {
    arg = arg || {};

    this.nameJa = arg.nameJa;
    this.nameEn = arg.nameEn;
  }
}

上記のリクエスト(Request Class)をフロントエンドでの API Request 前にバリデーションする場合は、次のようなコードになります。 SampleCode は、一部省略していたり、命名を変更しています。

import { ValidationError } from "class-validator";
import { ItemApi } from "$cms/api/ItemApi";
import { CreateItemReq } from "$shared/types/req/cms/item/CreateItemReq";
import ClassUtils from "$shared/utils/ClassUtils";
import { isValidationErrors } from "$shared-front/utils/TypeGuardUtils";
// ・・・ 省略・・・

export const useCreateItemModal = () => {
  // ・・・ 省略・・・
  const [errors, setErrors] = useState<ValidationError[]>([]);

  const createItem = async () => {
    setLoading(true);
    try {
      // ・・・ 省略・・・

      const [nameJa, setNameJa] = useState<string>("");
      const [nameEn, setNameEn] = useState<string>("");
      const [errors, setErrors] = useState<ValidationError[]>([]);

      // サポートされている言語ではない場合は、nullを設定する
      const req: CreateItemReq = {
        nameJa: isSupportedJa ? nameJa : null,
        nameEn: isSupportedEn ? nameEn : null,
      };

      // リクエストクラスに変換する処理。
      // convertToClass()の内部では、"class-transformer"のplainToClass()や、
      // "class-validator"のvalidate()を実行している。
      const request = await ClassUtils.convertToClass(CreateItemReq, req);

      // API リクエストを実行する処理。
      // class-validator の バリデーションを通過したら、API リクエストを実行する。
      await ItemApi.create(eventKey, request);
      // ・・・ 省略・・・
    } catch (err) {
      // エラーハンドリングの処理。
      // エラーが、class-validator のバリデーションエラーの場合は、エラーをセットする。
      if (isValidationErrors(err)) {
        setErrors(err);
      } else {
        ErrorUtils.handleError(err);
        throw err;
      }
    } finally {
      setLoading(false);
    }
  };

  // ・・・ 省略・・・
};

上記の Code 内のコメントの通り、ClassUtils.convertToClass()という EventHub のユーティリティ関数で、次のような 2 つの処理を内部的に行なっています。

  1. データをクラスに変換する。("class-transformer"のplainToClass()を使っている。)
  2. バリデーションを実行する。("class-validator"のvalidate()を使っている。)

そして、このClassUtils.convertToClass()は、主にフロントエンド/バックエンドのリクエスト/レスポンス のデータをやり取りする際に使用されています。

そして、class-validator の バリデーションを通過したら、API リクエストを実行する。

もし、バリデーションでエラーが発生した場合は、catch内のエラーをセットして、UI にバリデーション結果を通知するような仕組みになります。

また、このリクエストクラスは、フロントエンドだけでなく、バックエンド(NestJS)で受け取る型としても利用されています。

import { Body, Controller } from "@nestjs/common";
// ・・・ 省略・・・

@Controller("/api/cms/item")
export class CmsItemController {
  // ・・・ 省略・・・
  @Post(`/:${ParameterKey.eventKey}`)
  async create(@EventBody() event: EHEvent, @Body() req: CreateItemReq) {
    await this.dataSource.transaction(async (em) =>
      this.service.create(em, event, req)
    );
    return {};
  }
}

このようにリクエストを検証することでデータの安全性を担保することができます。

さらに、型を共有することでフロントエンドとバックエンドの間でデータ型を統一できるため、データの整合性も保ちやすくなります。

EventHub での class-validator の活用 Tips 📝

ここからは、EventHub での class-validator の活用 Tips📝 をご紹介していきます。

条件付き検証: @ValidateIf

@ValidateIf を使うと「ある条件が成立する場合だけ」特定のバリデーションを実行することができます。

条件の判定結果がfalseの場合は、バリデーション自体がスキップされるため、エラーも発生しません。

フォーム入力や設定項目など、「条件次第で必須かどうかが変わる」ケースなどで、柔軟に使えるデコレーターになります!

条件付き検証は、ドメインロジックに依存するバリデーションを実装するのに適しています。

EventHub では、ドメインロジックに応じたバリデーション・ルールの作成に@ValidateIfはよく使われています。

例えば、多言語対応で、バリデーションメッセージを言語ごとにカスタマイズする必要があるため、次のようなバリデーションを実装しています。

  • @ValidateIfを使って、日本語イベントの時だけバリデーション・ルールを付与する。
  • @ValidateIfを使って、英語イベントの時だけバリデーション・ルールを付与する。
import { Type } from "class-transformer";
import { IsNotEmpty, IsString, ValidateIf, MaxLength } from "class-validator";

import DataValidationError from "$shared/constants/DataValidationError";
import Spec from "$shared/constants/Spec";

export default class VideoBaseReq {
  // only for ValidateIf
  @IsNotEmpty() isSupportedJa: boolean;
  @IsNotEmpty() isSupportedEn: boolean;

  // 日本語イベントの時だけ、displayNameを検証する。
  @ValidateIf((o: VideoBaseReq) => o.isSupportedJa, { always: true })
  @IsNotEmpty({
    message: DataValidationError.DATA_IS_EMPTY,
  })
  @IsString({
    message: DataValidationError.DATA_IS_INVALID,
  })
  @MaxLength(Spec.maxLength.video.displayName, {
    message: DataValidationError.DATA_IS_TOO_LONG,
    context: { constraint1: Spec.maxLength.video.displayName },
  })
  displayName: string;

  // 英語イベントの時だけ、displayNameEnを検証する。
  @ValidateIf((o: VideoBaseReq) => o.isSupportedEn, { always: true })
  @IsNotEmpty({
    message: DataValidationError.DATA_IS_EMPTY,
  })
  @IsString({
    message: DataValidationError.DATA_IS_INVALID,
  })
  @MaxLength(Spec.maxLength.video.displayName, {
    message: DataValidationError.DATA_IS_TOO_LONG,
    context: { constraint1: Spec.maxLength.video.displayName },
  })
  displayNameEn: string;
  // ・・・ 省略・・・

  constructor(arg: {
    isSupportJa: boolean;
    isSupportEn: boolean;
    displayName: string;
    displayNameEn: string;
    // ・・・ 省略・・・
  }) {
    arg = arg || {};

    this.isSupportedJa = arg.isSupportJa;
    this.isSupportedEn = arg.isSupportEn;
    this.displayName = arg.displayName;
    this.displayNameEn = arg.displayNameEn;
    // ・・・ 省略・・・
  }
}

デコレーターの重ね合わせ

デコレーターは重ね合わせて、1 つのプロパティに対して複数のバリデーションデコレーターを併用することができます。 (複数のバリデーションを実行することができます)

例えば、次のようなメールアドレスのバリデーションを考えてみます。

  • 未入力でないこと
  • 256 文字以内であること
  • ASCII 文字であること
  • メールアドレス形式であること

上記の条件をすべて満たしている場合は、メールアドレスとして有効であると判定している処理になります。

import { IsAscii, IsEmail, IsNotEmpty, MaxLength } from "class-validator";

export class MailCheckReq {
  @IsNotEmpty({
    message: "未入力です",
  })
  @MaxLength(256, {
    message: "{{constraint1}}文字までです",
    context: { constraint1: 256 },
  })
  @IsAscii({
    message: "不正な値です",
  })
  @IsEmail(
    {},
    {
      message: "不正な値です",
    }
  )
  email!: string;

  constructor(email: string) {
    this.email = email;
  }
}

class-validator のカスタムバリデーター (Custom validation classes/Custom validation decorators)

class-validator の提供する標準的なデコレーターで対応できないバリデーション・パターンの場合は、カスタムバリデーターを作成することができます。

カスタム検証には大きく分けて以下の 2 パターンがあります。

  • Custom validation classes を作成して、@Validateデコレーターから呼び出す。
  • Custom validation decorators を作成して、デコレーターとして使う。

Custom validation classes

Custom validation classes は、@ValidatorConstraintデコレーターとValidatorConstraintInterfaceのインターフェースを使用して作成します。

EventHub では、Custom validation classes を使って、様々なカスタム・バリデーションを実装しています。

次のようなVideoKeyValidatorという Custom validation classes では、videoTypeによって受け取ったvideoKeyの検証を違う方法で行っています。

import {
  isAscii,
  IsIn,
  IsNotEmpty,
  IsString,
  IsUrl,
  Validate,
  ValidationArguments,
  ValidatorConstraint,
  ValidatorConstraintInterface,
} from "class-validator";

import DataValidationError from "$shared/constants/DataValidationError";

enum VideoType {
  videoType1 = "videoType1",
  videoType2 = "videoType2",
}

/**
 * videoTypeによってvideoKeyをvalidateする。
 */
@ValidatorConstraint({ async: false })
class VideoKeyValidator implements ValidatorConstraintInterface {
  validate(videoKey: string, args: ValidationArguments) {
    const req = args.object;
    switch (req.videoType) {
      case VideoReq.videoType1: // VideoType1の場合は、URLであることを検証
        return IsUrl(videoKey);
      case VideoReq.videoType2: // VideoType2の場合は、URLではないことを検証
        return !IsUrl(videoKey) && isAscii(videoKey);
      default:
        // videoTypeが不正な場合は、バリデーションエラーとする。
        return false;
    }
  }

  defaultMessage(args: ValidationArguments) {
    const req = args.object;
    switch (req.videoType) {
      case VideoReq.videoType1:
        return DataValidationError.VIDEO_URL_IS_INVALID;
      case VideoReq.videoType2:
        return DataValidationError.VIDEO_ID_IS_INVALID;
      default:
        // videoTypeが不正な場合は、不正なデータとしてメッセージを返す。
        return DataValidationError.DATA_IS_INVALID;
    }
  }
}

Custom validation classes は、@Validateデコレーターを使って呼び出すことができます。

Custom validation decorators

Custom validation decorators は、registerDecoratorメソッドを使用して作成します。

EventHub では、Custom validation decorators を使って、様々なカスタム・バリデーションを実装しています。

例えば、ValidatePasswordという Custom validation decorators では、「password の値が必要な文字を 2 種類以上含んでいる」ことを検証するようにしています。

import { registerDecorator, ValidationOptions } from "class-validator";

import DataValidationError from "$shared/constants/DataValidationError";

/**
 * passwordの値が必要な文字を含んでいるかをvalidateする。
 * password文字列は次の4種類の内、2種類以上を含む必要がある。
 *
 * - 英小文字
 * - 英大文字
 * - 数字
 * - 記号
 */
export const ValidatePassword = (validationOptions?: ValidationOptions) => {
  return function (object: Record<string, any>, propertyName: string) {
    registerDecorator({
      name: "validatePassword",
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: string): boolean {
          if (typeof value !== "string") {
            return false;
          }

          const containsLowercase = /[a-z]/.test(value);
          const containsUppercase = /[A-Z]/.test(value);
          const containsNumber = /[0-9]/.test(value);
          // ASCIIコードの中からsymbolを順に書いた。
          const containsSymbol = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/.test(
            value
          );

          // 上のboolean変数4つのうち、trueの数を計算する。
          const numOfCharTypes = [
            containsLowercase,
            containsUppercase,
            containsNumber,
            containsSymbol,
          ].filter((e) => e).length;
          return numOfCharTypes >= 2;
        },
        defaultMessage() {
          return DataValidationError.PASSWORD_NOT_CONTAIN_EXPECTED_CHARS;
        },
      },
    });
  };
};

さいごに

今回は、EventHub で採用している技術の 1 つである class-validator についてと、その活用事例についてご紹介していきました 🐱

class-validator でフロントエンド, バックエンドの値を検証する仕組みは、プロダクトの信頼性を高めるので、これからも活用していきたいです 💪

もしこれを見て EventHub に興味をお持ちいただけたら、ぜひ以下のリンクから詳細をご覧ください!

jobs.eventhub.co.jp

note.com

参考・引用

class-validator GitHub

https://github.com/typestack/class-validator

class-validator npm

https://www.npmjs.com/package/class-validator

TypeScript Decorators

https://www.typescriptlang.org/docs/handbook/decorators.html#introduction

NestJS Validation

https://docs.nestjs.com/techniques/validation

class-transformer GitHub

https://github.com/typestack/class-transformer

class-transformer npm

https://www.npmjs.com/package/class-transformer