🦁

NestJSでのGenericDTO定義とOpenAPIドキュメントでの出力を実装する

2024/03/12に公開

はじめに

今回は、NestJSでのGenericDTO定義とOpenAPI(Swagger)ドキュメントでの出力についてまとめたいと思います。
外部サービスへAPIを提供する開発の中で、レスポンスボディを「正常/エラー」共に共通化する必要があり、さらにOpenAPIドキュメントにも反映させる必要があったため、調査して内容のまとめになります。
(2024年03月12日時点)では、NestJSでのGenericDTOとOpenAPIの組み合わせで実装した日本語の情報は少ない印象です。これから開発される方の参考になれば幸いです。

GenericDTOとは?

GenericDTOは、日本語訳すると汎用DTO(Data Transfer Object)になります。

今回紹介するのは、すべてのAPIエンドポイントにおけるレスポンスを「正常/エラー」共に共通化して下記のようなデータ形式でレスポンスするためのDTO定義方法となります。

{
    "success": true,
    "date": "2024-03-12T09:31:14.548Z",
    "data": {
        "message": "Hello World!"
    },
    "error": null
}

例えば、Twitter APIやGitHub APIなどのAPIは、"data","meta","url"など各APIでプロパティが揃っております。

実装ガイド

では実際にどのように実装していくのか、ポイントを絞って解説してきます。
サンプルコードをGitHubへアップしたのでそちらも合わせてご参考にしていただければと思います。
r-knm/nestjs-generic-dto-sample

共通レスポンス用DTOクラスを定義する

src/common-response.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsDate, IsNotEmpty } from 'class-validator';

export class CommonResponseDto<TData> {
  @ApiProperty({
    description: '実行結果',
    example: true,
  })
  @IsBoolean()
  @IsNotEmpty()
  success: boolean;

  @ApiProperty({
    description: 'レスポンス送信日時',
    example: '2024-03-12T07:45:29.583Z',
    type: Date,
  })
  @IsDate()
  date: Date;

  @ApiProperty({
    description: 'データ',
  })
  @IsNotEmpty()
  data: TData | TData[];

  @ApiProperty({
    description: 'エラー内容',
    example: [],
  })
  @IsArray()
  @IsNotEmpty()
  error: string;
}

OpenAPIドキュメントへ出力するため、@nestjs/swaggerからApiPropertyをインポートし、各プロパティへデコレーターとしてセットしていきます。
ここで定義するプロパティがすべてのAPIエンドポイントにおけるレスポンスを「正常/エラー」共に共通化する部分になります。

AllExceptionFilterクラスを定義する

src/all-exceptions.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { CommonResponseDto } from './common-response.dto';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    // In certain situations `httpAdapter` might not be available in the
    // constructor method, thus we should resolve it here.
    const { httpAdapter } = this.httpAdapterHost;

    const ctx = host.switchToHttp();

    const httpStatus =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    let error: string;
    if (exception instanceof HttpException) {
      error = exception.message;
    } else {
      error = exception.toString();
    }

    const responseBody: CommonResponseDto<null> = {
      success: false,
      date: new Date(),
      data: null,
      error,
    };

    httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
  }
}

responseBodyへ、共通レスポンスとして返却するプロパティの値をセットします。

なお、AllExceptionFilterは、NestJSの例外ハンドリング用の仕組みになります。
詳しくは公式に記載があるため、そちらをご参考にしてください。
公式Doc: ExceptionFilters

共通レスポンス用デコレーターを定義する

src/api-common-ok-response.decorator.ts
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
import { CommonResponseDto } from './common-response.dto';

export const ApiCommonOkResponse = <TModel extends Type<unknown>>(
  model: TModel,
  type: 'array' | 'object',
) => {
  return applyDecorators(
    ApiExtraModels(CommonResponseDto, model),
    ApiOkResponse({
      schema: {
        title: `ApiCommonResponseOf${model.name}`,
        allOf: [
          { $ref: getSchemaPath(CommonResponseDto) },
          {
            properties: {
              data:
                type === 'array'
                  ? {
                      type: 'array',
                      items: { $ref: getSchemaPath(model) },
                    }
                  : {
                      type: 'object',
                      $ref: getSchemaPath(model),
                    },
            },
          },
        ],
      },
    }),
  );
};

GenericDTOを使用せずにOpenAPIドキュメントを出力する通常のケースでは、@nestjs/swaggerからApiOkResponseをインポートして、コントローラークラスの各メソッドへデコレーターとしてレスポンス型を定義していきます。
一方で、GenericDTOを使用した上でOpenAPIドキュメントを出力する今回のケースでは、共通レスポンス用のデコレーターを定義し、コントローラークラスの各メソッドへデコレーターとしてセットしてく実装となります。
※コントローラークラスでの各メソッドでのデコレーター定義については後述します。

なお、NestJSのOpenAPIドキュメント関連の定義は下記のドキュメントにまとまっているのでご参考にしてください。
(公式Doc)OpenAPI > Operations

ApiCommonOkResponseは200レスポンス用に定義したデコレーターになります。201用、204用などステータスコードごとにデコレーターを追加定義し、各ステータスコードでのレスポンスへ対応することが可能です。

コントローラクラスで共通レスポンス用DTOクラスのデコレーターをセットする

src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ApiOperation } from '@nestjs/swagger';
import { ApiCommonOkResponse } from './api-common-ok-response.decorator';
import { GetHelloResponse } from './get-hello.dto';
import { CommonResponseDto } from './common-response.dto';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @ApiOperation({
    summary: 'あいさつ取得',
    description: 'あいさつを取得する。',
  })
  @ApiCommonOkResponse(GetHelloResponse, 'object')
  getHello(): CommonResponseDto<GetHelloResponse> {
    return this.appService.getHello();
  }

  @Get('error')
  @ApiOperation({
    summary: '(強制エラー)',
    description: '強制的にエラーを出力する。',
  })
  getError(): void {
    return this.appService.getError();
  }
}

getHello関数にて@ApiCommonOkResponse(GetHelloResponse, 'object')というデコレーターがセットします。
第一引数にはAPIのレスポンスDTOクラス、第二引数にはレスポンスデータのデータ型をセットします。(サンプルでは、第二引数は'array'か'object'のみを扱うようにしておりますが、他データ型への対応も可能です)

「実装ガイド」は以上になります。実際にサンプルコードを見るとより理解が深まると思います。ぜひそちらも合わせてご参考にしていただければと思います。
r-knm/nestjs-generic-dto-sample

おわりに

今回は、「NestJSでのGenericDTO定義」と「OpenAPIドキュメントでの出力」について実装方法をまとめました。
社内用APIなどでは、レスポンスの共通化をするのは多少 too much な印象がありますが、外部へ公開するAPIではレスポンス型の共通化をすることで、メターデータの追加やエラーメッセージの追加などAPI開発といして1段上の設計ができるようになると考えております。
NestJSではコードからOpenAPIドキュメントを自動生成できる便利な機能が備わっているので、今回のGenericDTO定義を合わせて使うことがより一層その機能が引き出せると感じております。

参考記事

株式会社log build

Discussion