RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

openapi-typescriptとRedocly CLIを活用してOpenAPIドキュメントと型定義の同期を効率化する

はじめに

こんにちは!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

github.com

openapi-generatorはOpenAPIドキュメントからAPIクライアントを自動生成してくれるツールです。おそらく、OpenAPIからコードを自動生成するツールとしては最も有名なのではないかと思います。

ただし、MiiTel Phone Webでの採用にあたっては、openapi-generatorの利用のためにJavaの導入が必要なことが気にかかりました。 (開発環境やCIでのセットアップなどのコストが増加してしまう)

便利なツールではあるものの、今回実現したいことに対してはややtoo muchであると感じたため、別の選択肢も探ることにしました。

openapi-typescript

github.com

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ファイルを単一のファイルにまとめることができます。

github.com

note.com

また、Redocly CLIにはデコレーターという機能があります。

github.com

詳細については後ほど紹介しますが、このデコレーターを利用することでRedocly CLIがOpenAPIファイルをバンドルする際の挙動をカスタマイズすることが可能で、例えば、OpenAPIドキュメントから特定のAPIの定義などを取り除くこともできます。

そのため、Redocly CLIのbundleコマンドとデコレーターの機能を併用することで、OpenAPIドキュメントを縮小することができそうです。

また、openapi-typescriptの次のメジャーバージョンであるv7ではこのRedoclyを採用することが検討されています。

github.com

そのため、将来的に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を掲載しますが、以下のようなイメージで型定義が生成されます。

github.com

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-typescriptpathsという型を生成します。この型は各エンドポイントのURLをキー、そのエンドポイントに関する定義が値に設定されたinterfaceです。

github.com

このは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-typescriptpathsという型を生成します。この型をうまく活用すれば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コードを生成したい場合に参考になりましたら幸いです。