LoginSignup
17
10

More than 3 years have passed since last update.

NestJS + OpenAPI(Swagger)からAPIクライアントを生成し型安全に通信する

Last updated at Posted at 2020-12-13

こちらは、TypeScript Advent Calendar 2020 14日目の記事になります!

はじめに

最近、個人での開発にてTypeScriptを選定することが多くなり、その中で NestJS を使ってバックエンドのAPIを構築する機会があったのですが、
フロントエンド(Nuxt.jsで作ったSPA)との通信について、割といい感じに作れた気がしたので、備忘録として残したいと思います✍️

どんなものを作るか

Webアプリのフロントエンドとバックエンド間での通信として、以下のことを実現したいという気持ちがありました

  • リクエスト/レスポンスに型付け したい💪
    • フロント/バックエンド双方のTSで、InterfaceなりClassなりに値が詰められたDTOを扱いたい
    • バックエンド側で定義した型をフロント側でも使いたい
      • I/Fの更新をフロント側からいい感じに取り込みたい
  • フロントのコード上でAPIであることを意識したくない 🪨
    • パスとかHTTPメソッドとかは隠蔽して、TS上では関数呼び出しで使いたい
    • ただし、Cookieによるセッション管理や環境毎にリクエスト先のドメインを分けるなど、必要があればHTTP上の挙動をカスタマイズしたい
  • バリデーションや業務エラーを返却 したい🐟
    • 項目毎のバリデーションをしたい
      • フロントでもよしなにやるが、特にバックエンド側で期待しないリクエストを受けたらエラーとしたい
    • 業務エラーをいい感じに返却したい
      • IDに紐づくオブジェクトがDBから取れなかった...とか
      • 1回のリクエストに対してN個の業務エラーを返したい
        • 返された業務エラーをフロント側で識別し、処理(エラー表示、無視など)をおこないたい
    • ネットワークエラー等は例外でコントロールしたい

上記の実現にあたりNestJSの公式ドキュメントをみていくと、 OpenAPI の章の記述が役立ちそうですが、
やってくれるのはOpenAPIの定義を生成してくれるところまでなので、そこからAPIクライアントの作成と、フロントエンド側からの読み込みをどのように行うか検討しました

なお、本アドベントカレンダーでも型安全な通信の方法として GraphQLを使ったパターン を書かれている方がいますので、併せてご覧いただくと比較ができるかもしれません
(私は、開発時にGraphQLに対する知見がなかったのと、作りたいものに対してオーバースペック気味に感じたため今回は採用を見送りました)

作ったもの

ソースコードは以下になります🍔
サンプルになりそうな部分のみ抜き出しています
https://github.com/yktakaha4/nestjs-typed-client

/backend にNestJSで作ったバックエンド、 /frontend にReactで作ったフロントエンドのサンプルアプリケーションを格納しています
実際に作ったものでは別々のリポジトリにて管理していますが、今回は簡便のためひとつにまとめました

クライアントの生成からフロントエンドの取り込みを図にしたものは以下になります🦑
グレーの点線がソースコードの関連、オレンジの点線が実際の通信の関連のイメージです

archtecture.png

流れはこんな感じになります
なお、今回は 2.で生成するクライアントに typescript-axios を選択しましたが、 こちら に記載されているものから任意に選択可能です

  1. NestJSにて @nestjs/swagger にてアノテートしたDTOとエンドポイントを定義し、OpenAPIのyamlファイルを生成する
  2. @openapitools/openapi-generator-cli にて、1.で作成したyamlファイルからTypeScriptのAPIクライアントを生成する
  3. GitHub Actionsを用いて、tagがpushされた(≒Releaseを作成した)時、APIクライアントをtscにてビルドし GitHub Packages Registry に登録する
  4. フロントエンドから npm install @xxxxx/sample-backend-client の形で読み込んで利用する

アノテートしたDTOとエンドポイントの定義

バックエンド側で以下のようにリクエスト、レスポンスを定義します
長くなるので一部のみ抜粋しますが、エラークラスはこちらのように定義しています

エラークラスの定義(バックエンド)
export class EmptyNameError {
  // 空の名前を示すエラーオブジェクト

  @ApiProperty({ enum: EmptyNameErrorType })
  readonly type = EmptyNameErrorType.Value;

  @ApiProperty({ enum: GreetParameter })
  readonly parameter: GreetParameter;

  constructor(parameter: GreetParameter) {
    this.parameter = parameter;
  }
}

export class VulgarNameError {
  // 下品な名前を示すエラーオブジェクト

  @ApiProperty({ enum: VulgarNameErrorType })
  readonly type = VulgarNameErrorType.Value;

  @ApiProperty({ enum: GreetParameter })
  readonly parameter: GreetParameter;

  @ApiProperty()
  readonly vulgarWord: string;

  constructor(parameter: GreetParameter, vulgarWord: string) {
    this.parameter = parameter;
    this.vulgarWord = vulgarWord;
  }
}

export type GreetErrors = Array<EmptyNameError | VulgarNameError>;
クラスへのアノテーション(バックエンド)
export class GreetRequest {
  @ApiProperty()
  readonly lastName: string;

  @ApiProperty()
  readonly firstName: string;

  constructor(lastName: string, firstName: string) {
    this.lastName = lastName;
    this.firstName = firstName;
  }
}

@ApiExtraModels(EmptyNameError, VulgarNameError)
export class GreetResponse {
  @ApiProperty({ type: String, nullable: true })
  readonly message: string | null;

  @ApiProperty({
    type: 'array',
    items: { oneOf: greetErrorSchemaPaths },
  })
  readonly errors: GreetErrors;

  constructor(message: string | null, errors: GreetErrors) {
    this.message = message;
    this.errors = errors;
  }
}

エンドポイントのサンプルです
@ApiOkResponse によってアノテートしたDTOを指定します。 @ApiOperation でクライアントに生成されるメソッド名を指定できます

エンドポイント(バックエンド)
@ApiTags('App')
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @ApiOkResponse({ type: GreetResponse })
  @ApiOperation({ operationId: 'greet' })
  @Post('greet')
  @HttpCode(200)
  greet(@Body() request: GreetRequest): GreetResponse {
    // 入力チェック
    const errors = this.appService.validateGreetRequest(request);

    let message: string | null = null;
    if (errors.length === 0) {
      // エラーなしの場合のみメッセージを取得
      message = this.appService.getGreetingMessage(request);
    }

    return new GreetResponse(message, errors);
  }
}

NestJSのコードからyamlファイルを生成する方法はこちら でも説明しています
コマンドは以下

$ cd ./backend
$ npm run openapi:export

yamlファイルからクライアント生成

バックエンドのコードとパッケージを分けるため、 ./backend/client 配下にnpmパッケージ用の一式を格納しています

$ cd ./backend/client
$ npm ci
$ npm run generate
$ npm run build

# run generate時に実行されるコマンド
# オプション等は以下参照
# https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/typescript-axios.md
# JAVA_OPTS="-Dlog.level=warn" openapi-generator generate -i sample-backend.yml -o lib/ -g typescript-axios --api-package=api --model-package=models --additional-properties=supportsES6=true,withSeparateModelsAndApi=true

npmパッケージ公開

GitHub Actionsにて、 client.* という形式のタグが打たれたら以下ワークフローを実行するようにします
npm publish時に、生成したパッケージのバージョン名を一意にする必要があるので、今回はこちらのスクリプトを使って実現しています
今回は、クライアント側で読み込んでいるバージョンの一意性を保証できれば十分だったので、セマンティックバージョニングは考慮せずに、 0.0.1-${タグ名} の形式で公開するようにしました

ちなみに、トークンについては、GitHub Actionsがデフォルトで指定してくれるもので問題ありません
(プライベートリポジトリの場合、パッケージの読み込みを行う側でパッケージの読み込み権限のあるアクセストークンの発行が必要になります)

publish-client.yml
name: Publish client

on:
  push:
    tags:
      - client.*

jobs:
  publish:
    name: Publish client
    runs-on: ubuntu-18.04
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v2

      - uses: actions/setup-node@v1
        with:
          node-version: 12.x
          registry-url: https://npm.pkg.github.com/
          scope: "@yktakaha4"

      - run: npm ci
        working-directory: ./backend

      - run: npm run openapi:export
        working-directory: ./backend

      - run: npm ci
        working-directory: ./backend/client

      - run: npm run version $GH_REF
        working-directory: ./backend/client
        env:
          GH_REF: ${{ github.ref }}

      - run: npm publish
        working-directory: ./backend/client
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

上記に基づいて生成されたnpmパッケージは、こちら にて公開されます

Demo

以下の方法で起動できます
バックエンドは localhost:3100、フロントエンドは localhost:3000 で起動します

ターミナル
# バックエンドを起動
$ cd ./backend
$ npm ci
$ npm run start

# フロントエンドを起動(別ターミナルにて)
$ cd ./frontend
$ yarn install
$ yarn start

テキストボックスに苗字、名前を入力して Greet ボタンを押すと、挨拶が返却されます👋

名前を入力

リクエストとレスポンスはそれぞれこんな感じのものが送受されます

Request
{
  "lastName": "yuuki",
  "firstName": "takahashi"
}
Response
{
  "message": "Hello, Yuuki TAKAHASHI !",
  "errors": []
}

フロントエンドからのAPI呼び出しはこんな感じになります

呼び出しサンプル(フロントエンド)
// AppApiクラスは npmパッケージからインポートしたもの(自動生成)
const api = new AppApi();

api.greet(greetingRequest).then((axiosResponse) => {
  dispatchResponse(axiosResponse.data);
}).catch((error) => {
  dispatch({ type: 'UPDATE_MESSAGE', value: String(error) });
});

インテリセンスがちゃんと効いてます

インテリセンス

入力エラー(値が空 or 下品な言葉が入力されている)をサーバサイドで検証し、エラーの場合はフロントエンドにてメッセージを編集します

入力エラー

Response
{
  "message": null,
  "errors": [
    {
      "type": "EmptyNameError",
      "parameter": "LastName"
    },
    {
      "type": "VulgarNameError",
      "parameter": "FirstName",
      "vulgarWord": "shit"
    }
  ]
}

エラーメッセージ編集のサンプルは以下になります
このコードだけではわかりづらいですが、 typeparameter も型付けされており、また 型ガード の効果により VulgarNameError の判定文の中では vulgarWord が参照できています🌿

エラーメッセージ設定サンプル(フロントエンド)
const dispatchResponse = (response: GreetResponse) => {
  if (response.message) {
    // メッセージが返却された場合はそれを設定
    dispatch({ type: 'UPDATE_MESSAGE', value: response.message });
  } else {
    // されなかった場合は、エラーオブジェクトに合わせて設定
    const messages: Array<string> = [];
    for (const error of response.errors) {
      if (error.type === 'EmptyNameError') {
        if (error.parameter === 'LastName') {
          messages.push('・名前は空にできません');
        } else if (error.parameter === 'FirstName') {
          messages.push('・苗字は空にできません');
        }
      } else if (error.type === 'VulgarNameError') {
        if (error.parameter === 'LastName') {
          messages.push(`・名前に ${error.vulgarWord} は設定できません`);
        } else if (error.parameter === 'FirstName') {
          messages.push(`・苗字に ${error.vulgarWord} は設定できません`);
        }
      }
    }

    dispatch({ type: 'UPDATE_MESSAGE', value: messages.join('') });
  }
};

一つ残念なのは、現時点でのOpenAPIv3ではパラメータ上でリテラル値を表現できないため、(こちらのConstant Parametersの項のように、単一のメンバを持つEnumとして定義する必要あり
上記の型ガードを実現するにあたり、 type'EmptyNameError' | 'VulgarNameError' のようなstringのUnion型では定義できず、自動生成されるコードは EmptyNameErrorTypeEnum | VulgarNameErrorTypeEnum のような形になるため、インテリセンスを効かせることができずにいます
値の検証は正しくできるため、これでも問題はないですが...
(私の理解不足なだけで、いいやり方ができる気はものすごくします🙃)

あと、今回は本旨から外れるため割愛していますが、項目毎のバリデーションについてはこちらを使うといい感じにできるので、併せて導入をオススメします

まとめ

ひとついい実装パターンが作れた気がするので、他のやり方(先ほど説明したGraphQLや、frourioなんかもそうなんでしょうか?)についても挑戦していきたいと思います!

17
10
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
17
10