🕌

【Python / React】 モノレポ構成 プロジェクトでの OpenAPI Generator 奮闘記

に公開

はじめに

Recustomer 株式会社 でエンジニアをしている AN と申します。
フロントエンドがメインですが、バックエンドも書きます!

この記事では、バックエンド(Python)とフロントエンド(React/TypeScript)を持つモノリポ構成のプロジェクトで、OpenAPI Generator を活用した API クライアント自動生成を導入・運用してきた、 約 1 年間の「知見と奮闘」 をお届けします。

背景と導入の動機

私たちのプロジェクトでは、もともとスキーマ駆動開発を導入していました。スキーマ駆動開発とは、バックエンドとフロントエンド間の API の仕様を OpenAPI の YAML(または JSON) 形式で管理し、双方がその仕様に基づいて実装を進めるアプローチです。フロントエンドはその仕様書をもとに TypeScript の型定義ファイルを作成していました。こちらを導入したきっかけは将来的にフロントエンド専門のエンジニアでも、バックエンドの API 仕様を詳細に意識せずに開発できるようにする為でした。

当時の構成イメージ

# backendで、OpenAPIのYAML形式で、API仕様を定義
backend/
  └── schemas/
      ├── user.yaml
      ├── store.yaml
      └── ...

# スキーマから手動で TypeScript の型を定義
frontend/
  └── types/
      ├── user.d.ts
      ├── store.d.ts
      └── ...

こちらのアプローチには以下のメリットがありました。

  • スキーマ定義からモックを生成して、FE が BE 実装を待たずして開発を進められた
  • API 仕様が明文化され、BE / FE 間での認識の相違が減った

スキーマ駆動開発の限界

しかし、プロジェクトが成長するにつれて、いくつかの運用上の課題が見えてきました!

課題 詳細
スキーマ定義の手間 OpenAPI スキーマを YAML 形式で手動定義する作業自体が大きな手間であった
スキーマ定義とバックエンド実装の不整合 OpenAPI の定義を更新するのはいいが、バックエンドがその定義と整合性が合わなくてエラーになる
フロントエンドの同期漏れ スキーマは更新したけど FE の型定義を更新し忘れたり、誤った型を定義していたというケースが発生

特に限界を感じたのは、スキーマ定義とバックエンド実装の不整合でした。開発初期には、何が原因でエラーが発生しているのか特定が困難な状況でした。

OpenAPI Generator への移行を決断

そこで、以下の方針で OpenAPI Generator への移行を決断しました。

【移行前】

課題: BE 不整合によるエラー発生、FE 同期漏れ

【移行後】

移行のポイント

弊社のバックエンドでは、Python のフレームワークである Django Ninja と FastAPI を採用しています。 両者とも、実装コードから OpenAPI 標準の仕様書(openapi.json)を自動生成する機能を標準で備えています。この特性を活かし、以下のフローを構築しました。

  1. BE の実装を唯一の情報源に
    Django Ninja / FastAPI が自動生成する OpenAPI スキーマを正とします。
  2. FE の型定義とクライアントを自動生成
    生成された openapi.json から、OpenAPI Generator を用いて TypeScript のコード(型定義・API クライアント)を自動生成します。
  3. 手書きの API クライアントを廃止:
    axios を用いた手動での API クライアントの実装をやめ、自動生成されたコードをラップして使用する運用に変更しました。
    これにより、「BE の実装を変更 → スキーマが自動更新 → FE のコードを自動生成」という一方向のフローが確立され、同期漏れのリスクを大幅に削減できると考えました。

得られた効果

OpenAPI Generator を採用することで、想定通り以下のリワードを獲得できました!
これらにより劇的に開発効率が向上しました!

  • バックエンドの OpenAPI スキーマから TypeScript の型定義を自動生成
  • API クライアントコードの自動生成による開発効率の向上
  • 型安全な API 呼び出しの実現とバリデーションエラーの減少

OpenAPI Generator の課題

もちろん良いことばかりではなく、運用していく中で課題も見つかりました。

課題 1 - Enum の名前重複による上書き問題

特に困ったのが、Enum の名前重複による上書き問題です。 Python 側では別々のクラスとして定義していても、クラス名が同じだと自動生成された TypeScript 上で競合し、意図しない定義で上書きされてしまう事象が発生しました。

class SampleStatus(StrEnum):
  ACTIVE = "ACTIVE"
  DISABLE = "DISABLE"

class SampleStatus(IntEnum):
  ACTIVE = 1
  DISABLE = 2

生成された TypeScript(期待した定義が消え、IntEnum 側で上書きされる)

// 打ち消される前
export const SampleStatus = {
  ACTIVE: "ACTIVE",
  DISABLE: "DISABLE",
} as const;
export type SampleStatus = (typeof SampleStatus)[keyof typeof SampleStatus];

// 打ち消された後
export const SampleStatus = {
  NUMBER_1: 1,
  NUMBER_2: 2,
} as const;
export type SampleStatus = (typeof SampleStatus)[keyof typeof SampleStatus];

このように、型安全を求めて導入したはずが、サイレントに型定義が上書きされてしまうという、非常に危険な状態に陥りました。この問題を解決するため、命名規則の見直しや設定の調整が必要になりました。

加えて、IntEnum の値が NUMBER_X のような、意図しない型名に変換されてしまう問題も発生しました。そのため、エンドポイントの最終的なレスポンススキーマにおいては StrEnum を使用する方針としました。

課題 2 - フロントエンドコードの自動生成の同期漏れ

OpenAPI Generator を導入したものの、課題として残っていたのが、フロントエンドの自動生成を実行するプロセスが手動であったことです。

やはり人間が仲介する以上、「BE のコードは変えたけど、自動生成コマンドの実行を忘れてしまう」というヒューマンエラーの問題が残りました。これでは、せっかく自動化を導入したのに、手動による同期漏れのリスクが再発してしまいます。

そこで私たちは、このヒューマンエラーを排除するため、「バックエンドのスキーマが変更されたことを検知し、フロントエンドのコード生成までを自動で実行する仕組み」を導入しました。

課題 3 - レスポンス型が any になる問題

FastAPI での OpenAPI 自動生成で、レスポンスの型が any になってしまう問題が発生しました。

@router.post(
    "/hoge",
    responses={200: {"description": "成功"}, 400: {"description": "失敗"}},
    operation_id="hoge",
)
def hoge(
    request: Request,
) -> Response:
    return Response
// 期待した型
async hoge(request: Request): Promise<void>

// 実際に生成された型
async hoge(request: Request): Promise<any>

バックエンド側で、response_class を定義することでこの問題を解決しました!

@router.post(
    "/hoge",
    responses={200: {"description": "成功"}, 400: {"description": "失敗"}},
    operation_id="hoge",
    response_class=Response,  # 追記
)
def hoge(
    request: Request,
) -> Response:
    return Response

既存コードから OpenAPI Generator のコードへの置き換え

ここからは、実際に既存のコードベースで実装された数百のエンドポイントを、OpenAPI Generator で自動生成したコードに置き換えるメイン作業の手順についてご説明します。

まず、置き換え作業を開始するにあたり、チームで大まかな手順を決めました。

・置き換え作業の手順と戦略
実際の置き換え手順としては、以下の戦略を採用しました。

エンドポイントの洗い出しと優先順位付け: 数百あるエンドポイントの洗い出しから始めました。弊社ではマイクロサービスを採用しているため、サービスごとに、エンドポイントの数が少ないサービスから順に置き換えることにしました。

タスクの割り当て: 既存の fetch 用の関数を Notion に一覧としてまとめ、各メンバーに担当を割り当てて進めました。

・Result 型の導入(エラーハンドリングの改善)
また、この置き換え作業と並行して、フロントエンドに Result 型を導入しました。

Result 型とは、処理の結果を「成功(Ok)」または「失敗(Err)」のいずれかの状態として明示的に表現する型です。これにより、エラーハンドリングを強制し、未処理のエラーをなくすことができます。
なので、通常の try ~ catch のエラーハンドリングから Result 型のエラーハンドリングをこの置き換えタイミングと同時に実施することにしました。

・実際の作業内容フロー
最終的に、以下の流れで置き換え作業を実施しました。

置き換え対象の fetch 関数のリスト化: 既存の fetch 関数を特定して、notion にリストを作成する。

fetch 関数の置き換え: 既存の fetch 関数を、OpenAPI の自動生成のコードをラップした関数に置き換える。

エラーハンドリングの修正: Result 型に基づき、エラーハンドリングを修正する。

型の整合性確認: lint チェックを行いエラーになる場合は、型の変換を実施する。

実際の置き換え風景

fetch 関数の置き換え

// axiosのclentを使用した fetch 関数
export const getLabels = async (): Promise<Label[]> => {
  const data = await apiClient.get(apiLabels).then((res) => {
    if (res.status !== HttpStatusCode.Ok) throw Error;
    return res.data;
  });
  return data;
};

// OpenAPI Generator で自動生成したコードをラップした fetch 関数
// ApiResponse の中にResult型のを仕込んでいます。
export const getLabels = async (req?: Req): Promise<ApiResponse<Label[]>> => {
  return await handleApiResponse(
    apiHoge.label.getLabelList(createCookieHeader(req))
  );
};

エラーハンドリングの修正

// try ~ catch を使用したエラーハンドリング
try {
  const data = await getLabels();
} catch (error) {
  handleToast("エラーが発生しました", true);
  return;
}

// result 型を使用したエラーハンドリング
// エラーチェックをしないと ret.value で value にアクセスするとエラーになる
const ret = await getLabels();
if (ret.isErr()) {
  handleToast("エラーが発生しました", true);
  return;
}
const data = ret.value;

型の整合性確認

・日付型(Date 型)の扱い

これは明確にフロントエンド側の設計(型定義)に起因する問題でしたが、従来のスキーマ駆動開発では、バックエンドで Date 型として定義している箇所を、フロントエンド側では string 型として扱っていました。

// 手動で型定義していた時代
interface Hoge {
  createdAt: string; // 実際は Date 型だが、stringとして扱っていた
}

// OpenAPIの自動生成
interface Hoge {
  createdAt: Date; // スキーマ定義に基づき Date 型として生成
}

そのため、OpenAPI による自動生成を導入した結果、当該フィールドが期待通り Date 型として出力されるようになり、フロントエンドのコードベースの至る所で、string 型と Date 型の型の換処理をする必要がありました。

・JSON 非対応のデータ型問題と superjson の活用

フロントエンドでは React のフレームワークとして Next.js を採用しており、Page Router の getServerSideProps (SSR)を利用しています。
しかし、SSR で取得したデータをコンポーネントに渡す際、JSON シリアライズの壁に直面しました。

OpenAPI Generator の導入により、API レスポンスの型定義が Date 型など JSON 非対応のデータ型を含むようになったため、SSR が返すデータにこれらが含まれていると、デシリアライズに失敗し、エラーが発生するようになりました。

この問題を解決するため、 superjson を導入しました。
superjson は、JSON の標準でサポートされていない Date や Set などの型を良しなに変換してくれます。

・enum 変換

こちらも Date 型と同様にフロントエンド側の課題です。
これまでは、バックエンドで enum として定義されている型を、フロントエンドでは enum string の場合は string 型、enum int の場合は number 型として扱っていました。

しかし、自動生成された型を利用するようになったことで、これまで暗黙的に行っていたことが、できなくなり、フロントエンド側で enum の型変換を明示的に行う必要が出てきました。
alias (as) を用いて型を強制することも出来ましたが、それだと OpenApi Generator を使用する意味が無くなるので、as は最終手段として、基本的に使用しない方針にしました。

// こちらの逆パターンなどもあり
export const toSampleStatus = (value: string): SampleStatus => {
  switch (value) {
    case "active":
      return SampleStatus.ACTIVE;
    case "inactive":
      return SampleStatus.INACTIVE;
    default:
      return assertNever(value);
  }
};

このように、OpenAPI Generator を導入したことにより、手動運用による暗黙的な型の取り扱いが解消され、型が明確になりました。

その結果、フロントエンドの堅牢性が大幅に向上したと同時に、従来の設計における「本来はこのような処理が必要だった」という技術的負債や認識の甘さを認識させられる、非常に良い学習機会にもなりました。

まとめ

ここまで読んでいただき、ありがとうございます。他に様々な課題や問題がありましたが、特に共有したい内容を中心に話してきました。
弊社では、今年度中にすべての fetch 関数を置き換えることを目標に現在進行中で奮闘しております!

最後に Recustomer 株式会社では、絶賛エンジニアの採用活動に力を入れております。
興味が湧いたらぜひ応募いただければと思います。

弊社の他のメンバーもアドベントカレンダーで記事を投稿しているので、是非覗いて見てください!
https://qiita.com/advent-calendar/2025/recustomer

Discussion