3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ラクスパートナーズAdvent Calendar 2024

Day 20

OpenAPIを使った開発でのコードジェネレーター Orvalの活用事例

Last updated at Posted at 2024-12-19

本記事はラクスパートナーズ Advent Calendar 2024の20日目の記事です。

日頃OpenAPIを使ってWeb開発をしています。
OpenAPIには周辺ツールが数多く公開されており、日々の開発でもいくつかのツールを利用しています。
今回はそのツールの1つのOrvalの活用について紹介したいと思います。

Orvalとは

まずは公式サイトを見てみましょう。

Generate client with appropriate type-signatures

Generate, valid, cache and mock in your frontend applications all with your OpenAPI specification.

公式サイトトップページから引用

見出しを直訳すると「適切な型シグネチャを持つクライアントを生成する」というメッセージに読めそうです。
続く説明文は「OpenAPI仕様を使って、フロントエンドアプリケーションで生成、検証、キャッシュ、モックを行う」と述べられています。

要約するとOrvalは、OpenAPIで書かれたWebAPIの仕様から、フロントエンド側でWebAPIを利用するために必要なTypeScriptで書かれたコードを生成してくれるツールです。

公式サイトにはOrvalを利用することに次のようなメリットがあると書かれています。(意訳を含みます)

  • APIをすぐ利用できて、APIを利用するコードを書く際の人為的なミスを防ぐことができる
  • チーム内に標準が確立され、認識齟齬がなくなり、UIに集中できる
  • モックを生成することでバックエンドの開発を待たなくても、アプリケーションの動作検証ができる

単にWebAPIを呼び出すコードを生成するだけでなく、フロントエンドでのWebAPIの利用に関わる実装の手間を全体的に解消しようとしているのがOrvalの特徴です。

Orvalの開発元について調べたところ、生みの親であるVictor Buryさんというベルギーのソフトウェアエンジニアの方を中心に、メンテナーやコントリビューターの方々によって開発されているようでした。

Orval自体もTypeScriptで書かれており、フロントエンド開発に携わる自分としてはなにかあったときにコードを読みやすいという点が嬉しいと感じました。

バージョン1.0.0のリリースが2020年1月12日で執筆時点ではリリースから5年が経ち、7.3.0が公開されています。

執筆時点でのGitHubスター数は3298で、利用状況についてはnpm trendsで確認したところ特に2023年ごろからかなり活発に利用されているようです。

npm_trends.png

作者の方はOpenAPIを使って開発をしていたときにswagger editorswagger codegenを使っていたそうですが、それではニーズを満たすことはできなかったのでOrvalを開発し始めたそうです。

Orvalで生成できるコードは主に以下のようなコードです。

  • WebAPIとの通信処理
  • 通信処理 + ReactQueryやSWRを使った状態管理
  • MSWを使った通信処理のモック
  • Zodを使ったスキーマ型の生成

以上がOrvalについての基本的な概要です。

活用事例

以降は筆者がOrvalを使って便利だと感じた使い方について紹介します。
Orvalの基本的な使い方については、公式ドキュメントGitHubリポジトリに実装サンプルがあるので本記事では説明しません。

利用したツールのバージョンは以下になります。

ツール名 バージョン
orval 7.3.0
@tanstack/react-query 5.62.8
storybook 8.4.7
msw 2.7.0
msw-storybook-addon 2.0.4
vitest 2.1.8

記事内に登場するサンプルコードは以下のGitHubリポジトリにアップしています。
https://github.com/yuichiyasui/orval-example

今回はサンプルコードではフレームワークとしてNext.jsを利用しますが、他のフレームワークやルーターでもOrvalの機能の利用にはあまり影響はないので気にしないでください。

またv6.28.0から、生成されたファイルに対してBiomeによる修正を行えるオプションが追加されているので、今回はフォーマッターとしてBiomeを利用します。

サンプルコードのOpenAPIは、OpenAPIの公式のサンプルとしてお馴染みのpetstoreを引用します。
VSCode拡張機能のSwagger Viewerでは以下のような構成になっていることを確認できます。

petstore.png

UIの実装で利用する

まずはUIの実装でのOrvalを利用します。
Orvalでは標準機能としてAxiosやFetch APIを使ったWebAPIとの通信処理と、React Query(tanstack-query)などをはじめとする状態管理ツールに通信処理を組み合わせたコードの生成をサポートしています。
また標準機能に加えてCustom HTTP clientとして上記以外の通信手段を独自に用意して自動生成されるコードの中で利用することもできます。

今回はFetch APIをベースにReact Queryを使ったクライアントサイドでのデータフェッチと、FetchAPIだけでRSCとして利用するパターンを紹介します。

まずはorval.config.tsを以下のように構成します。
今回はReact Queryを利用したいのでclientオプションにreact-queryを指定します。

orval.config.ts
import { defineConfig } from "orval";

const config = defineConfig({
  petstore: {
    input: {
      target: "./openapi/petstore.yaml",
    },
    output: {
      mode: "tags-split",
      target: "./src/__generated__",
      biome: true,
      client: "react-query",
      httpClient: "fetch",
      clean: true,
      override: {
        mutator: {
          path: "./src/libs/custom-fetch.ts",
          name: "customFetch",
        },
      },
    },
  },
});

export default config;

設定ファイルをTypeScriptで書けるのも嬉しいポイントです。

overrideオプションではFetch APIをカスタムしたcustomFetchを指定しています。
今回利用するcustomFetchは以下の公式のサンプルをベースに一部修正を加えました。

src/libs/custom-fetch.ts
// NOTE: Supports cases where `content-type` is other than `json`
const getBody = <T>(c: Response | Request): Promise<T> => {
  const contentType = c.headers.get("content-type");

  if (contentType && contentType.includes("application/json")) {
    return c.json();
  }

  if (contentType && contentType.includes("application/pdf")) {
    return c.blob() as Promise<T>;
  }

  return c.text() as Promise<T>;
};

const getUrl = (contextUrl: string): string => {
  const baseUrl = process.env.NEXT_PUBLIC_API_URL || "";
  const url = new URL(`${baseUrl}${contextUrl}`);

  return url.toString();
};

// NOTE: Add headers
const getHeaders = (headers?: HeadersInit): HeadersInit => {
  return {
    ...headers,
    "Content-Type": "application/json",
  };
};

export const customFetch = async <T>(
  url: string,
  options: RequestInit,
): Promise<T> => {
  const requestUrl = getUrl(url);
  const requestHeaders = getHeaders(options.headers);

  const requestInit: RequestInit = {
    ...options,
    headers: requestHeaders,
  };

  const request = new Request(requestUrl, requestInit);
  const response = await fetch(request);
  const data = await getBody<T>(response);

  if (!response.ok) {
    throw new Error(data as unknown as string);
  }

  return { status: response.status, data, headers: response.headers } as T;
};

サンプルコードには以下の観点で修正を加えました。

  • getUrl関数内でbaseUrlを環境変数から参照するようにする
  • customFetch関数内でレスポンスのHTTP Statusが異常を示した場合にエラーをthrowするようにする

このように必要な範囲で手を入れやすいような設計になっています。

自動生成は以下のようにpackage.jsonにコマンドを設定しておき、pnpm generateを実行します。

package.json
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "format": "biome check --write .",
+    "generate": "orval --config ./orval.config.ts",

コマンドを実行すると自動生成が実行され、以下のようなファイル構成になっていることが確認できます。

  ./
  ├── openapi
  │   └── petstore.yaml
  ├── orval.config.ts 
  ├── package.json
  ├── src
+ │   ├── __generated__
+ │   │   ├── pets
+ │   │   │   └── pets.ts
+ │   │   └── petstore.schemas.ts

生成されたコードを一部確認してみましょう。

src/__generated__/petstore.schemas.ts
/**
 * Generated by orval v7.3.0 🍺
 * Do not edit manually.
 * Petstore
 * OpenAPI spec version: 1.0.0
 */
export type ListPetsParams = {
  /**
   * How many items to return at one time (max 100)
   */
  limit?: number;
};

export interface Error {
  code: number;
  message: string;
}

export interface Pet {
  id: number;
  name: string;
  tag?: string;
}

/**
 * @maxItems 100
 */
export type Pets = Pet[];

src/__generated__/pets/pets.ts
/**
 * @summary List all pets
 */
export type listPetsResponse = {
  data: Pets;
  status: number;
  headers: Headers;
};

export const getListPetsUrl = (params?: ListPetsParams) => {
  const normalizedParams = new URLSearchParams();

  Object.entries(params || {}).forEach(([key, value]) => {
    if (value !== undefined) {
      normalizedParams.append(key, value === null ? "null" : value.toString());
    }
  });

  return normalizedParams.size
    ? `/pets?${normalizedParams.toString()}`
    : `/pets`;
};

export const listPets = async (
  params?: ListPetsParams,
  options?: RequestInit,
): Promise<listPetsResponse> => {
  return customFetch<Promise<listPetsResponse>>(getListPetsUrl(params), {
    ...options,
    method: "GET",
  });
};

export const getListPetsQueryKey = (params?: ListPetsParams) => {
  return [`/pets`, ...(params ? [params] : [])] as const;
};

export const getListPetsQueryOptions = <
  TData = Awaited<ReturnType<typeof listPets>>,
  TError = Error,
>(
  params?: ListPetsParams,
  options?: {
    query?: Partial<
      UseQueryOptions<Awaited<ReturnType<typeof listPets>>, TError, TData>
    >;
    request?: SecondParameter<typeof customFetch>;
  },
) => {
  const { query: queryOptions, request: requestOptions } = options ?? {};

  const queryKey = queryOptions?.queryKey ?? getListPetsQueryKey(params);

  const queryFn: QueryFunction<Awaited<ReturnType<typeof listPets>>> = ({
    signal,
  }) => listPets(params, { signal, ...requestOptions });

  return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
    Awaited<ReturnType<typeof listPets>>,
    TError,
    TData
  > & { queryKey: DataTag<QueryKey, TData> };
};

export type ListPetsQueryResult = NonNullable<
  Awaited<ReturnType<typeof listPets>>
>;
export type ListPetsQueryError = Error;

export function useListPets<
  TData = Awaited<ReturnType<typeof listPets>>,
  TError = Error,
>(
  params: undefined | ListPetsParams,
  options: {
    query: Partial<
      UseQueryOptions<Awaited<ReturnType<typeof listPets>>, TError, TData>
    > &
      Pick<
        DefinedInitialDataOptions<
          Awaited<ReturnType<typeof listPets>>,
          TError,
          TData
        >,
        "initialData"
      >;
    request?: SecondParameter<typeof customFetch>;
  },
): DefinedUseQueryResult<TData, TError> & {
  queryKey: DataTag<QueryKey, TData>;
};
export function useListPets<
  TData = Awaited<ReturnType<typeof listPets>>,
  TError = Error,
>(
  params?: ListPetsParams,
  options?: {
    query?: Partial<
      UseQueryOptions<Awaited<ReturnType<typeof listPets>>, TError, TData>
    > &
      Pick<
        UndefinedInitialDataOptions<
          Awaited<ReturnType<typeof listPets>>,
          TError,
          TData
        >,
        "initialData"
      >;
    request?: SecondParameter<typeof customFetch>;
  },
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> };
export function useListPets<
  TData = Awaited<ReturnType<typeof listPets>>,
  TError = Error,
>(
  params?: ListPetsParams,
  options?: {
    query?: Partial<
      UseQueryOptions<Awaited<ReturnType<typeof listPets>>, TError, TData>
    >;
    request?: SecondParameter<typeof customFetch>;
  },
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> };
/**
 * @summary List all pets
 */

export function useListPets<
  TData = Awaited<ReturnType<typeof listPets>>,
  TError = Error,
>(
  params?: ListPetsParams,
  options?: {
    query?: Partial<
      UseQueryOptions<Awaited<ReturnType<typeof listPets>>, TError, TData>
    >;
    request?: SecondParameter<typeof customFetch>;
  },
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> } {
  const queryOptions = getListPetsQueryOptions(params, options);

  const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
    queryKey: DataTag<QueryKey, TData>;
  };

  query.queryKey = queryOptions.queryKey;

  return query;
}

1つ目はAPIのスキーマの型定義になっていて、2つは通信処理の実装のコードになっています。

生成したコードの中にはgetListPetsQueryKeyのようにReact QueryのqueryKeyを規則的に生成してくれるコードが生成されます。
queryKeyを手動で運用するのは辛いので、個人的にはこれだけでもOrvalを利用するメリットは大いにあると感じています。

記事内では「動物リスト」のUIを作成しますが、生成されたコードの中でlistPets関数とuseListPetsフックの2つを利用します。

動物リストのUIを以下のように実装してみました。

src/components/pet-list.tsx
"use client";

import { useListPets } from "@/__generated__/pets/pets";

export const PetList = () => {
  const { data, isLoading, isError } = useListPets({
    limit: 10,
  });

  return (
    <table className="border-collapse border border-gray-300 text-sm">
      <caption className="font-bold text-left text-lg mb-2">動物リスト</caption>
      <thead className="border-b border-gray-300 bg-gray-50 text-left">
        <tr>
          <th className="p-2 w-10">ID</th>
          <th className="p-2 w-40">名前</th>
          <th className="p-2 w-40">ジャンル</th>
        </tr>
      </thead>
      <tbody>
        {(() => {
          if (isLoading) {
            return (
              <tr>
                <td colSpan={3} className="p-2 text-center">
                  読み込み中...
                </td>
              </tr>
            );
          }

          if (isError) {
            return (
              <tr>
                <td colSpan={3} className="p-2 text-center text-red-700">
                  エラーが発生しました
                </td>
              </tr>
            );
          }

          if (typeof data === "undefined" || data.data.length === 0) {
            return (
              <tr>
                <td colSpan={3} className="p-2 text-center">
                  見つかりませんでした
                </td>
              </tr>
            );
          }

          return data.data.map((d) => (
            <tr key={d.id} className="border-b border-gray-300 last:border-b-0">
              <td className="p-2">{d.id}</td>
              <td className="p-2">{d.name}</td>
              <td className="p-2">{d.tag}</td>
            </tr>
          ));
        })()}
      </tbody>
    </table>
  );
};

仕上がったUIはこんな感じです。

スクリーンショット 2024-12-19 17.33.21.png

通信部分について本来手で書くようなコードは、Orvalで生成されたコードにより隠蔽されていて、パラメーターやレスポンスにもしっかり型がついており、スマートかつ安全に利用できることがわかります。

今回Next.jsでサンプルを作成しているのでReact Server Componentでも実装してみました。

src/app/rsc/_components/pet-list.tsx
import { listPets } from "@/__generated__/pets/pets";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { PetListFallback } from "./pet-list-fallback";

export const PetList = () => {
  return (
    <table className="border-collapse border border-gray-300 text-sm">
      <caption className="font-bold text-left text-lg mb-2">動物リスト</caption>
      <thead className="border-b border-gray-300 bg-gray-50 text-left">
        <tr>
          <th className="p-2 w-10">ID</th>
          <th className="p-2 w-40">名前</th>
          <th className="p-2 w-40">ジャンル</th>
        </tr>
      </thead>
      <tbody>
        <ErrorBoundary FallbackComponent={PetListFallback}>
          <Suspense
            fallback={
              <tr>
                <td colSpan={3} className="p-2 text-center">
                  読み込み中...
                </td>
              </tr>
            }
          >
            <ListContent />
          </Suspense>
        </ErrorBoundary>
      </tbody>
    </table>
  );
};

const ListContent = async () => {
  const data = await listPets({ limit: 10 }, { next: { revalidate: 60 } });

  if (typeof data === "undefined" || data.data.length === 0) {
    return (
      <tr>
        <td colSpan={3} className="p-2 text-center">
          見つかりませんでした
        </td>
      </tr>
    );
  }

  return data.data.map((d) => (
    <tr key={d.id} className="border-b border-gray-300 last:border-b-0">
      <td className="p-2">{d.id}</td>
      <td className="p-2">{d.name}</td>
      <td className="p-2">{d.tag}</td>
    </tr>
  ));
};


src/app/rsc/_components/pet-list-fallback.tsx
"use client";

import { ComponentPropsWithoutRef } from "react";
import { ErrorBoundary } from "react-error-boundary";

type FallbackProps = ComponentPropsWithoutRef<
  Exclude<
    ComponentPropsWithoutRef<typeof ErrorBoundary>["FallbackComponent"],
    undefined
  >
>;

export const PetListByServerFallback = ({
  resetErrorBoundary,
}: FallbackProps) => (
  <tr>
    <td colSpan={3} className="p-2 text-center">
      <p className="text-red-700">エラーが発生しました</p>
      <div className="mt-2">
        <button type="button" onClick={resetErrorBoundary}>
          再読み込み
        </button>
      </div>
    </td>
  </tr>
);

RSCではコンポーネントの構成を変えてListContentの中でlistPets(Fetch APIのラッパー)を呼ぶようにしています。
listPetsにはNext.js独自のFetchAPIのオプションも渡せるので、キャッシュの制御も柔軟に行えるようになっています。

このようにReact Queryを使ったクライアントサイドでのデータフェッチも、RSCを使ったサーバーサイドでのデータフェッチでも手軽に通信処理を実装することができます。

実装時のWebAPIのモックについて

補足として、上記実装の通信部分の確認にはモックサーバーとしてPrismを利用しています。
後述するようなOrvalでMSWのハンドラーを生成してレスポンスをモックする方法もあります。
しかしNext.jsのようなハイブリッドなレンダリング方法を取るような場合だとセッティングが複雑になり、手元だとHydration Errorがうまく回避できなかったため、本記事では、Prismによるモックサーバーを利用するような形をとりました。

package.json
  "scripts": {
    "dev": "next dev --turbopack",
+    "dev:mock": "prism mock ./openapi/petstore.yaml & NEXT_PUBLIC_API_URL=http:localhost:4010 next dev --turbopack",

Next.jsでのMSWを使ったモックについて参考にさせていただいた記事

MSWのセットアップ

続いてモック周りの利用をする前にorval.config.tsを修正します。

orval.config.ts
import { defineConfig } from "orval";

const config = defineConfig({
  petstore: {
    input: {
      target: "./openapi/petstore.yaml",
    },
    output: {
      mode: "tags-split",
      target: "./src/__generated__",
      biome: true,
      client: "react-query",
      httpClient: "fetch",
      clean: true,
+     mock: {
+       type: "msw",
+       delay: false,
+       baseUrl: "http://localhost:8080",
+       useExamples: true,
+       generateEachHttpStatus: true,
+     },
      override: {
        mutator: {
          path: "./src/libs/custom-fetch.ts",
          name: "customFetch",
        },
      },
    },
  },
});

export default config;

typeについては現状mswしか指定できませんが今後Cypressが指定できるようになる模様です。
https://orval.dev/reference/configuration/output#type

delayについては、読み込み中の状態を確認しやすいというメリットはある一方、テストで利用する際に実行時間が伸びてしまい都合が悪いためデフォルトでは利用しないようにしています。

baseUrlについては、実際クライアントサイドからリクエストされるURLで固定しています。
ここを指定していないとテストでモックがうまく動きませんでした。

useExamplesについて、標準の挙動だとモック時のAPIのレスポンスはfaker-jsによって生成されるランダムな値となりますが、 useExamplestrueにするとOpenAPIのレスポンスのexampleに記載されている値がレスポンスとして返却されるようになります。

openapi/petstore.yaml
paths:
  /pets:
    get:
      summary: List all pets
      operationId: listPets
      tags:
        - pets
      parameters:
        - name: limit
          in: query
          description: How many items to return at one time (max 100)
          required: false
          schema:
            type: integer
            maximum: 100
            format: int32
      responses:
        '200':
          description: A paged array of pets
          headers:
            x-next:
              description: A link to the next page of responses
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pets'
+             example:
+               - id: 0
+                 name: ポチ
+                 tag: 
+               - id: 1
+                 name: タマ
+                 tag: 
+               - id: 2
+                 name: ペリー
+                 tag: 

generateEachHttpStatusについては有効化するとOpenAPIに記載のHTTP Statusのパターンごとにハンドラーを生成してくれます。
異常系のモックで利用したいため有効化します。

では生成されたコードを見てみましょう。

  ├── README.md
  ├── openapi
  │   └── petstore.yaml
  ├── orval.config.ts
  ├── package.json
  ├── pnpm-lock.yaml
  ├── src
  │   ├── __generated__
  │   │   ├── pets
+ │   │   │   ├── pets.msw.ts
  │   │   │   └── pets.ts
  │   │   └── petstore.schemas.ts

src/__generated__/pets/pets.msw.ts
/**
 * Generated by orval v7.3.0 🍺
 * Do not edit manually.
 * Petstore
 * OpenAPI spec version: 1.0.0
 */
import { faker } from "@faker-js/faker";
import { http, HttpResponse } from "msw";
import type { Error, Pet, Pets } from "../petstore.schemas";

export const getListPetsResponseMock = (): Pets => [
  { id: 0, name: "ポチ", tag: "" },
  { id: 1, name: "タマ", tag: "" },
  { id: 2, name: "ペリー", tag: "" },
];

export const getListPetsResponseMock200 = (): Pets => [
  { id: 0, name: "ポチ", tag: "" },
  { id: 1, name: "タマ", tag: "" },
  { id: 2, name: "ペリー", tag: "" },
];

export const getListPetsResponseMock500 = (
  overrideResponse: Partial<Error> = {},
): Error => ({
  code: faker.number.int({ min: undefined, max: undefined }),
  message: faker.string.alpha(20),
  ...overrideResponse,
});

export const getCreatePetsResponseMockDefault = (
  overrideResponse: Partial<Error> = {},
): Error => ({
  code: faker.number.int({ min: undefined, max: undefined }),
  message: faker.string.alpha(20),
  ...overrideResponse,
});

export const getShowPetByIdResponseMock = (
  overrideResponse: Partial<Pet> = {},
): Pet => ({
  id: faker.number.int({ min: undefined, max: undefined }),
  name: faker.string.alpha(20),
  tag: faker.helpers.arrayElement([faker.string.alpha(20), undefined]),
  ...overrideResponse,
});

export const getShowPetByIdResponseMock200 = (
  overrideResponse: Partial<Pet> = {},
): Pet => ({
  id: faker.number.int({ min: undefined, max: undefined }),
  name: faker.string.alpha(20),
  tag: faker.helpers.arrayElement([faker.string.alpha(20), undefined]),
  ...overrideResponse,
});

export const getShowPetByIdResponseMock500 = (
  overrideResponse: Partial<Error> = {},
): Error => ({
  code: faker.number.int({ min: undefined, max: undefined }),
  message: faker.string.alpha(20),
  ...overrideResponse,
});

export const getListPetsMockHandler = (
  overrideResponse?:
    | Pets
    | ((
        info: Parameters<Parameters<typeof http.get>[1]>[0],
      ) => Promise<Pets> | Pets),
) => {
  return http.get("http://localhost:8080/pets", async (info) => {
    return new HttpResponse(
      JSON.stringify(
        overrideResponse !== undefined
          ? typeof overrideResponse === "function"
            ? await overrideResponse(info)
            : overrideResponse
          : getListPetsResponseMock(),
      ),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  });
};

export const getListPetsMockHandler200 = (
  overrideResponse?:
    | Pets
    | ((
        info: Parameters<Parameters<typeof http.get>[1]>[0],
      ) => Promise<Pets> | Pets),
) => {
  return http.get("http://localhost:8080/pets", async (info) => {
    return new HttpResponse(
      JSON.stringify(
        overrideResponse !== undefined
          ? typeof overrideResponse === "function"
            ? await overrideResponse(info)
            : overrideResponse
          : getListPetsResponseMock200(),
      ),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  });
};

export const getListPetsMockHandler500 = (
  overrideResponse?:
    | Error
    | ((
        info: Parameters<Parameters<typeof http.get>[1]>[0],
      ) => Promise<Error> | Error),
) => {
  return http.get("http://localhost:8080/pets", async (info) => {
    return new HttpResponse(
      JSON.stringify(
        overrideResponse !== undefined
          ? typeof overrideResponse === "function"
            ? await overrideResponse(info)
            : overrideResponse
          : getListPetsResponseMock500(),
      ),
      { status: 500, headers: { "Content-Type": "application/json" } },
    );
  });
};

export const getCreatePetsMockHandler = (
  overrideResponse?:
    | void
    | ((
        info: Parameters<Parameters<typeof http.post>[1]>[0],
      ) => Promise<void> | void),
) => {
  return http.post("http://localhost:8080/pets", async (info) => {
    if (typeof overrideResponse === "function") {
      await overrideResponse(info);
    }
    return new HttpResponse(null, { status: 201 });
  });
};

export const getCreatePetsMockHandler201 = (
  overrideResponse?:
    | void
    | ((
        info: Parameters<Parameters<typeof http.post>[1]>[0],
      ) => Promise<void> | void),
) => {
  return http.post("http://localhost:8080/pets", async (info) => {
    if (typeof overrideResponse === "function") {
      await overrideResponse(info);
    }
    return new HttpResponse(null, { status: 201 });
  });
};

export const getCreatePetsMockHandlerDefault = (
  overrideResponse?:
    | Error
    | ((
        info: Parameters<Parameters<typeof http.post>[1]>[0],
      ) => Promise<Error> | Error),
) => {
  return http.post("http://localhost:8080/pets", async (info) => {
    return new HttpResponse(
      JSON.stringify(
        overrideResponse !== undefined
          ? typeof overrideResponse === "function"
            ? await overrideResponse(info)
            : overrideResponse
          : getCreatePetsResponseMockDefault(),
      ),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  });
};

export const getShowPetByIdMockHandler = (
  overrideResponse?:
    | Pet
    | ((
        info: Parameters<Parameters<typeof http.get>[1]>[0],
      ) => Promise<Pet> | Pet),
) => {
  return http.get("http://localhost:8080/pets/:petId", async (info) => {
    return new HttpResponse(
      JSON.stringify(
        overrideResponse !== undefined
          ? typeof overrideResponse === "function"
            ? await overrideResponse(info)
            : overrideResponse
          : getShowPetByIdResponseMock(),
      ),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  });
};

export const getShowPetByIdMockHandler200 = (
  overrideResponse?:
    | Pet
    | ((
        info: Parameters<Parameters<typeof http.get>[1]>[0],
      ) => Promise<Pet> | Pet),
) => {
  return http.get("http://localhost:8080/pets/:petId", async (info) => {
    return new HttpResponse(
      JSON.stringify(
        overrideResponse !== undefined
          ? typeof overrideResponse === "function"
            ? await overrideResponse(info)
            : overrideResponse
          : getShowPetByIdResponseMock200(),
      ),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  });
};

export const getShowPetByIdMockHandler500 = (
  overrideResponse?:
    | Error
    | ((
        info: Parameters<Parameters<typeof http.get>[1]>[0],
      ) => Promise<Error> | Error),
) => {
  return http.get("http://localhost:8080/pets/:petId", async (info) => {
    return new HttpResponse(
      JSON.stringify(
        overrideResponse !== undefined
          ? typeof overrideResponse === "function"
            ? await overrideResponse(info)
            : overrideResponse
          : getShowPetByIdResponseMock500(),
      ),
      { status: 500, headers: { "Content-Type": "application/json" } },
    );
  });
};
export const getPetsMock = () => [
  getListPetsMockHandler(),
  getCreatePetsMockHandler(),
  getShowPetByIdMockHandler(),
];

MSWで利用できるハンドラーを取得する関数が生成されていることがわかります。
getListPetsMockHandlerのようにそれぞれのハンドラーの取得関数は引数を受け取ることができ、引数を通じて任意のレスポンスを返すようにしたり中間処理を挟むことができます。

以降のセクションではこのハンドラーを使ってテストやUIのカタログ化をします。

テストコードで通信をモックする

本記事ではテストにはVitestを利用します。
先ほど作成したPetListコンポーネントをテストします。

以下が作成したテストコードです。

src/components/pet-list.test.tsx
import {
  getListPetsMockHandler,
  getListPetsMockHandler500,
} from "@/__generated__/pets/pets.msw";
import { renderWithProviders } from "@/utils/testing";
import { setupServer } from "msw/node";
import { PetList } from "./pet-list";
import { screen, waitFor, within } from "@testing-library/react";

const listPetsInterceptor = vi.fn();

const handlers = [getListPetsMockHandler()];

const server = setupServer(...handlers);

beforeAll(() => {
  server.listen();
});

afterEach(() => {
  server.resetHandlers();
  vi.clearAllMocks();
});

afterAll(() => {
  server.close();
});

test("動物リストが表示される", async () => {
  server.use(
    getListPetsMockHandler(({ request }) => {
      const url = new URL(request.url);
      listPetsInterceptor(url.searchParams.toString());

      return [{ id: 1, name: "ポチ", tag: "" }];
    }),
  );

  renderWithProviders(<PetList />);

  await waitFor(() => {
    expect(listPetsInterceptor).toHaveBeenCalled();
  });
  expect(listPetsInterceptor).toHaveBeenLastCalledWith("limit=10");

  const table = screen.getByRole("table", { name: "動物リスト" });
  expect(table).toBeInTheDocument();
  const rowgroup = within(table).getAllByRole("rowgroup");
  expect(rowgroup).toHaveLength(2);
  const thead = rowgroup[0];
  expect(thead).toBeInTheDocument();
  const tbody = rowgroup[1];
  expect(tbody).toBeInTheDocument();

  const ths = within(thead).getAllByRole("columnheader");
  expect(ths).toHaveLength(3);
  expect(ths[0]).toHaveTextContent("ID");
  expect(ths[1]).toHaveTextContent("名前");
  expect(ths[2]).toHaveTextContent("ジャンル");

  const rows = within(tbody).getAllByRole("row");
  expect(rows).toHaveLength(1);
  const cells = within(rows[0]).getAllByRole("cell");
  expect(cells).toHaveLength(3);
  expect(cells[0]).toHaveTextContent("1");
  expect(cells[1]).toHaveTextContent("ポチ");
  expect(cells[2]).toHaveTextContent("");
});

test("動物リストが見つからない場合にメッセージが表示される", async () => {
  server.use(
    getListPetsMockHandler(({ request }) => {
      const url = new URL(request.url);
      listPetsInterceptor(url.searchParams.toString());

      return [];
    }),
  );

  renderWithProviders(<PetList />);

  await waitFor(() => {
    expect(listPetsInterceptor).toHaveBeenCalled();
  });
  expect(listPetsInterceptor).toHaveBeenLastCalledWith("limit=10");

  const notFound = screen.getByText("見つかりませんでした");
  expect(notFound).toBeInTheDocument();
});

test("エラー時にエラーメッセージが表示される", async () => {
  server.use(
    getListPetsMockHandler500(({ request }) => {
      const url = new URL(request.url);
      listPetsInterceptor(url.searchParams.toString());
      
      return { code: 500, message: "Internal Server Error" };
    }),
  );

  renderWithProviders(<PetList />);

  await waitFor(() => {
    expect(listPetsInterceptor).toHaveBeenCalled();
  });
  expect(listPetsInterceptor).toHaveBeenLastCalledWith("limit=10");

  const error = screen.getByText("読み込みに失敗しました");
  expect(error).toBeInTheDocument();
});

先頭部分でserverをセットアップして、各テストケースではレスポンスを上書きする形で利用しています。

またMSWのハンドラーの中でモック関数を呼び出すようにすることで、APIが呼び出されたかどうかとその時のパラメーターが期待した値になっているかテストするようにしています。

test("動物リストが表示される", async () => {
  server.use(
    getListPetsMockHandler(({ request }) => {
      const url = new URL(request.url);
      listPetsInterceptor(url.searchParams.toString());
      
      return [{ id: 1, name: "ポチ", tag: "" }];
    }),
  );

  renderWithProviders(<PetList />);

  await waitFor(() => {
    expect(listPetsInterceptor).toHaveBeenCalled();
  });
  expect(listPetsInterceptor).toHaveBeenLastCalledWith("limit=10");

レスポンスを上書きする際も型が効くので使いやすいと感じました。

Storybookで通信をモックする

続いてはStorybookを使ったUIのカタログ化で自動生成されたMSWのコードを利用します。
本記事ではStorybookでMSWを利用するのにmsw-storybook-addonを利用します。
アドオンの導入手順については本記事では割愛します。

下記はPetListコンポーネントに関するストーリーです。

src/components/pet-list.stories.tsx
import {
  getListPetsMockHandler,
  getListPetsMockHandler500,
  getListPetsResponseMock,
} from "@/__generated__/pets/pets.msw";
import { PetList } from "./pet-list";
import { Meta, StoryObj } from "@storybook/react";
import { delay } from "msw";

const meta = {
  component: PetList,
} satisfies Meta<typeof PetList>;

export default meta;

type Story = StoryObj<typeof PetList>;

const handlers = {
  success: [getListPetsMockHandler()],
  loading: [
    getListPetsMockHandler(async () => {
      await delay(2000);
      return getListPetsResponseMock();
    }),
  ],
  empty: [getListPetsMockHandler(() => [])],
  error: [
    getListPetsMockHandler500(async () => {
      await delay(2000);
      return { code: 500, message: "Internal Server Error" };
    }),
  ],
};

export const Default = {
  parameters: {
    msw: {
      handlers: handlers.success,
    },
  },
} satisfies Story;

export const Loading = {
  parameters: {
    msw: {
      handlers: handlers.loading,
    },
  },
} satisfies Story;

export const Empty = {
  parameters: {
    msw: {
      handlers: handlers.empty,
    },
  },
} satisfies Story;

export const Error = {
  parameters: {
    msw: {
      handlers: handlers.error,
    },
  },
} satisfies Story;

ポイントとしては読み込み中とエラーのストーリーには読み込み中の状態からの移行が確認しやすいようにdelayを入れています。

Loading
ローディング

Error
エラー

コンポーネント内で通信処理があるコンポーネントもカタログ化することができました。
特にエラー時のUIの状態などはモックの手段が整っていないと再現が難しかったりするので、エンジニア以外のメンバーにプロダクトの挙動を説明したい場合などにも役に立つと思います。

まとめ

今回は主にWebAPIとの通信部分やそのモックでの利用事例を紹介しましたが、OrvalではZodと組み合わせたHonoのエンドポイントの生成をサポートするなど、フロントエンドの域を超えて型安全な通信手段を提供しようとしているように思いました。

またcustom clientをうまく使えばより柔軟にユースケースに合わせてコード生成ができます。
実例として業務ではk6を使った負荷試験でもサーバーサイドでOrvalを利用しているシーンがありました。

実際利用してみてかなり使いやすいツールだと感じたので、今後もOpenAPIを利用した開発でのコードジェネレーターの有力な選択肢の1つとして動向を追っていきたいと思います。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?