はじめに
こんにちは!RevCommでフロントエンドエンジニアをしている田中です。
最近、MiiTel Phone Webというプロダクトにopenapi-typescript
とRedoclyというツールを使用してOpenAPIドキュメントからTypeScriptの型定義の管理を効率化する仕組みを導入しました。それらのツールの導入背景や使い方などについて説明します。
この記事は以下のバージョンを想定して記述されています。
ツール | バージョン |
---|---|
Node.js | 20.11.0 |
openapi-typescript |
6.7.5 |
@redocly/cli |
1.10.4 |
導入の経緯について
MiiTel Phone WebではAxiosを使ってREST APIを叩いています。 今までREST APIに関する型定義は、OpenAPIドキュメントを参考に手動でTypeScriptの型を定義して運用していました。
import type { AxiosResponse } from 'axios'; import axios, { API_PATHS } from 'apis/axios'; // 以下のようなinterfaceをOpenAPIの定義を元に用意します export interface Tag { id?: string; name: string; } export interface CreateTagResponse { id: string; name: string; } // 用意したinterfaceを元に関数を定義します export const createTag = async ( tag: Tag, ): Promise<CreateTagResponse> => { // APIを叩く処理... };
このように型を定義することで、APIを呼ぶ際に誤ったパラメータを指定することを防止していました。この仕組みはうまく機能していたものの、プロダクトを開発していく中で、OpenAPIの更新に対してTypeScriptの型の更新が追いつかない箇所が生じるようになりました。 また、OpenAPIドキュメントを確認しつつ、手動でTypeScriptの型定義を定義していく作業は煩雑になりがちであり、ミスも生じやすいです。 OpenAPIの定義からTypeScriptの型を自動生成すれば、これらの課題を改善できるのではないかと思い、仕組みを入れてみることにしました。
実現したいこと
今回、仕組みを導入する上で、以下の点を重視して検討しました。
現状の実装を保ちつつ、部分的に自動生成した型を導入していきたい
先ほど紹介したように、MiiTel Phone WebにはすでにAxiosをベースにREST APIを叩く仕組みが存在します。
export const createTag = async ( tag: Tag, ): Promise<CreateTagResponse> => { // APIを叩く処理... };
新しく仕組みを導入する上で、大掛かりなリライトなどが必要になってしまうと大変です。既存の仕組みをベースにできる限り移行コストやリスクを抑えつつ、段階的に導入していけるとよさそうです。
プロダクトに必要なAPIに関するコードのみを生成したい
MiiTel Phone Webが参照しているOpenAPIドキュメントには、MiiTel Phone Web以外のプロダクトから利用されているAPIの定義も含まれています。利用していないものも含めたすべてのAPIに関する型定義を生成しようとすると、未使用の型定義が大量にできてしまいそうです。そのため、MiiTel Phone Webから利用している特定のAPIに関する型定義のみを参照できると理想的です。
以上の2点を念頭に選択肢を探ることにしました。
選択肢について
OpenAPIからTypeScriptのコードを生成するにあたっていくつか選択肢がありそうです。 検討したものをいくつか紹介します。
openapi-generator
openapi-generator
はOpenAPIドキュメントからAPIクライアントを自動生成してくれるツールです。おそらく、OpenAPIからコードを自動生成するツールとしては最も有名なのではないかと思います。
ただし、MiiTel Phone Webでの採用にあたっては、openapi-generator
の利用のためにJavaの導入が必要なことが気にかかりました。 (開発環境やCIでのセットアップなどのコストが増加してしまう)
便利なツールではあるものの、今回実現したいことに対してはややtoo muchであると感じたため、別の選択肢も探ることにしました。
openapi-typescript
openapi-typescript
はOpenAPIドキュメントからTypeScriptの型定義を自動生成してくれるnpmパッケージです。openapi-typescript
の特徴として、APIクライアントの生成はサポートせず※、TypeScriptの型定義のみを生成してくれます。openapi-generator
と比較するとかなりシンプルなツールです。(※openapi-typescript
の作者の方によりopenapi-fetchというライブラリが開発されていて、こちらのパッケージによりAPIクライアントが提供されています)
openapi-typescript
は下記の理由からとても魅力的に感じました。
- Node.jsで実行できるため、導入コストが低いこと
- 既存のAxiosを使ってAPIを叩いているコードに対して
openapi-typescript
で生成された型定義を段階的に適用していけるため、比較的低リスク・低コストでの移行が見込めること - 型定義のみを生成してくれるので取り回しがしやすく柔軟性が高い
- 型定義以外は生成されないのでバンドルサイズも増加しない
このopenapi-typescript
を活用することで、実現したいことの一つとして挙げた「できる限り低コスト・リスクで段階的に移行する」ことは実現できそうです。
しかし、現時点ではopenapi-typescript
は指定した特定のAPIに関する型定義のみを生成する仕組みが存在せず、2つ目の点に関しては実現ができなさそうです。これについては別途解決策を探ってみることにしました。
OpenAPIドキュメントを縮小する
MiiTel Phone Webが参照しているOpenAPIドキュメントは、MiiTel Phone Webで利用していないAPIに関する定義もたくさん含まれています。このOpenAPIドキュメントからMiiTel Phone Webで利用しているAPIに関する定義のみを抽出できると理想的です。これについてはRedocly CLIというツールを導入して実現することにしました。
Redocly CLIとは?
以下のようなOpenAPIに関するさまざまな機能を提供してくれる高機能なツールです。Node.jsで実装されています。
- OpenAPIドキュメントのlint
- OpenAPIドキュメントのvalidation
- 複数のOpenAPIドキュメントのバンドル
- ファイルの分割
- APIドキュメントの生成
Redocly CLIを採用した背景
Redocly CLIにはbundle
コマンド(redocly bundle
)というものがあります。このコマンドを使うことで$ref
を使って分割された複数のOpenAPIファイルを単一のファイルにまとめることができます。
また、Redocly CLIにはデコレーターという機能があります。
詳細については後ほど紹介しますが、このデコレーターを利用することでRedocly CLIがOpenAPIファイルをバンドルする際の挙動をカスタマイズすることが可能で、例えば、OpenAPIドキュメントから特定のAPIの定義などを取り除くこともできます。
そのため、Redocly CLIのbundle
コマンドとデコレーターの機能を併用することで、OpenAPIドキュメントを縮小することができそうです。
また、openapi-typescript
の次のメジャーバージョンであるv7ではこのRedoclyを採用することが検討されています。
そのため、将来的にRedoclyとopenapi-typescript
の併用がよりしやすくなることが想定されるため、そういった点も魅力的に感じてRedocly CLIを採用することにしました。
openapi-typescript
とRedocly CLIを連携させる
とはいえ、現在のopenapi-typescript
の最新メジャーバージョンであるv6では、まだRedoclyのサポートが導入されていません。
そのため、自前で簡単なスクリプトを用意してこれらのツールを連携させることにしました。以下がスクリプトのイメージです。
// @ts-check import { Buffer } from 'node:buffer'; import { exec } from 'node:child_process'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import process from 'node:process'; import { promisify } from 'node:util'; import openapiTS from 'openapi-typescript'; async function main() { // プロジェクトのルートディレクトリ const rootDir = join(dirname(new URL(import.meta.url).pathname), '../'); const tmpDir = join(rootDir, 'tmp'); const pathToOpenAPIDocument = join(tmpDir, 'openapi.json'); const pathToMinifiedOpenAPIDocument = join(tmpDir, 'openapi.min.json'); const pathToRedoclyConfig = join(rootDir, 'redocly.yaml'); const pathToGeneratedTypeDefinitions = join(rootDir, 'src/apis/types. generated.ts'); await mkdir(tmpDir, { recursive: true }); // (1) 最新のOpenAPIドキュメントの定義をダウンロード await downloadLatestOpenAPIDocument(pathToOpenAPIDocument); // (2) Redocly CLIを使用して1でダウンロードしたOpenAPIドキュメントを最小化したドキュメントを生成します await minifyOpenAPIDocument({ cwd: rootDir, output: pathToMinifiedOpenAPIJSON, config: pathToRedoclyConfig }); // (3) 2で生成されたOpenAPIに対してopenapi-typescriptを適用して、TypeScriptの型定義を生成します const document = JSON.parse(await readFile(pathToMinifiedOpenAPIDocument, { encoding: 'utf-8' })); await generateTypeDefinitions({ document, output: pathToGeneratedTypeDefinitions }); } async function minifyOpenAPIDocument({ cwd, output, config }) { const result = await promisify(exec)( `npx @redocly/cli bundle --output=${output} --config=${config} --remove-unused-components`, { cwd } ); if (result.stdout) { console.info(result.stdout); } if (result.stderr) { console.error(result.stderr); } } async function generateTypeDefinitions({ document, output }) { const generatedCode = await openapiTS(document, { commentHeader: [ '/* eslint-disable */', '// This file was automatically generated by `scripts/generate-openapi-types.mjs`.', `// Do not edit this file directly.`, ].join('\\n') }); await writeFile(output, generatedCode, { encoding: 'utf-8' }); } main().catch((error) => { console.error(error); process.exit(1); });
要点をいくつか挙げると、まずスクリプトの実行時に最新のOpenAPIドキュメントをダウンロードします。OpenAPIドキュメントはフロントエンドのリポジトリとは別に管理されているため、都度、最新の定義をダウンロードしています。
// (1) 最新のOpenAPIドキュメントの定義をダウンロード await downloadLatestOpenAPIDocument(pathToOpenAPIDocument);
次に、ダウンロードしたOpenAPIドキュメントをRedocly CLIを使って最小化します。
// (2) Redocly CLIを使用して1でダウンロードしたOpenAPIドキュメントを最小化したドキュメントを生成します await minifyOpenAPIDocument({ cwd: rootDir, output: pathToMinifiedOpenAPIJSON, config: pathToRedoclyConfig });
ここで呼ばれているminifyOpenAPIDocument
ではredocly bundle
コマンドを実行しています。重要なのが--remove-unused-component
オプションで、これによってredocly bundle
コマンドがOpenAPIドキュメントを生成する際に、デコレーターにより除外されたエンドポイントに関する定義が取り除かれます。
async function minifyOpenAPIDocument({ cwd, output, config }) { const result = await promisify(exec)( `npx @redocly/cli bundle --output=${output} --config=${config} --remove-unused-components`, { cwd } ); if (result.stdout) { console.info(result.stdout); } if (result.stderr) { console.error(result.stderr); } }
--config
オプションにはプロジェクト直下に配置しているredocly.yaml
というファイルへのパスを指定しています。このファイルにはRedocly CLIの設定が記述されており、デコレーターの設定が記述されています。具体的には、以下のようにfilter-in
デコレーターというものを指定しています。
extends: - recommended apis: rest: root: ./tmp/openapi.json # (1)でダウンロードしてきたOpenAPIドキュメントのパス decorators: filter-in: property: operationId # MiiTel Phone Webで利用するAPIに関するoperationIdのみを列挙します value: - authenticate - getMe # ... - listUsers
filter-in
デコレーターを使用することで、redocly bundle
コマンドを実行する際に、指定した条件にマッチするAPIエンドポイントのみを抽出することができます。ここではMiiTel Phone Webで利用されているAPIに関するoperationId
を指定してフィルタリングを行なっています。
最後にopenapi-typescript
を使って、(2)でRedocly CLIによって生成されたOpenAPIドキュメントをベースにTypeScriptの型定義を生成します。
// (3) 2で生成されたOpenAPIに対してopenapi-typescriptを適用して、TypeScriptの型定義を生成します const document = JSON.parse(await readFile(pathToMinifiedOpenAPIDocument, { encoding: 'utf-8' })); await generateTypeDefinitions({ document, output: pathToGeneratedTypeDefinitions });
ここで呼ばれているgenerateTypeDefinitions
は以下のように定義されていて、openapi-typescript
が提供するAPIを利用してTypeScriptの型定義を生成しています。
async function generateTypeDefinitions({ document, output }) { const generatedCode = await openapiTS(document, { commentHeader: [ '/* eslint-disable */', '// This file was automatically generated by `scripts/generate-openapi-types.mjs`.', `// Do not edit this file directly.`, ].join('\\n') }); await writeFile(output, generatedCode, { encoding: 'utf-8' }); }
ここではopenapi-typescript
をライブラリとして利用していますが、以下のようにCLIとして利用することも可能です。用途に応じて使い分けると便利だと思います。
$ npx openapi-typescript ./tmp/openapi.json -o ./apis/types.ts
openapi-typescript
が公開しているexampleを掲載しますが、以下のようなイメージで型定義が生成されます。
Axiosに型をつける
パラメータ・レスポンスの型付け
まず、今まで手で作っていたAPIの型定義は単純にopenapi-typescript
で置き換えることができそうです。
// 置き換え前のイメージ import type { AxiosResponse } from 'axios'; import axios, { API_PATHS } from 'apis/axios'; export interface Tag { id?: string; name: string; } export interface CreateTagResponse { id: string; name: string; } export const createTag = async ( tag: Tag, ): Promise<CreateTagResponse> => { // APIを叩く処理... };
例えば、上記のコードは以下のように置き換えることができます。
import type { AxiosResponse } from 'axios'; import axios, { API_PATHS } from 'apis/axios'; // openapi-typescriptによって生成された型定義を読み込みます import type { paths } from 'apis/types.generated'; type CreateTagAPI = paths['/api/tags']['post']; export type CreateTagParams = NonNullable< CreateTagAPI['requestBody'] >['content']['application/json']; export type CreateTagResponse = CreateTagAPI['responses']['200']['content']['application/json']; export const createTag = async ( params: CreateTagParams, ): Promise<CreateTagResponse> => { // APIを叩く処理... };
openapi-typescript
はpaths
という型を生成します。この型は各エンドポイントのURLをキー、そのエンドポイントに関する定義が値に設定されたinterface
です。
このはpaths
型を使うと、以下のようなイメージで特定のエンドポイントに関する型定義を取得できます。
// `POST /api/tags`に関する定義を取得 type CreateTagAPI = paths['/api/tags']['post']; // リクエストボディに関する型定義を取得 export type CreateTagParams = NonNullable< CreateTagAPI['requestBody'] >['content']['application/json']; // レスポンスボディに関する型定義を取得 export type CreateTagResponse = CreateTagAPI['responses']['200']['content']['application/json'];
あとはこれらの型を使って、関数の型定義を置き換えていきます。段階的に移行がしやすいため、開発途中から導入するケースにおいてもopenapi-typescript
は融通が利いて使いやすい印象です。
URLの型付け
先ほど紹介したように、openapi-typescript
はpaths
という型を生成します。この型をうまく活用すればURLについても型安全に指定する仕組みが用意できそうに思いました。
まずAxiosでAPIを実行する際にURLの型がきちんとチェックされるようにするため、以下のような型を用意することにしました。
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; // axiosが提供するAxiosInstanceをベースに、URLに対して型チェックが適用される型を用意します interface TypedAxiosInstance extends Pick<AxiosInstance, 'defaults' | 'interceptors' | 'request'> { // openapi-typescriptで定義された型を活用して`url`プロパティに対して型チェックが効くようにします (AllowedPathについては後述します) // eslint-disable-next-line @typescript-eslint/no-explicit-any <T = any, R = AxiosResponse<T>, D = any, URL extends string = string>( config: Omit<AxiosRequestConfig<D>, 'url'> & { url: URL extends AllowedPath ? URL : never }, ): Promise<R>; // こちらも上記と同様に、url引数に対して型チェックが効くようにします // eslint-disable-next-line @typescript-eslint/no-explicit-any <T = any, R = AxiosResponse<T>, D = any, URL extends string = string>( url: URL extends AllowedPath ? URL : never, config?: AxiosRequestConfig<D>, ): Promise<R>; }
重要なのがここで利用されているAllowedPath
型です。この型はopenapi-typescript
で生成されたpaths
のキーに合致する文字列以外はエラーとするように定義されています。
import type { paths } from 'apis/types.generated'; // `/api/users/{id}`を`/api/users/${string}`というような型へ置き換えます // 例) `/api/users/{id}`を`/api/users/${string}`のような型に変換します export type OpenAPIPathPlaceholderToTSType<T extends string> = T extends `${infer Prefix}/{${string}}${infer Next}` ? `${Prefix}/${string}${OpenAPIPathPlaceholderToTSType<Next>}` : T; export type AllowedPath = OpenAPIPathPlaceholderToTSType<`${string}${keyof paths}`>;
Axiosのインスタンスを生成する際に先ほどのTypedAxiosInstance
を利用します。
const axios = Axios.default.create(axiosConfig) as TypedAxiosInstance;
これによりAxiosによりAPIを実行する際に、パスがOpenAPIで定義されたものであるかどうかを自動でチェックしてくれます。
axios(`/api/users/${userId}/profile` as const); // => OK axios(`/api/no_such_endpoint` as const); // => 型エラー!!😊
ただこれには少し制限があって、例えばOpenAPIに/api/users/{id}
と/api/users/{id}/profile
というAPIが定義されていた場合に、以下のようなケースで意図せずして型チェックが通ってしまう問題がありました...
import { expectTypeOf } from 'expect-type'; expectTypeOf('/api/users/123' as const).toMatchTypeOf<AllowedPath>(); // => OK (意図どおり) expectTypeOf('/api/users/123/profile' as const).toMatchTypeOf<AllowedPath>(); // => OK (意図どおり) expectTypeOf('/api/users/123/no_such_endpoint' as const).not.toMatchTypeOf<AllowedPath>(); // => NG (OpenAPIで未定義のパスにも関わらず、意図せずして型チェックが通ってしまう...)
これはOpenAPIPathPlaceholderToTSType<'/api/users/{id}'>
が /api/users/${string}
として解釈されることが原因です。課題はあるものの、大抵のケースではうまくワークするはずなので、ひとまず妥協することにしました…
URLの型定義を改善する
先ほどの課題はAllowedPath
を以下のような型定義に変えると解決できることがわかりました。
type WithoutSlash<T extends string> = T extends `${string}/${string}` ? never : T; type OpenAPIPathPlaceholderToTSType< T extends string, Param extends string, > = T extends `${infer Prefix}{${string}}${infer Next}` ? `${Prefix}${WithoutSlash<Param>}${OpenAPIPathPlaceholderToTSType<Next, Param>}` : T; export type AllowedPath<Param extends string> = OpenAPIPathPlaceholderToTSType<`${string}${keyof paths}`, Param>;
そして、TypedAxiosInstance
の型も以下のように変更します。新しく導入されたWithoutSlash
型と以下のAllowedPath
の型パラメータに指定している点が重要で、これらを組み合わせることにより意図した通りに型の推論が効くようになりました!
interface TypedAxiosInstance extends Pick<AxiosInstance, 'defaults' | 'interceptors' | 'request'> { // eslint-disable-next-line @typescript-eslint/no-explicit-any <T = any, R = AxiosResponse<T>, D = any, URL extends string = string>( config: Omit<AxiosRequestConfig<D>, 'url'> & { url: URL extends AllowedPath<infer _> ? URL : never }, ): Promise<R>; // eslint-disable-next-line @typescript-eslint/no-explicit-any <T = any, R = AxiosResponse<T>, D = any, URL extends string = string>( url: URL extends AllowedPath<infer _> ? URL : never, config?: AxiosRequestConfig<D>, ): Promise<R>; }
TypeScriptはとても柔軟で驚きました。 これにより、先ほど意図せずして型チェックが通ってしまっていたケースも解消することができました。
import { expectTypeOf } from 'expect-type'; expectTypeOf('/api/users/123' as const).toMatchTypeOf<AllowedPath>(); // => OK (意図どおり) expectTypeOf('/api/users/123/profile' as const).toMatchTypeOf<AllowedPath>(); // => OK (意図どおり) expectTypeOf('/api/users/123/no_such_endpoint' as const).not.toMatchTypeOf<AllowedPath>(); // => OK (ちゃんと型エラーが発生してくれる)
ちなみにここでは型のテストにexpect-typeというライブラリを利用しています。Vitestではこのexpect-type
が初めから組み込まれており、自前でユーティリティタイプや複雑な型定義を実装する必要が出てきた際などの型定義のテストで活用すると便利だと思います。
今後について
まだ仕組みを導入し始めたばかりなので、いくつか課題などが残っています。
openapi-typescript
で生成された型を元に、型生成の効率化やURLに対する型チェックなどができるようになったので、今後はURLから適用すべきパラメータやレスポンスの型なども自動で推論する仕組みなどを用意できるとさらによさそうです。
また、openapi-typescript
のv7がリリースされるとRedoclyのサポートが入る予定なので、もしかしたらRedocly CLIを使ってOpenAPIドキュメントを縮小する手順などをより簡略化できるのではないかと思っています。
おわりに
この記事ではopenapi-typescript
やRedoclyなどを活用した仕組みの導入について解説いたしました。もし今後、OpenAPIやSwaggerのドキュメントからTypeScriptコードを生成したい場合に参考になりましたら幸いです。