⚔️

ZodとNext.jsのRoute Handlersから型安全なAPIクライアントを生成する最強ライブラリ「FrourioNext」

に公開

はじめに

以前の記事「Zodと設定0行でNext.jsのRoute Handlersに完全な型を付与する最強ライブラリ「FrourioNext」」では、Next.js の Route Handlers における型安全性の課題と、それを解決する FrourioNext の基本的な機能について紹介しました。

前回の記事を公開した時点ではいくつかの機能が未実装でしたが、現在のv0.9.3ですべて実装され、より強力なライブラリへと進化しています。

今回の記事では、その FrourioNext の中でも特に強力な クライアント生成機能 に焦点を当て、その使い方とメリットを詳しく解説していきます。

Next.js の App Router で API を開発する際、Route Handlers は非常に便利ですが、フロントエンドからその API を呼び出す際の型安全性は別途確保する必要があります。OpenAPI スキーマからクライアントを生成する方法もありますが、スキーマ定義と実装が乖離してしまうリスクや、セットアップの手間が課題でした。

FrourioNext は、Zod を使って Route Handlers のリクエスト/レスポンススキーマを定義するだけで、型安全な API クライアントコードを自動生成してくれる画期的なライブラリです。バックエンド (route.ts) とフロントエンド間の型整合性を FrourioNext が保証してくれるため、開発者はより本質的なロジックの実装に集中できます。

FrourioNext のクライアント生成機能

FrourioNext の中核となる機能の一つが、frourio.ts ファイルに Zod で定義された API 仕様から、型安全なクライアントコード (.client.ts) を自動生成する機能です。

  • 型安全な API 呼び出し: 生成されたクライアント関数は、リクエストパラメータやレスポンスの型を TypeScript が静的にチェックしてくれます。これにより、タイプミスや型の不一致による実行時エラーを未然に防ぐことができます。Zod によるランタイムバリデーションも組み込まれており、予期せぬレスポンス形式にも対応できます。
  • 入力補完: エディタの入力補完機能により、利用可能な API エンドポイントや必要なパラメータ(パスパラメータ、クエリ、ボディなど)を簡単に確認できます。
  • リファクタリング耐性: API の仕様変更(パスパラメータの変更、リクエスト/レスポンスの型の変更など)があった場合、再度コード生成を実行するだけでクライアントコードも自動的に更新されます。これにより、変更漏れによるバグを防ぎ、安全かつ効率的なリファクタリングが可能になります。

クライアントの使い方

FrourioNext は、各 API ルートに対応する .client.ts ファイルと、プロジェクト全体の API クライアントを集約したルートの .client.ts ファイル (例: app/frourio.client.ts) を生成します。

ルートの .client.ts は、主に2つのクライアントファクトリ関数をエクスポートします。

  • $fc: High-Level Client。API 呼び出しが成功した場合、レスポンスボディを直接返します。API エラー (4xx, 5xx) やレスポンスのバリデーションエラーが発生した場合は例外をスローします。通常のフロントエンドコンポーネントでの利用に適しています。
  • fc: Low-Level Client。API 呼び出しの結果を詳細なオブジェクトで返します。HTTP ステータス、レスポンスの妥当性 (Zod バリデーション結果)、成功/失敗データ、エラー理由、生の Response オブジェクトなどが含まれます。エラーハンドリングをより細かく制御したい場合に便利です。

クライアントインスタンスの作成

通常、アプリケーション全体で共有するクライアントインスタンスを作成します。

lib/apiClient.ts
import { $fc } from '../app/frourio.client'; // 生成されたルートクライアントをインポート

// High-Level Client インスタンスを作成
export const apiClient = $fc({
  // 必要に応じてオプションを指定
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '/api', // APIのベースURL
  // fetch 関数に渡すデフォルトオプション
  init: {
    headers: {
      'X-Custom-Header': 'my-value',
    },
  },
  // カスタム fetch 関数を指定することも可能
  // fetch: customFetch,
});

// Low-Level Client が必要な場合は同様に作成
// import { fc } from '../app/frourio.client';
// export const lowLevelApiClient = fc({ ... });

API の呼び出し (High-Level Client)

作成した apiClient を使って API を呼び出します。API のパス構造がオブジェクトのプロパティアクセスに対応します。

components/MyComponent.tsx
import { apiClient } from '../lib/apiClient';
import { useEffect, useState } from 'react';

// 例: /api/users API (frourio.ts で定義されている想定)
// GET /api/users
// POST /api/users
// GET /api/users/[userId] (ファイルパス: app/api/users/[userId]/route.ts)

type User = { id: number; name: string };

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        // GET /api/users/{userId}
        // パスパラメータは params で指定 (キーはファイルシステムの [userId] に対応)
        const userData = await apiClient.users['[userId]'].$get({
          params: { userId }, // params のキーは frourio.ts の param 定義に従う
          // クエリパラメータがある場合は query で指定
          // query: { includeDetails: true },
          // 個別の fetch オプションも指定可能
          // init: { cache: 'no-store' }
        });
        setUser(userData);
      } catch (err) {
        console.error(err);
        setError('ユーザー情報の取得に失敗しました。');
      }
    };
    fetchUser();
  }, [userId]);

  const handleCreateUser = async (name: string) => {
    try {
      // POST /api/users
      // リクエストボディは body で指定
      const newUser = await apiClient.users.$post({
        body: { name },
      });
      console.log('新しいユーザーが作成されました:', newUser);
      // TODO: UI 更新など
    } catch (err) {
      console.error(err);
      alert('ユーザーの作成に失敗しました。');
    }
  };

  // ... render logic ...
}

$get, $post, $put, $delete, $patch などのメソッドが用意されており、それぞれ対応する HTTP メソッドで API を呼び出します。引数のオブジェクトには params, query, body, headers, init などを指定できます。どのプロパティが必要かは frourio.ts の定義に基づいて型チェックされます。

API の呼び出し (Low-Level Client)

Low-Level Client (fc) を使う場合、戻り値のオブジェクトをチェックして処理を分岐します。

import { lowLevelApiClient } from '../lib/apiClient'; // fc で作成したクライアント

async function fetchUsersWithLowLevel() {
  const result = await lowLevelApiClient.users.$get({ query: { limit: 10 } });

  if (result.ok && result.isValid) {
    // 成功 (2xx) かつレスポンス形式が Zod スキーマと一致
    // result.data は { status: number, headers?: object, body?: any } 形式
    console.log('Users:', result.data.body);
    console.log('Status:', result.data.status); // 例: 200
    console.log('Raw Response:', result.raw); // 生の Response オブジェクト
  } else if (!result.ok && result.isValid) {
    // API エラー (4xx, 5xx) だがレスポンス形式は Zod スキーマと一致 (エラーレスポンスの型定義がある場合)
    // result.failure は { status: number, headers?: object, body?: any } 形式
    console.error('API Error Body:', result.failure.body);
    console.error('Status:', result.failure.status); // 例: 404
    console.log('Raw Response:', result.raw);
  } else if (!result.isValid && result.reason instanceof z.ZodError) {
    // バリデーションエラー
    if (result.raw) {
      // レスポンスのバリデーションエラー (fetch は成功)
      // result.ok は true (2xx) または false (4xx, 5xx)
      console.error('Response Validation Error:', result.reason);
      console.log('Status:', result.raw.status);
      console.log('Raw Response:', result.raw);
    } else {
      // リクエストパラメータ (params, query, headers, body) のバリデーションエラー (fetch 前に失敗)
      // result.ok, result.raw などは undefined
      console.error('Request Validation Error:', result.reason);
    }
  } else if (result.error) {
    // ネットワークエラーなど、fetch 自体が失敗した場合
    // result.ok, result.isValid は undefined の可能性がある
    console.error('Fetch Error:', result.error);
    // result.raw は通常 undefined だが、カスタム fetch 関数によっては存在する可能性もある
  } else {
    // 上記以外の予期せぬケース (通常は発生しないはず)
    console.error('Unknown Error:', result);
  }
}

Low-Level Client は、より詳細なレスポンス情報に基づいて処理を行いたい場合や、エラーハンドリングを独自に実装したい場合に有効です。

その他の便利な機能

URL の型安全な生成 ($url)

API を直接呼び出すのではなく、その URL 文字列だけを型安全に取得したい場合があります。例えば、<Link> コンポーネントの href に指定する場合などです。

クライアントオブジェクトには $url プロパティがあり、これを使うとパスパラメータやクエリパラメータを含んだ URL を型安全に生成できます。

High-Level Client ($fc) の $url は、パラメータ (params, query) が Zod スキーマに違反する場合に例外 (ZodError) をスローします。

import { apiClient } from '../lib/apiClient'; // $fc で作成したクライアント

// GET /api/users/[userId]?tab=profile
const userId = 123;
try {
  // アクセスキーはファイルシステムの '[userId]' に対応
  const userProfileUrl = apiClient.users['[userId]'].$url.get({
    params: { userId }, // params のキーは frourio.ts の param 定義に従う
    query: { tab: 'profile' }, // frourio.ts で定義された query のみ許可
  });
  // userProfileUrl は "/api/users/123?tab=profile" のような文字列になる (baseURL が /api の場合)
  console.log(userProfileUrl);
} catch (err) {
  // パラメータの型が間違っている場合 (例: userId: 'abc') や、
  // frourio.ts で定義されていないクエリパラメータを指定した場合などはここで ZodError がスローされる
  console.error(err);
}

一方、Low-Level Client (fc) の $url は、パラメータが不正な場合でも例外をスローせず、結果オブジェクト ({ isValid: boolean, data?: string, reason?: ZodError }) を返します。

import { lowLevelApiClient } from '../lib/apiClient'; // fc で作成したクライアント

// パラメータが正しい場合
const validResult = lowLevelApiClient.users['{userId}'].$url.get({
  params: { userId: 123 },
  query: { tab: 'profile' },
});
if (validResult.isValid) {
  console.log(validResult.data); // "/api/users/123?tab=profile"
}

// パラメータが不正な場合 (例: query に未定義のパラメータ)
const invalidResult = lowLevelApiClient.users['{userId}'].$url.get({
  params: { userId: 123 },
  query: { invalidParam: true }, // frourio.ts で定義されていないクエリ
});
if (!invalidResult.isValid) {
  console.error('URL generation failed:', invalidResult.reason); // ZodError
}

データフェッチライブラリとの連携 ($build)

SWR や TanStack Query (旧 React Query) などのデータフェッチライブラリと連携する場合、キャッシュキーとフェッチャー関数を渡すのが一般的です。FrourioNext クライアントの $build メソッドは、この形式に合わせたデータ構造を簡単に生成するのに役立ちます。

import useSWR from 'swr';
import { apiClient } from '../lib/apiClient';

function UserList() {
  // apiClient.users.$build({ query: { limit: 20 } }) は以下のタプルを返す:
  // [
  //   { dir: '/users', query: { limit: 20 } }, // キャッシュキーの一部となるオブジェクト
  //   () => apiClient.users.$get({ query: { limit: 20 } }) // フェッチャー関数
  // ]
  const [key, fetcher] = apiClient.users.$build({ query: { limit: 20 } });

  const { data: users, error } = useSWR(key, fetcher);

  if (error) return <div>Error loading users</div>;
  if (!users) return <div>Loading...</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

$build は、第一要素に API パスとパラメータを含むオブジェクト (キャッシュキーとして利用可能)、第二要素に High-Level Client ($fc) を使ったフェッチャー関数を返します。これにより、データフェッチライブラリとの連携コードを簡潔かつ型安全に記述できます。

ストリーミングレスポンスの扱い

API が Server-Sent Events (SSE) などのストリーミングレスポンスを返す場合も、FrourioNext は対応しています。

route.tsReadableStream を持つ Response オブジェクトを直接返し、対応する frourio.ts では res の定義を省略します。FrourioNext は res が省略された場合、クライアント側でレスポンスボディをパースしようとせず、生の Response オブジェクトをそのまま扱います。

app/api/chat/frourio.ts
import type { FrourioSpec } from '@frourio/next';
import { z } from 'zod';

// res 定義を省略する
export const frourioSpec = {
  post: {
    body: z.object({ prompt: z.string() }),
    // res: { ... } を書かない
  },
} satisfies FrourioSpec;
app/api/chat/route.ts
import { createRoute } from './frourio.server';

export const { POST } = createRoute({
  post: async ({ body }) => {
    // ここで直接 Response を返す (FrourioSpec の res で型定義しない)
    const stream = new ReadableStream({
      async start(controller) {
        // 例: 1秒ごとにメッセージを送信 (Server-Sent Events 形式)
        const encoder = new TextEncoder();
        let i = 0;
        const intervalId = setInterval(() => {
          const message = `id: ${i}\ndata: Hello ${i++}\n\n`; // SSE形式
          controller.enqueue(encoder.encode(message));
          if (i > 5) {
            clearInterval(intervalId);
            controller.close();
          }
        }, 1000);
      },
    });

    return new Response(stream, {
      status: 200,
      headers: {
        'Content-Type': 'text/event-stream', // SSE の標準 MIME タイプ
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
      },
    });
  },
});

クライアント側では、Low-Level Client (fc) を使用します。frourio.tsres を定義していないため、成功時の result.data には生の Response オブジェクトが直接格納されます (result.isValid は常に true となります)。API エラー (4xx, 5xx) の場合も、result.failure に生の Response オブジェクトが格納される可能性があります。

import { lowLevelApiClient } from '../lib/apiClient';

async function handleStream() {
  const result = await lowLevelApiClient.chat.$post({ body: { prompt: 'こんにちは' } });

  // frourio.ts で res を定義していない場合:
  // - 成功時(2xx): result.ok=true, result.isValid=true, result.data=Response
  // - APIエラー時(4xx, 5xx): result.ok=false, result.isValid=true, result.failure=Response
  // - リクエストバリデーションエラー時: result.isValid=false, result.reason=ZodError
  // - fetchエラー時: result.error=Error

  if (result.ok && result.isValid && result.data instanceof Response) {
    // 成功 (2xx)
    const response = result.data; // 生の Response オブジェクト
    console.log('Stream started successfully. Status:', response.status);
    const reader = response.body?.getReader();
    if (!reader) return;

    const decoder = new TextDecoder();
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        const chunk = decoder.decode(value, { stream: true }); // stream: true を推奨
        console.log('Received chunk:', chunk);
        // TODO: UI にストリーミング表示するなど (例: Server-Sent Events のパース)
      }
      console.log('Stream finished.');
    } catch (error) {
      console.error('Stream reading error:', error);
    } finally {
      reader.releaseLock();
    }
  } else if (!result.ok && result.isValid && result.failure instanceof Response) {
    // API エラー (4xx, 5xx)
    const errorResponse = result.failure;
    console.error('API Error Status:', errorResponse.status);
    const errorText = await errorResponse.text(); // エラーレスポンスのボディを読む
    console.error('API Error Body:', errorText);
  } else if (!result.isValid) {
    // リクエストバリデーションエラー
    console.error('Request Validation Error:', result.reason);
  } else if (result.error) {
    // fetch エラー
    console.error('Fetch Error:', result.error);
  } else {
    // その他の予期せぬエラー
    console.error('Failed to start stream:', result);
  }
}

このように、Low-Level Client の戻り値を詳細にチェックし、result.data または result.failureResponse インスタンスの場合に ReadableStream を取得してデータを処理します。

まとめ

FrourioNext は、Zod によるスキーマ定義から型安全な API クライアントを自動生成することで、Next.js App Router での API 開発体験を大幅に向上させます。

  • 開発効率の向上: 面倒なクライアントコードの手書きやスキーマ同期作業から解放されます。
  • 品質の向上: コンパイル時および実行時の型チェックにより、フロントエンドとバックエンド間の型の不整合に起因するバグを削減します。
  • メンテナンス性の向上: API 仕様の変更に強く、リファクタリングを容易にします。

High-Level Client と Low-Level Client、URL 生成、データフェッチライブラリ連携、ストリーミング対応など、豊富な機能を提供しており、様々なユースケースに対応可能です。

Next.js で型安全かつ効率的な API 開発を目指すなら、FrourioNext は間違いなく検討すべき選択肢の一つです。ぜひ導入して、その強力な機能を体験してみてください。

Discussion