FE_Dev / docs /steering /architecture /api-design-pattern.md
GitHub Actions
Deploy from GitHub Actions [dev] - 2025-10-31 07:28:50
68f7925
# API設計パターン
## 概要
このドキュメントでは、本プロジェクトにおけるAPI設計の標準パターンと実装ルールを定義します。
Gradio API をプロキシする `/gradio-proxy/check-url` の実装を参考例として、統一された設計アプローチを記載します。
## アーキテクチャ概要
```
フロントエンド (React)
↓ useCheckUrl() hook
API Client (api-client/gradio-proxy/check-url.ts)
↓ hc<RpcType>().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<typeof checkUrlRequestSchema>;
// レスポンススキーマ
export const checkUrlResponseSchema = z.union([
z.unknown(), // 外部APIの正常レスポンス
z.object({
status: z.literal('error'),
message: z.string(),
}),
]);
export type CheckUrlResponse = z.infer<typeof checkUrlResponseSchema>;
```
**ルール**:
- リクエスト・レスポンス両方に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<RpcType>()`による型推論が機能せず、フロントエンドでの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<RpcType>(apiUrl).api.rpc;
/**
* check_url APIを呼び出すカスタムフック(mutation版)
* @returns mutationオブジェクト(タイムアウト5分)
*/
export function useCheckUrl() {
return useMutation({
mutationFn: async (request: CheckUrlRequest): Promise<CheckUrlResponse> => {
console.log('[useCheckUrl] API呼び出し開始:', {
ownUrl: request.ownUrl,
urlCount: request.urlText.length,
dummyMode: request.dummyMode,
});
const apiCall = async (): Promise<CheckUrlResponse> => {
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<RpcType>(apiUrl).api.rpc;
/**
* score_step3 APIを呼び出すカスタムフック
* @returns キャッシュ対応のAPI呼び出し関数(タイムアウト5分、キャッシュ24時間)
*/
export function useScoreStep3() {
const queryClient = useQueryClient();
return useCallback(
async (request: ScoreStep3Request): Promise<ScoreStep3Response> => {
return queryClient.fetchQuery(
createQueryOptions<ScoreStep3Response>(
{
queryKey: ['score-step3', request],
queryFn: async (): Promise<ScoreStep3Response> => {
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<ScoreStep3Response>,
);
},
[queryClient],
);
}
```
**パターン選択のガイドライン**:
| パターン | 用途 | 特徴 |
| -------------------------- | ----------------------------- | ------------------------------------------ |
| **useMutation** | 単発のAPI呼び出し | シンプル、mutation状態管理、キャッシュ不要 |
| **queryClient.fetchQuery** | キャッシュが必要、複数API連携 | キャッシュ活用、React Query機能フル活用 |
**共通ルール**:
- `hc<RpcType>()`で型安全なクライアント生成
- `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<UseQueryOptions> = {
// 自動再フェッチを無効化
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<T = unknown>(overrides?: Partial<UseQueryOptions<T>>, customTimeout?: number): Partial<UseQueryOptions<T>> {
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<UseQueryOptions<T>>;
}
```
**キャッシュ戦略**:
- `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 (
<div>
<button onClick={() => onSubmit(formData)} disabled={isPending}>
{isPending ? '送信中...' : '送信'}
</button>
{isError && <p>エラーが発生しました</p>}
</div>
);
}
```
**パターン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<T = unknown>(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<typeof xxxRequestSchema>;
```
### Hono RPC型推論
```typescript
// route.ts
export type RpcType = typeof app;
// api-client
import type { RpcType } from '@/app/api/rpc/[...route]/route';
const rpcClient = hc<RpcType>(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連携