こちらは、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で作ったフロントエンドのサンプルアプリケーションを格納しています
実際に作ったものでは別々のリポジトリにて管理していますが、今回は簡便のためひとつにまとめました
クライアントの生成からフロントエンドの取り込みを図にしたものは以下になります🦑
グレーの点線がソースコードの関連、オレンジの点線が実際の通信の関連のイメージです
流れはこんな感じになります
なお、今回は 2.で生成するクライアントに typescript-axios
を選択しましたが、 こちら に記載されているものから任意に選択可能です
- NestJSにて @nestjs/swagger にてアノテートしたDTOとエンドポイントを定義し、OpenAPIのyamlファイルを生成する
- @openapitools/openapi-generator-cli にて、1.で作成したyamlファイルからTypeScriptのAPIクライアントを生成する
- GitHub Actionsを用いて、tagがpushされた(≒Releaseを作成した)時、APIクライアントをtscにてビルドし
GitHub Packages Registry
に登録する - フロントエンドから
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がデフォルトで指定してくれるもので問題ありません
(プライベートリポジトリの場合、パッケージの読み込みを行う側でパッケージの読み込み権限のあるアクセストークンの発行が必要になります)
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
ボタンを押すと、挨拶が返却されます👋
リクエストとレスポンスはそれぞれこんな感じのものが送受されます
{
"lastName": "yuuki",
"firstName": "takahashi"
}
{
"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 下品な言葉が入力されている)をサーバサイドで検証し、エラーの場合はフロントエンドにてメッセージを編集します
{
"message": null,
"errors": [
{
"type": "EmptyNameError",
"parameter": "LastName"
},
{
"type": "VulgarNameError",
"parameter": "FirstName",
"vulgarWord": "shit"
}
]
}
エラーメッセージ編集のサンプルは以下になります
このコードだけではわかりづらいですが、 type
も parameter
も型付けされており、また 型ガード の効果により 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なんかもそうなんでしょうか?)についても挑戦していきたいと思います!