Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 39 additions & 16 deletions docs/adr/0007-openapi-typescript-codegen.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# ADR-0007: OpenAPI → TypeScript 型コード生成の導入検討
# ADR-0007: OpenAPI → TypeScript 型コード生成の導入(完全移行)

## ステータス

Proposed

> 本 ADR は「BE の Pydantic schema と FE の TypeScript 型の二重定義に対し、OpenAPI から TS 型を自動生成する仕組みを導入するか」の判断材料であり、採用確定ではない。
> 後述の Phase 1 パイロット(生成パイプライン基盤の構築 + 1 ドメインの試験移行)の結果を見て `Accepted` への昇格、または `Deprecated`(現状の手動同期を継続)を判断する。
> 本 ADR は「BE の Pydantic schema と FE の TypeScript 型の二重定義に対し、OpenAPI から TS 型を自動生成する仕組みを導入するか」を扱う。
> **採用方針は確定**:OpenAPI → TypeScript 型生成を導入し、frontend の手書き DTO 型を生成物へ**完全移行**する(手書き interface を残したまま alias で FE 独自名を温存する折衷案は採らない。詳細は「決定内容」参照)。リリース前のため呼び出し側 rename コストが低く、踏み込んだ移行が可能と判断した。
> ただし status は `Proposed` のまま据え置く。後述の Phase 0 + Phase 1 パイロットが `make ci` green を満たし、CI ドリフト検知が機能することを確認できた時点で `Accepted` に昇格する。前提が崩れた場合は `Deprecated`(現状の手動同期を継続)に倒す。
> 本 ADR の起票自体は領域横断リファクタ(`XR_apply`)のスコープ内だが、**パイプライン実装と型移行は本 ADR が定義する後続 PR で行う**(codegen は重い投資であり、env/docs 修正と束ねるとレビュー不能になるため)。

## コンテキスト
Expand Down Expand Up @@ -34,15 +35,33 @@ DevForge は backend(FastAPI + Pydantic)が REST API の DTO を `backend/ap

## 決定内容

FastAPI が出力する OpenAPI スキーマから **`openapi-typescript`** で TypeScript 型を生成するパイプラインを導入する案を提示する。導入する場合の方針は以下のとおり
FastAPI が出力する OpenAPI スキーマから **`openapi-typescript`** で TypeScript 型を生成するパイプラインを導入する。frontend の手書き DTO 型は生成物へ完全移行する。方針は以下のとおり

### 基本方針

- **backend(Pydantic schema)を DTO の Single Source of Truth とする**。frontend の手書き型は生成物に置き換えていく。
- **backend(Pydantic schema)を DTO の Single Source of Truth とする**。frontend の手書き DTO 型は**全廃し、生成物へ完全移行する**(alias で FE 独自名を温存する折衷はしない)。
- **FE 独自名は廃し backend のクラス名に統一する**。`CareerResumeResponse` のような FE 独自名は呼び出し側ごと BE 名(`ResumeResponse`)へ rename する。`Career` のようなドメイン文脈プレフィックスは割り切って捨てる(命名統一マップは後述)。
- 生成先は `frontend/src/api/generated.ts`(コミット対象・**手編集禁止**をファイル冒頭コメントで明示)。
- ergonomics のため、生成物を **BE 名のまま 1:1 で再エクスポートする薄い層**(例 `frontend/src/api/types.ts`: `export type ResumeResponse = components["schemas"]["ResumeResponse"]`)を置く。呼び出し側は `components["schemas"][...]` を直書きせずこの再エクスポート名を使う。これは FE 独自名ではなく生成物の機械的ミラーであり、二重管理にはならない。
- **`request<T>()`(`api/client.ts`)の 401 リフレッシュ・CSRF・Cookie 認証ロジックは一切変更しない**。生成された型を `request<T>()` の型引数として渡すだけにする。
- **API パスの SSoT である `api/paths.ts` は維持**する。codegen は型のみを対象とし、パス定数は置き換えない。

### 命名統一マップ(FE 独自名 → BE 名)

完全移行では以下の FE 独自名を廃止し、backend のクラス名に統一する(呼び出し側ごと rename)。

| FE 独自名(廃止) | BE 名(統一先) |
|---|---|
| `TaskProgress`(`api/githubLink.ts`) | `ProgressResponse` |
| `AuthResponse`(`api/auth.ts`) | `TokenResponse` |
| `CareerExperience`(`types.ts`) | `Experience` |
| `CareerResumePayload`(`types.ts`) | `ResumeBase` |
| `CareerResumeResponse`(`types.ts`) | `ResumeResponse` |
| `BlogAccount`(`types.ts`) | `BlogAccountResponse` |
| `BlogArticle`(`types.ts`) | `BlogArticleResponse` |

既に BE と同名のため rename 不要(型定義の出どころを生成物へ差し替えるだけ): `ContributionDay` / `ContributionCalendar` / `GitHubLinkResponse` / `CachedGitHubLinkResponse` / `MasterItem` / `TechStackMasterItem`。

### パイプライン構成

1. **OpenAPI エクスポート**: backend の FastAPI app から `app.openapi()` を JSON にダンプする backend スクリプト(`backend/scripts/export_openapi.py` 想定)を追加。出力は `backend/openapi.json`(または一時ファイル)。
Expand All @@ -52,14 +71,16 @@ FastAPI が出力する OpenAPI スキーマから **`openapi-typescript`** で

### 段階移行プラン

採用する場合は以下の順で進める。Phase 1 をパイロットとし、効果を見てから先へ進む。
以下の順で進める。Phase 1 をパイロットとし、効果を見てから先へ進む。各 Phase は別 PR で実施する

| Phase | 対象 | 内容 | リスク |
|---|---|---|---|
| 0 | 基盤 | `export_openapi.py` / `openapi-typescript` 依存追加 / `make codegen-types` / `generated.ts` 初回生成 / CI ドリフト検知 | 中 |
| 1 | 読み取り(パイロット) | `api/shared.ts`(`TaskStatusResponse` 系)を `generated.ts` の型へ置換。手書き interface を `type X = components["schemas"]["..."]` の再エクスポートに変更 | 低〜中 |
| 2 | 主要レスポンス | `api/githubLink.ts` / `schemas/github_link.py` 系、`api/auth.ts` の `AuthResponse` を移行 | 中 |
| 3 | フォーム入出力含む | `types.ts` の `CareerResume*` / `BlogAccount` / `MasterItem` 系を移行。`payloadBuilders.ts` / `formMappers.ts` への影響を確認 | 中〜高 |
| 0 | 前提・基盤 | ①`response_model` 棚卸し(未設定 14 個を「DTO 不要」と「schema 化必要」に仕分け)②不足 schema 追加 + `response_model` 付与(`/github/login-url`→`GitHubLoginUrlResponse`、202 系→共通 `TaskAcceptedResponse`)③`backend/scripts/export_openapi.py` 追加 ④`openapi-typescript` を devDependency 追加 ⑤`make codegen-types`(Nix wrap)⑥`generated.ts` 初回生成 ⑦CI ドリフト検知(`git diff --exit-code`)| 中 |
| 1 | 読み取り(パイロット) | `api/shared.ts`(`TaskStatusResponse`、`/cache/status` は `response_model` 付き・死角なし)を完全移行。`make ci` green と「BE schema をわざと変えると CI が落ちる」ことを確認 | 低〜中 |
| 2 | 主要レスポンス | `api/githubLink.ts`(`TaskProgress`→`ProgressResponse` 含む)・`api/auth.ts`(`AuthResponse`→`TokenResponse`)を完全移行。手書き interface を全削除し BE 名へ rename。E2E 必須 | 中 |
| 3 | フォーム入出力含む | `types.ts` の `CareerResume*`(→`Resume*`)/ `BlogAccount`(→`BlogAccountResponse`)/ `MasterItem` 系を完全移行。`payloadBuilders.ts` / `formMappers.ts` の追従。E2E 必須 | 中〜高 |

> **完全移行の前提(Phase 0 に内包)**: 生成できるのは `response_model` 付きエンドポイントのみ(実測 40 中 26)。DTO を持つのに `response_model` 未設定のエンドポイント(`/github/login-url` の `{authorization_url, state}`、`/run`・`/run/retry` の `{status}`)を schema 化・付与する作業を、完全移行の前提として Phase 0 で実施する。これを怠ると「生成物と手書きが混在する中途半端な状態」になり完全移行が成立しない。

### 移行しないもの(重要)

Expand All @@ -79,18 +100,20 @@ FastAPI が出力する OpenAPI スキーマから **`openapi-typescript`** で

## トレードオフ・既知のリスク

1. **生成物の肥大**: `generated.ts` は全 schema を含むため大きくなる。型のみで本番バンドルには乗らない(`import type`)が、差分レビューのノイズにはなる。手編集禁止コメントで誤編集を防ぐ。
1. **生成物の肥大**: `generated.ts` は全 schema を含むため大きくなる。型のみで本番バンドルには乗らない(`import type`)が、差分レビューのノイズにはなる。手編集禁止コメントで誤編集を防ぐ。なお本 repo の schema は約 36 クラス(最大 `resume.py` 12 クラス)と小さく、肥大の影響は軽微。
2. **backend app の import コスト**: openapi エクスポートは FastAPI app を import する必要があり、WeasyPrint 等のネイティブ依存解決のため **Nix devshell 経由必須**(生シェル直叩き禁止。`.claude/CLAUDE.md` 準拠)。
3. **CI 実行時間の増加**: codegen + `git diff` チェックのステップが増える。
4. **命名差の吸収**: 現行 FE は backend と別名(`CareerResumeResponse` ↔ `ResumeResponse`)を使う箇所がある。移行時は再エクスポート(`export type CareerResumeResponse = components["schemas"]["ResumeResponse"]`)で名前を保ち、呼び出し側の破壊を避ける。
5. **`response_model` 未設定エンドポイントの穴**: OpenAPI に型が出ない(`/auth/me` は `response_model=TokenResponse` を確認済みだが、未設定箇所があると生成されない)。移行前に backend 全 router の `response_model` 付与状況を棚卸しする必要がある。
6. **E2E 影響**: github 連携・ブログ・通知の UI フローに関わる型を移行するため、Phase 2 以降は `npm run test:e2e` 必須。
4. **完全移行に伴う一括 rename**: FE 独自名を BE 名へ統一するため呼び出し側を広範に rename する(命名統一マップ参照)。リリース前のため破壊的だが許容。`Career` 等のドメイン文脈プレフィックスは名前から失われる(割り切る)。
5. **入出力兼用 schema の `-Input`/`-Output` 分裂(論点A)**: 同一 Pydantic モデルを request body と response の双方に使うと、required/optional 解釈が入出力で変わるため openapi-typescript が 2 系統の型を生成しうる。`resume.py` の `ResumeBase` 等が候補。Phase 0 で入出力兼用 schema を棚卸しし、分裂が起きる箇所を把握する。
6. **optional/required の解釈ズレ(論点B)**: Pydantic のデフォルト値(例 `TokenResponse.is_github_user: bool = False`)は OpenAPI で optional 扱いになり、生成物が `is_github_user?: boolean` になりうる。手書きの required と乖離し呼び出し側が型エラー/挙動変化になる。移行時に「本当に optional で良いか」を BE schema 側で見直す好機とする。
7. **`response_model` 未設定エンドポイントの穴 / 新規 schema 命名(論点C)**: OpenAPI に型が出るのは `response_model` 付きのみ(実測 40 中 26)。完全移行の前提として Phase 0 で未設定箇所を棚卸し・付与する。匿名 `{...}` には BE 命名規約で名前を付ける(`/github/login-url`→`GitHubLoginUrlResponse`、202 受付応答は複数箇所で使い回せるため共通 `TaskAcceptedResponse` に集約して DRY 化)。
8. **E2E 影響**: github 連携・ブログ・通知の UI フローに関わる型を移行するため、Phase 2 以降は `npm run test:e2e` 必須。

## 将来の移行条件

- **Accepted への昇格条件**: Phase 0 + Phase 1 パイロットが `make ci` green を満たし、CI ドリフト検知が機能する(backend schema をわざと変えると CI が落ちる)ことを確認できること。
- **Deprecated(現状維持)への判断**: 生成物の肥大・CI コスト・命名吸収の手間が、手動同期で足りている現状の規律に見合わないと判断した場合は、本 ADR を `Deprecated` にし現状の手動同期 + クロス参照コメントを継続する。
- backend の `response_model` 付与が不完全で OpenAPI に型が出ない場合は、先に backend 側の `response_model` 整備を別タスクで完了させる
- **Deprecated(現状維持)への判断**: 生成物の肥大・CI コスト・一括 rename の手間が、手動同期で足りている現状の規律に見合わないと判断した場合は、本 ADR を `Deprecated` にし現状の手動同期 + クロス参照コメントを継続する。
- **`response_model` 整備は Phase 0 に内包**: 完全移行の前提として、未設定エンドポイントの schema 化・付与を Phase 0 内で実施する(別タスク化しない)。これにより「生成物と手書きの混在」を避ける

## 関連リンク

Expand Down
40 changes: 36 additions & 4 deletions frontend/e2e/github-link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,39 @@ test.describe("GitHub 連携 - 検出フレームワーク表示", () => {
).toBeVisible();
});

test("サイドバーの GitHub連携 クリックで連携が実行されポーリング表示になる", async ({
test("サイドバーの GitHub連携 クリックは画面遷移のみで連携を実行しない", async ({
page,
}) => {
let runCalled = false;
await page.route("**/api/github-link/cache", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ result: null, status: null }),
}),
);
await page.route("**/api/github-link/run", (route) => {
runCalled = true;
return route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify({ status: "pending" }),
});
});

// 別ページから GitHub連携 リンクで遷移する
await page.goto("/career");
await waitForAuthenticatedLayout(page);

await page.getByRole("link", { name: "GitHub連携", exact: true }).click();

// キャッシュ(空状態)が表示され、連携 API は呼ばれない
await expect(page).toHaveURL(/\/github_link/);
await expect(page.getByText(/まだ連携データがありません/)).toBeVisible();
expect(runCalled).toBe(false);
});

test("サブパネルの「連携実行」ボタンで連携が実行されポーリング表示になる", async ({
page,
}) => {
await page.route("**/api/github-link/cache", (route) =>
Expand Down Expand Up @@ -209,9 +241,9 @@ test.describe("GitHub 連携 - 検出フレームワーク表示", () => {
await page.goto("/github_link");
await waitForAuthenticatedLayout(page);

await page
.getByRole("button", { name: "GitHub連携", exact: true })
.click();
// ▼ でサブパネルを開き、「連携実行」ボタンを押すと連携が走る
await page.getByRole("button", { name: "GitHub連携オプション" }).click();
await page.getByRole("button", { name: "連携実行" }).click();

await expect(
page.getByText("GitHubプロフィールを取得中..."),
Expand Down
4 changes: 2 additions & 2 deletions frontend/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ test.describe("認証済みユーザーのナビゲーション", () => {

await expect(page.getByText("DevForge")).toBeVisible();
await expect(page.getByRole("link", { name: "職務経歴書" })).toBeVisible();
// GitHub連携 は連携トリガーを兼ねるためボタン
// GitHub連携 は画面遷移に徹するためリンク(連携実行はサブパネルのボタン)
await expect(
page.getByRole("button", { name: "GitHub連携", exact: true }),
page.getByRole("link", { name: "GitHub連携", exact: true }),
).toBeVisible();
await expect(page.getByRole("link", { name: "ブログ連携" })).toBeVisible();
});
Expand Down
27 changes: 17 additions & 10 deletions frontend/src/components/AuthenticatedLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { NavLink, Outlet, useNavigate } from "react-router-dom";

import type { AuthUser } from "../router/guards";
import type { Theme } from "../hooks/useTheme";
Expand All @@ -25,14 +25,15 @@ export function AuthenticatedLayout({
onLogout: () => void;
}) {
const navigate = useNavigate();
const location = useLocation();
// GitHub 連携オプション(フォーク含む)の開閉とチェック状態。
const [githubOptionsOpen, setGithubOptionsOpen] = useState(false);
const [includeForks, setIncludeForks] = useState(false);

/**
* GitHub 連携を実行する。
* 連携 API のトリガーはこのサイドバークリックのみ。
* 連携 API のトリガーはサブパネル内の「連携実行」ボタンのみ。
* サイドバー項目のクリックは画面遷移に徹し、連携リクエストは飛ばさない
* (不要なリクエストの温床になるため)。
* 実行意図を runNonce としてページへ渡し、ダッシュボード側で
* 連携実行(ポーリング)とエラー表示を担わせる。
*/
Expand All @@ -42,8 +43,6 @@ export function AuthenticatedLayout({
});
};

const githubActive = location.pathname === "/github_link";

return (
<div className={shared.page}>
<div className={styles.appLayout}>
Expand All @@ -61,13 +60,14 @@ export function AuthenticatedLayout({
{user.isGitHubUser && (
<div className={styles.sidebarItemGroup}>
<div className={styles.sidebarItemRow}>
<button
type="button"
className={`${styles.sidebarItem} ${githubActive ? styles.active : ""}`}
onClick={triggerGitHubLink}
<NavLink
to="/github_link"
className={({ isActive }) =>
`${styles.sidebarItem} ${isActive ? styles.active : ""}`
}
>
GitHub連携
</button>
</NavLink>
<button
type="button"
className={styles.sidebarChevron}
Expand All @@ -82,6 +82,13 @@ export function AuthenticatedLayout({
</div>
{githubOptionsOpen && (
<div className={styles.sidebarSubPanel}>
<button
type="button"
className={styles.sidebarItem}
onClick={triggerGitHubLink}
>
連携実行
</button>
<label className={styles.sidebarCheckbox}>
<input
type="checkbox"
Expand Down
Loading