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):

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呼び出し開始:', { ... });

推奨ログポイント

  1. リクエスト受信時: メタデータ(URLカウント、ダミーモード等)
  2. 分岐処理時: ダミーモード使用、フォールバック等
  3. 外部API呼び出し前後: 開始と完了
  4. エラー発生時: エラー詳細とスタックトレース
  5. レスポンス返却時: 成功メッセージ

型安全性の確保

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順序

  1. 外部ライブラリ
  2. 内部モジュール(@/
  3. 相対パス(使用禁止)

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

まとめ

このパターンに従うことで、以下のメリットが得られます。

  1. 型安全性: Zod + Hono RPCによるエンドツーエンドの型推論
  2. 保守性: 統一された構造とログ出力
  3. テスト性: ダミーモードによる開発効率向上
  4. 拡張性: 新しいAPIの追加が容易
  5. エラー処理: 一貫したエラーハンドリング
  6. 柔軟性: 用途に応じた2つの実装パターン
    • useMutation: シンプルな単発実行
    • queryClient.fetchQuery: キャッシュ活用と複数API連携