# API設計パターン ## 概要 このドキュメントでは、本プロジェクトにおけるAPI設計の標準パターンと実装ルールを定義します。 Gradio API をプロキシする `/gradio-proxy/check-url` の実装を参考例として、統一された設計アプローチを記載します。 ## アーキテクチャ概要 ``` フロントエンド (React) ↓ useCheckUrl() hook API Client (api-client/gradio-proxy/check-url.ts) ↓ hc().api.rpc['gradio-proxy']['check-url'] RPC Router (app/api/rpc/[...route]/route.ts) ↓ .route('/gradio-proxy', gradioProxyRoute) Server Route (server/routes/gradio-proxy.ts) ↓ .post('/check-url', zValidator(...)) Service Layer (services/gradio.ts) ↓ getCheckUrl() External API (Gradio) ``` ## ディレクトリ構造 ``` project-root/ ├── schema/ # Zodスキーマ定義 │ └── check-url.ts # check-url API用の型定義とバリデーション ├── server/routes/ # Honoルート定義 │ └── gradio-proxy.ts # Gradio APIプロキシのエンドポイント実装 ├── api-client/ # フロントエンド向けAPIクライアント │ └── check-url.ts # React Query hooks ├── app/api/rpc/[...route]/ # Next.js App Router統合 │ └── route.ts # RPCルート登録 └── services/ # ビジネスロジック層 └── gradio.ts # Gradio API呼び出し ``` ## 実装ステップ ### 1. Zodスキーマ定義 (`schema/`) **目的**: リクエスト・レスポンスの型安全性とバリデーション **実装例** (`schema/gradio-proxy/check-url.ts`): ```typescript import { z } from 'zod'; // リクエストスキーマ export const checkUrlRequestSchema = z .object({ ownUrl: z.string().min(1, '自社URLを入力してください。'), urlText: z.array(z.string()).min(3, '競合URLを3件以上入力してください。'), ownImage: z.string().nullable(), ownImageName: z.string().optional(), dummyMode: z.boolean().optional().default(false), userEmail: z.string().optional(), userIdentifier: z.string().min(1, 'ユーザー識別子が必要です。'), }) .refine((data) => data.ownUrl.trim() !== '', { message: '自社URLを入力してください。', path: ['ownUrl'], }); export type CheckUrlRequest = z.infer; // レスポンススキーマ export const checkUrlResponseSchema = z.union([ z.unknown(), // 外部APIの正常レスポンス z.object({ status: z.literal('error'), message: z.string(), }), ]); export type CheckUrlResponse = z.infer; ``` **ルール**: - リクエスト・レスポンス両方にZodスキーマを定義 - エクスポートする型は`z.infer`を使用 - バリデーションエラーメッセージは日本語で明確に - `dummyMode`など共通プロパティは`.optional().default()`で設定 ### 2. サーバーサイドルート (`server/routes/`) **目的**: エンドポイント実装とビジネスロジック呼び出し **実装例** (`server/routes/gradio-proxy.ts`): ```typescript import { checkUrlRequestSchema } from '@/schema/gradio-proxy/check-url'; import { getDummyData } from '@/server/utils/dummy-data'; import { getCheckUrl } from '@/services/gradio'; import { zValidator } from '@hono/zod-validator'; import { Hono } from 'hono'; export const gradioProxyRoute = new Hono() // POST /check-url .post('/check-url', zValidator('json', checkUrlRequestSchema), async (c) => { try { const request = c.req.valid('json'); console.log('[CHECK_URL API] Request metadata:', { ownUrl: request.ownUrl, urlCount: request.urlText.length, dummyMode: request.dummyMode, }); // ダミーモード分岐 if (request.dummyMode) { try { const dummyData = getDummyData('get_check_url.json'); return c.json(dummyData); } catch (error) { return c.json( { status: 'error', message: error instanceof Error ? error.message : 'ダミーデータの読み込みに失敗しました。', }, 404, ); } } // サービス層呼び出し const result = await getCheckUrl( { ownUrl: request.ownUrl, urlText: request.urlText, ownImage: request.ownImage, ownImageName: request.ownImageName, }, request.userEmail || null, request.userIdentifier, ); // エラーハンドリング if (result && typeof result === 'object' && 'status' in result && result.status === 'error') { return c.json(result, 400); } return c.json(result); } catch (error) { console.error('[CHECK_URL API] Error:', error); return c.json({ status: 'error', message: '不明なエラーが発生しました。' }, 500); } }) // GET /status - ステータス確認(複数パスの例) .get('/status', async (c) => { return c.json({ status: 'ok', service: 'gradio-proxy' }); }); ``` **ルール**: - `zValidator`でリクエストバリデーション - ダミーモードの処理はサーバーサイドで実装 - ログ出力は`[API_NAME]`プレフィックスを付ける - エラーレスポンスは`{ status: 'error', message: string }`形式 - 適切なHTTPステータスコード (400, 404, 408, 500) を返す - サービス層の関数を直接呼び出す - **重要**: `new Hono().get().post()`のようにメソッドチェーンで書く(型推論のため) **メソッドチェーンの重要性**: ```typescript // ✅ Good - rpcClientで型推論が正しく動作 export const checkUrlRoute = new Hono() .post('/', ...) .get('/status', ...) // ❌ Bad - rpcClientで型推論が動作しない export const checkUrlRoute = new Hono() checkUrlRoute.post('/', ...) checkUrlRoute.get('/status', ...) ``` メソッドチェーンを使わないと、`hc()`による型推論が機能せず、フロントエンドでのAPI呼び出し時に型安全性が失われます。 ### 3. RPCルート登録 (`app/api/rpc/[...route]/route.ts`) **目的**: Next.js App RouterへのHonoルート統合 **実装例**: ```typescript import { gradioProxyRoute } from '@/server/routes/gradio-proxy'; import { proposalRoute } from '@/server/routes/proposal'; import { screenshotRoute } from '@/server/routes/screenshot'; import { Hono } from 'hono'; import { handle } from 'hono/vercel'; const app = new Hono() .basePath('/api/rpc') .onError((err, c) => { console.error('Global error:', err); return c.json({ error: { message: err.message || 'Internal Server Error', code: 'SYSTEM_ERROR' } }, 500); }) .notFound((c) => { return c.json({ error: { message: 'Not Found', code: 'NOT_FOUND' } }, 404); }) .route('/screenshot', screenshotRoute) .route('/proposal', proposalRoute) .route('/gradio-proxy', gradioProxyRoute); export const GET = handle(app); export const POST = handle(app); export const PUT = handle(app); export const DELETE = handle(app); export const PATCH = handle(app); export type RpcType = typeof app; ``` **ルール**: - `.basePath('/api/rpc')`でベースパスを設定 - グローバルエラーハンドラと404ハンドラを定義 - 新しいルートは`.route()`で追加 - `RpcType`を必ずエクスポート(型推論のため) ### 4. APIクライアント (`api-client/`) **目的**: フロントエンドからのAPI呼び出しをhooks化 **実装パターン1: useMutation(単発実行向け)** (`api-client/gradio-proxy/check-url.ts`): ```typescript import type { RpcType } from '@/app/api/rpc/[...route]/route'; import { withTimeout } from '@/lib/utils'; import type { CheckUrlRequest, CheckUrlResponse } from '@/schema/gradio-proxy/check-url'; import { useMutation } from '@tanstack/react-query'; import { hc } from 'hono/client'; // RPCクライアント設定 const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; const rpcClient = hc(apiUrl).api.rpc; /** * check_url APIを呼び出すカスタムフック(mutation版) * @returns mutationオブジェクト(タイムアウト5分) */ export function useCheckUrl() { return useMutation({ mutationFn: async (request: CheckUrlRequest): Promise => { console.log('[useCheckUrl] API呼び出し開始:', { ownUrl: request.ownUrl, urlCount: request.urlText.length, dummyMode: request.dummyMode, }); const apiCall = async (): Promise => { const response = await rpcClient['gradio-proxy']['check-url'].$post({ json: request, }); if (!response.ok) { const errorData = (await response.json()) as { message?: string }; console.error('[useCheckUrl] API エラー:', errorData); throw new Error(errorData.message || 'check_url APIの呼び出しに失敗しました'); } const data = await response.json(); console.log('[useCheckUrl] API呼び出し成功'); return data as CheckUrlResponse; }; try { return await withTimeout(apiCall(), 5 * 60 * 1000); // 5分タイムアウト } catch (error) { console.error('[useCheckUrl] API呼び出しエラー:', error); throw error; } }, }); } ``` **実装パターン2: queryClient.fetchQuery(キャッシュ活用・複数API連携向け)** (`api-client/gradio-proxy/score-step3.ts`): ```typescript import { createQueryOptions } from '@/api-client/query-config'; import type { RpcType } from '@/app/api/rpc/[...route]/route'; import type { ScoreStep3Request, ScoreStep3Response } from '@/schema/gradio-proxy/score-step3'; import type { FetchQueryOptions } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query'; import { hc } from 'hono/client'; import { useCallback } from 'react'; const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; const rpcClient = hc(apiUrl).api.rpc; /** * score_step3 APIを呼び出すカスタムフック * @returns キャッシュ対応のAPI呼び出し関数(タイムアウト5分、キャッシュ24時間) */ export function useScoreStep3() { const queryClient = useQueryClient(); return useCallback( async (request: ScoreStep3Request): Promise => { return queryClient.fetchQuery( createQueryOptions( { queryKey: ['score-step3', request], queryFn: async (): Promise => { const response = await rpcClient['gradio-proxy']['score-step3'].$post({ json: request, }); if (!response.ok) { const errorData = (await response.json()) as { message?: string }; throw new Error(errorData.message || 'score_step3 APIの呼び出しに失敗しました'); } return await response.json(); }, }, 5 * 60 * 1000, // 5分タイムアウト ) as FetchQueryOptions, ); }, [queryClient], ); } ``` **パターン選択のガイドライン**: | パターン | 用途 | 特徴 | | -------------------------- | ----------------------------- | ------------------------------------------ | | **useMutation** | 単発のAPI呼び出し | シンプル、mutation状態管理、キャッシュ不要 | | **queryClient.fetchQuery** | キャッシュが必要、複数API連携 | キャッシュ活用、React Query機能フル活用 | **共通ルール**: - `hc()`で型安全なクライアント生成 - `withTimeout`または`createQueryOptions`でタイムアウト管理 - エンドポイント名はケバブケース(`rpcClient['gradio-proxy']['check-url']`のようにネストする) - 詳細なログ出力(開始、成功、エラー) - エラーハンドリングを適切に実装 **パターン1(useMutation)の特徴**: - `useMutation`フックを直接使用 - `mutateAsync()`で呼び出し、mutation状態(isLoading, isError等)を取得可能 - `withTimeout`で直接タイムアウトを設定(5分が一般的) - キャッシュ不要な単発実行に最適 **パターン2(queryClient.fetchQuery)の特徴**: - `useCallback`でメモ化されたAPI呼び出し関数を返す - `createQueryOptions`でキャッシュ・タイムアウト設定を統一管理 - React Queryのキャッシュ機能を活用(同一リクエストの重複防止) - 複数のAPI呼び出しを連携させる場合に最適 **React Query設定** (`api-client/query-config.ts`): パターン2で使用する共通設定: ```typescript import { withTimeout } from '@/lib/utils'; import type { QueryFunctionContext, UseQueryOptions } from '@tanstack/react-query'; export const defaultQueryOptions: Partial = { // 自動再フェッチを無効化 refetchOnWindowFocus: false, refetchOnMount: false, // キャッシュ設定 staleTime: Infinity, // データは常に新鮮 gcTime: 24 * 60 * 60 * 1000, // 24時間キャッシュ保持 // リトライ設定(長時間APIのためリトライ無効) retry: 0, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // タイムアウト設定(デフォルト20分) meta: { timeout: 20 * 60 * 1000, }, }; /** * createQueryOptions - queryFnにタイムアウトを自動追加 * @param overrides 上書きしたいオプション * @param customTimeout カスタムタイムアウト時間(ミリ秒) */ export function createQueryOptions(overrides?: Partial>, customTimeout?: number): Partial> { const timeout = customTimeout || (defaultQueryOptions.meta as { timeout: number })?.timeout || 10 * 60 * 1000; const wrappedOptions = { ...defaultQueryOptions, ...overrides }; if (wrappedOptions.queryFn && typeof wrappedOptions.queryFn === 'function') { const originalQueryFn = wrappedOptions.queryFn; wrappedOptions.queryFn = async (context: QueryFunctionContext) => { console.log(`[QUERY] API呼び出し開始 - タイムアウト: ${timeout / 1000}秒`); try { const promise = Promise.resolve(originalQueryFn(context)); const result = await withTimeout(promise, timeout); console.log(`[QUERY] API呼び出し成功`); return result; } catch (error) { console.error(`[QUERY] API呼び出し失敗:`, error); throw error; } }; } return wrappedOptions as Partial>; } ``` **キャッシュ戦略**: - `staleTime: Infinity`: 明示的に再フェッチしない限りキャッシュを使用 - `gcTime: 24時間`: メモリ効率とパフォーマンスのバランス - 同じリクエストパラメータなら自動的にキャッシュから返却 - タイムアウト処理が自動的に追加される(デフォルト20分) ### 5. フロントエンド統合 **パターン1(useMutation)の使用例**: ```typescript import { useCheckUrl } from '@/api-client/gradio-proxy/check-url'; export default function PreAnalysisInput() { const { dummyMode, userIdentifier } = useGlobalStore(); const { user } = useUserStore(); const { mutateAsync: checkUrl, isPending, isError } = useCheckUrl(); const onSubmit = async (data: FormData) => { try { const response = await checkUrl({ ownUrl: data.ownUrl, urlText: data.urlText, ownImage: data.ownImage || '', ownImageName: data.ownImageName, dummyMode, userEmail: user?.user?.email, userIdentifier, }); // エラーチェック if ((response as { status: string }).status === 'error') { setErrorMessage((response as { message: string }).message); return; } // 正常処理 // ... } catch (error) { console.error('API呼び出しエラー:', error); setErrorMessage('エラーが発生しました'); } }; return (
{isError &&

エラーが発生しました

}
); } ``` **パターン2(queryClient.fetchQuery)の使用例**: ```typescript import { useScoreStep3 } from '@/api-client/gradio-proxy/score-step3'; export default function PreAnalysisCheck() { const scoreStep3Api = useScoreStep3(); const [isLoading, setIsLoading] = useState(false); const onSubmit = async () => { setIsLoading(true); try { // キャッシュを活用して呼び出し const result = await scoreStep3Api({ ownUrl, urlText: competitionUrls, tempImages, fvInfos, cnInfo, dummyMode, userEmail, userIdentifier, }); // 結果を処理 // ... } catch (error) { console.error('API呼び出しエラー:', error); } finally { setIsLoading(false); } }; } ``` **使用ルール**: **パターン1(useMutation)**: - `mutateAsync`を使用してAPI呼び出し - `isPending`, `isError`等のmutation状態を活用 - ローディング状態は自動管理される - 単発の実行に適している **パターン2(queryClient.fetchQuery)**: - フックから返される関数を直接呼び出す - ローディング状態は手動管理(useState等) - 同じパラメータでの呼び出しは自動的にキャッシュから返却 - 複数のAPI呼び出しを連携させる場合に適している **共通ルール**: - `dummyMode`をリクエストに含める(サーバーサイドで分岐) - エラーハンドリングをtry-catchで実装 ## ダミーモードの扱い ### 基本方針 - **フロントエンド**: `dummyMode`プロパティをAPIリクエストに含める - **サーバーサイド**: `dummyMode`に応じて分岐処理 - `true`: `public/dummy/`からJSONファイルを読み込んで返す - `false`: 実際のサービス層(Gradio API等)を呼び出す ### ダミーデータ配置 ``` public/ └── dummy/ ├── get_check_url.json ├── get_proposal_fv.json └── ... ``` ### 実装パターン 共通のユーティリティ関数 `getDummyData` を使用します。 ```typescript import { getDummyData } from '@/server/utils/dummy-data'; if (request.dummyMode) { try { const dummyData = getDummyData('get_check_url.json'); console.log('[API_NAME] Dummy data loaded successfully'); return c.json(dummyData); } catch (error) { console.error('[API_NAME] Dummy data loading failed:', error); return c.json( { status: 'error', message: error instanceof Error ? error.message : 'ダミーデータの読み込みに失敗しました。', }, 404, ); } } ``` ### ユーティリティ関数 `server/utils/dummy-data.ts` に以下の関数を提供しています。 ```typescript /** * ダミーデータファイルを読み込む * @param fileName ファイル名(例: 'get_check_url.json') * @returns パースされたJSONデータ * @throws ファイルが存在しない場合やJSON解析に失敗した場合 */ export function getDummyData(fileName: string): T; /** * ダミーデータファイルが存在するかチェック * @param fileName ファイル名 * @returns ファイルが存在する場合true */ export function hasDummyData(fileName: string): boolean; ``` ## エラーハンドリング ### エラーレスポンス形式 ```typescript { status: 'error', message: string // ユーザー向けエラーメッセージ(日本語) } ``` ### HTTPステータスコード - `400 Bad Request`: バリデーションエラー、API論理エラー - `404 Not Found`: リソースが見つからない - `408 Request Timeout`: タイムアウト - `500 Internal Server Error`: サーバー内部エラー ### エラーメッセージの日本語化 ```typescript if (error instanceof Error) { errorMessage = error.message; if (error.message.includes('タイムアウト') || error.message.includes('timeout')) { errorMessage = 'タイムアウトが発生しました。もう一度やり直してください。'; statusCode = 408; } } ``` ## ログ出力規約 ### フォーマット ``` [API_NAME] アクション: 詳細情報 ``` ### 例 ```typescript console.log('[CHECK_URL API] Request metadata:', { ... }); console.error('[CHECK_URL API] Error:', error); console.log('[useCheckUrl] API呼び出し開始:', { ... }); ``` ### 推奨ログポイント 1. **リクエスト受信時**: メタデータ(URLカウント、ダミーモード等) 2. **分岐処理時**: ダミーモード使用、フォールバック等 3. **外部API呼び出し前後**: 開始と完了 4. **エラー発生時**: エラー詳細とスタックトレース 5. **レスポンス返却時**: 成功メッセージ ## 型安全性の確保 ### Zodスキーマ → TypeScript型 ```typescript export const xxxRequestSchema = z.object({ ... }); export type XxxRequest = z.infer; ``` ### Hono RPC型推論 ```typescript // route.ts export type RpcType = typeof app; // api-client import type { RpcType } from '@/app/api/rpc/[...route]/route'; const rpcClient = hc(apiUrl).api.rpc; ``` これにより、エンドポイント名や型が自動補完され、タイプミスを防止できます。 ## パス指定ルール ### インポート - **禁止**: 相対パス (`../../../`) - **必須**: エイリアス (`@/`) ```typescript // ❌ Bad import { getCheckUrl } from '../../../services/gradio'; // ✅ Good import { getCheckUrl } from '@/services/gradio'; ``` ### Node.js組み込みモジュール ```typescript // Biomeルール準拠 import * as fs from 'node:fs'; import * as path from 'node:path'; ``` ## コーディング規約 ### import順序 1. 外部ライブラリ 2. 内部モジュール(`@/`) 3. 相対パス(使用禁止) ### any型の禁止 ```typescript // ❌ Bad const data: any = await response.json(); // ✅ Good const data = (await response.json()) as CheckUrlResponse; ``` ### Prettierによる自動フォーマット ```bash npx prettier --write <編集したファイル> ``` ## チェックリスト 新しいAPIを追加する際は、以下を確認してください。 - [ ] Zodスキーマ定義 (`schema/`) - [ ] サーバーサイドルート実装 (`server/routes/`) - [ ] RPCルート登録 (`app/api/rpc/[...route]/route.ts`) - [ ] **APIクライアントパターンの選択**: - [ ] パターン1(useMutation): 単発実行、キャッシュ不要 - [ ] パターン2(queryClient.fetchQuery): キャッシュ活用、複数API連携 - [ ] APIクライアント作成 (`api-client/`) - [ ] フロントエンド統合 - [ ] ダミーモード対応 - [ ] エラーハンドリング - [ ] ログ出力 - [ ] Prettierフォーマット - [ ] 型安全性の確認 ## 参考実装 完全な実装例として、以下のファイルを参照してください。 **パターン1(useMutation)の実装例**: - スキーマ: `schema/gradio-proxy/check-url.ts` - サーバールート: `server/routes/gradio-proxy.ts` (check-urlエンドポイント) - APIクライアント: `api-client/gradio-proxy/check-url.ts` - フロントエンド: `components/pages/index/pre-analysis-input.tsx` **パターン2(queryClient.fetchQuery)の実装例**: - スキーマ: `schema/gradio-proxy/score-step3.ts` - サーバールート: `server/routes/gradio-proxy.ts` (score-step3エンドポイント) - APIクライアント: `api-client/gradio-proxy/score-step3.ts` - 統合フック: `api-client/gradio-proxy/use-pre-analysis.ts` - フロントエンド: `components/pages/index/pre-analysis-check.tsx` **共通**: - RPCルート: `app/api/rpc/[...route]/route.ts` - Query設定: `api-client/query-config.ts` ## まとめ このパターンに従うことで、以下のメリットが得られます。 1. **型安全性**: Zod + Hono RPCによるエンドツーエンドの型推論 2. **保守性**: 統一された構造とログ出力 3. **テスト性**: ダミーモードによる開発効率向上 4. **拡張性**: 新しいAPIの追加が容易 5. **エラー処理**: 一貫したエラーハンドリング 6. **柔軟性**: 用途に応じた2つの実装パターン - **useMutation**: シンプルな単発実行 - **queryClient.fetchQuery**: キャッシュ活用と複数API連携