diff --git a/docs/adr/0007-openapi-typescript-codegen.md b/docs/adr/0007-openapi-typescript-codegen.md index d630f601..edb2c01e 100644 --- a/docs/adr/0007-openapi-typescript-codegen.md +++ b/docs/adr/0007-openapi-typescript-codegen.md @@ -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 修正と束ねるとレビュー不能になるため)。 ## コンテキスト @@ -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()`(`api/client.ts`)の 401 リフレッシュ・CSRF・Cookie 認証ロジックは一切変更しない**。生成された型を `request()` の型引数として渡すだけにする。 - **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`(または一時ファイル)。 @@ -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 で実施する。これを怠ると「生成物と手書きが混在する中途半端な状態」になり完全移行が成立しない。 ### 移行しないもの(重要) @@ -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 内で実施する(別タスク化しない)。これにより「生成物と手書きの混在」を避ける。 ## 関連リンク diff --git a/frontend/e2e/github-link.spec.ts b/frontend/e2e/github-link.spec.ts index eeda1288..f9e14526 100644 --- a/frontend/e2e/github-link.spec.ts +++ b/frontend/e2e/github-link.spec.ts @@ -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) => @@ -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プロフィールを取得中..."), diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts index 26e9d5d8..9630091c 100644 --- a/frontend/e2e/navigation.spec.ts +++ b/frontend/e2e/navigation.spec.ts @@ -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(); }); diff --git a/frontend/src/components/AuthenticatedLayout.tsx b/frontend/src/components/AuthenticatedLayout.tsx index d6bd1781..e0a975c0 100644 --- a/frontend/src/components/AuthenticatedLayout.tsx +++ b/frontend/src/components/AuthenticatedLayout.tsx @@ -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"; @@ -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 としてページへ渡し、ダッシュボード側で * 連携実行(ポーリング)とエラー表示を担わせる。 */ @@ -42,8 +43,6 @@ export function AuthenticatedLayout({ }); }; - const githubActive = location.pathname === "/github_link"; - return (
@@ -61,13 +60,14 @@ export function AuthenticatedLayout({ {user.isGitHubUser && (
- +