Spaces:
Sleeping
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):
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):
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()のようにメソッドチェーンで書く(型推論のため)
メソッドチェーンの重要性:
// ✅ 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ルート統合
実装例:
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):
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):
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で使用する共通設定:
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)の使用例:
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)の使用例:
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 を使用します。
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 に以下の関数を提供しています。
/**
* ダミーデータファイルを読み込む
* @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;
エラーハンドリング
エラーレスポンス形式
{
status: 'error',
message: string // ユーザー向けエラーメッセージ(日本語)
}
HTTPステータスコード
400 Bad Request: バリデーションエラー、API論理エラー404 Not Found: リソースが見つからない408 Request Timeout: タイムアウト500 Internal Server Error: サーバー内部エラー
エラーメッセージの日本語化
if (error instanceof Error) {
errorMessage = error.message;
if (error.message.includes('タイムアウト') || error.message.includes('timeout')) {
errorMessage = 'タイムアウトが発生しました。もう一度やり直してください。';
statusCode = 408;
}
}
ログ出力規約
フォーマット
[API_NAME] アクション: 詳細情報
例
console.log('[CHECK_URL API] Request metadata:', { ... });
console.error('[CHECK_URL API] Error:', error);
console.log('[useCheckUrl] API呼び出し開始:', { ... });
推奨ログポイント
- リクエスト受信時: メタデータ(URLカウント、ダミーモード等)
- 分岐処理時: ダミーモード使用、フォールバック等
- 外部API呼び出し前後: 開始と完了
- エラー発生時: エラー詳細とスタックトレース
- レスポンス返却時: 成功メッセージ
型安全性の確保
Zodスキーマ → TypeScript型
export const xxxRequestSchema = z.object({ ... });
export type XxxRequest = z.infer<typeof xxxRequestSchema>;
Hono RPC型推論
// 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;
これにより、エンドポイント名や型が自動補完され、タイプミスを防止できます。
パス指定ルール
インポート
- 禁止: 相対パス (
../../../) - 必須: エイリアス (
@/)
// ❌ Bad
import { getCheckUrl } from '../../../services/gradio';
// ✅ Good
import { getCheckUrl } from '@/services/gradio';
Node.js組み込みモジュール
// Biomeルール準拠
import * as fs from 'node:fs';
import * as path from 'node:path';
コーディング規約
import順序
- 外部ライブラリ
- 内部モジュール(
@/) - 相対パス(使用禁止)
any型の禁止
// ❌ Bad
const data: any = await response.json();
// ✅ Good
const data = (await response.json()) as CheckUrlResponse;
Prettierによる自動フォーマット
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
まとめ
このパターンに従うことで、以下のメリットが得られます。
- 型安全性: Zod + Hono RPCによるエンドツーエンドの型推論
- 保守性: 統一された構造とログ出力
- テスト性: ダミーモードによる開発効率向上
- 拡張性: 新しいAPIの追加が容易
- エラー処理: 一貫したエラーハンドリング
- 柔軟性: 用途に応じた2つの実装パターン
- useMutation: シンプルな単発実行
- queryClient.fetchQuery: キャッシュ活用と複数API連携