🦈

orvalでexamplesに定義した全てのレスポンスのモックを生成したい

2024/04/29に公開

こんにちは!
最近orvalを使い始めた者です。

クライアントまで生成できる&カスタマイズできる範囲も広いことが非常に気に入っているのですが、1点だけつらみを感じていました。
それが、mockを1パターンしか生成してくれない[1]事です。

↑の理由からMockの生成は利用できずにいたのですが、実現できたためまとめます。

環境

  • orval 6.28.2
  • msw 2.2.14

やりたいこと

以下のようなSchemaを例として、example, examplesに定義した全てのパターンのモックを生成することを目指します。

schema.yaml
schema.yaml
openapi: 3.0.0
info:
  title: Sample API
  version: 0.0.0
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT
servers:
  - url: http://xxxxxxxxx
paths:
  /users:
    get:
      summary: Returns a list of users.
      operationId: "get-users"
      description: "description"
      tags:
        - users
      responses:
        "200":
          description: A JSON array of user names
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
              examples:
                default: # Refを使うパターン
                  $ref: "#/components/examples/GetUsersResponseExample"
                filterd: # 直接書くパターン
                  value:
                    - "userC"
                empty:
                  value: []
        "403":
          description: Error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples: # Errorを複数定義するパターン
                forbidden:
                  value:
                    statusCode: 403
                    errorMessage: "Forbidden"
                permission_denied:
                  value:
                    statusCode: 403
                    errorMessage: "Permission denied"
  /users/{id}:
    get:
      summary: Returns a user by ID.
      operationId: "get-users-detail"
      description: "description"
      tags:
        - users
      parameters:
        - name: id
          in: path
          description: ID of the user to fetch
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Expected response to a valid request
          content:
            application/json:
              schema:
                type: string
              example: "userA" # 1件だけ定義するパターン
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                statusCode: 404
                errorMessage: "User not found"

components:
  schemas:
    ErrorResponse:
      type: object
      properties:
        statusCode:
          type: number
        errorMessage:
          type: string
  examples:
    GetUsersResponseExample:
      value:
        - userD
        - userE
        - userF

できたもの

以下のような形でClientMockBuilderを定義し、orval.config.tsoutput.mockへ渡す事で実現できます。

ただし、以下の注意点があります。

  • ステータスコードは成功/失敗それぞれで1種類しか利用できない
    • 例: 401, 403などのexamplesをそれぞれ定義してもモックは401しか生成されない😢
  • 型が怪しい箇所がある
    • OrvalのConfigExternalの型が実態とずれているのかもしれない...?
generateCustomMockImpl.ts
generateCustomMockImpl.ts
import type { ClientMockBuilder } from "@orval/core";

type Example = {
  body: any;
  contentType: string;
  statusCode: number;
};

/**
 * Mockの実装(string)を返す
 * examplesは全てconstへ吐き出す
 * 生成されるhandlerは以下のparamsを受け取る
 *  - name: keyof typeof examples | undefined (default: examplesの最初のkey)
 *  - delayMs: number | undefined (default: 1000)
 */
const generateMockImplementation = ({
  method,
  mockPath,
  handlerName,
  exampleName,
  examples,
}: {
  method: string;
  mockPath: string;
  handlerName: string;
  exampleName: string;
  examples: Record<string, Example>;
}) =>
  `
export const ${exampleName} = ${JSON.stringify(examples)} as const
export const ${handlerName} = (name: keyof typeof ${exampleName} | undefined = '${Object.keys(
    examples,
  ).shift()}', delayMs: number | undefined = 1000) => {
  return http.${method}('${mockPath}', async (_info) => {
    const example = ${exampleName}[name]
    await delay(delayMs);
    return new HttpResponse(JSON.stringify(example.body), {
      status: Number(example.statusCode),
      headers: { "Content-Type": example.contentType},
    });
  });
}\n
` as const;

/**
 * Mockの実装を生成する
 */
export const generateCustomMockImpl: ClientMockBuilder = (verbOptions) => {
  const method = verbOptions.verb.toLocaleLowerCase();
  // operationIdの記号を置換&キャメルケースに変換
  const operationId = verbOptions.operationId
    .replace(/{|}|:/g, "")
    .replace(/-/g, "_")
    .replace(/_./g, (s) => {
      return s.charAt(1).toUpperCase();
    });

  const handlerName = `${operationId}MockHandler`;
  const exampleName = `${operationId}Examples`;

  // /{pathParam} -> /:pathParam
  const mockPath = `${verbOptions.pathRoute.replace(/{([^}]*)}/g, ":$1")}`;

  // examples, statusCode, contentTypeのパターンを取得
  // verbOptions.response.typesにはorvalで処理されたresponseのパターンが入る
  const examples = Object.entries(verbOptions.response.types)
    .flatMap(([type, responses]) =>
      responses.flatMap((response) => {
        const baseExample = {
          contentType: response.contentType,
          statusCode: Number(response.key),
        };
        if (response.examples) {
          // examplesを使ったパターン
          return Object.entries(response.examples).map(
            ([exampleKey, example]) => ({
              ...baseExample,
              name: exampleKey,
              body:
                // refを使った場合、exampleにレスポンスボディが入っている(その他の場合はyamlの構造通りexample.valueにレスポンスボディが入る)
                Object.keys(example).length === 1 && "value" in example
                  ? example.value
                  : example,
            }),
          );
        }
        if (response.example) {
          // exampleを使ったパターン
          return {
            ...baseExample,
            name: type,
            body:
              Object.keys(response.example).length === 1 &&
              "value" in response.example
                ? response.example.value
                : response.example,
          };
        }
      }),
    )
    // nameをkeyにしたオブジェクトに変換
    .reduce(
      (acc, example) => {
        if (example) {
          acc[example.name] = {
            body: example.body,
            contentType: example.contentType,
            statusCode: example.statusCode,
          };
        }
        return acc;
      },
      {} as Record<string, Example>,
    );

  if (Object.keys(examples).length === 0) {
    // Exampleが定義されていない場合は空オブジェクトを返すように設定しておく
    examples["default"] = {
      body: {},
      contentType: "application/json",
      statusCode: 200,
    };
  }

  const handlerImplementation = generateMockImplementation({
    method,
    mockPath,
    handlerName,
    exampleName,
    examples,
  });

  return {
    imports: verbOptions.response.imports,
    implementation: {
      function: "",
      handlerName: handlerName,
      handler: handlerImplementation,
    },
  } as any; // ConfigExternalの型と一致しないのでanyを使っている
};

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

export default defineConfig({
  api: {
    ...
    output: {
      ...
      mock: generateCustomMockImpl,
    },
  },
});

#やりたいことで記載したAPISchemaで生成すると、以下のようなmockが生成されます🥳

users.msw.ts
users.msw.ts
/**
 * Generated by orval v6.28.2 🍺
 * Do not edit manually.
 * Sample API
 * OpenAPI spec version: 0.0.0
 */
import { HttpResponse, delay, http } from "msw";

export const getUsersExamples = {
  default: {
    body: ["userD", "userE", "userF"],
    contentType: "application/json",
    statusCode: 200,
  },
  filterd: {
    body: ["userC"],
    contentType: "application/json",
    statusCode: 200,
  },
  empty: { body: [], contentType: "application/json", statusCode: 200 },
  forbidden: {
    body: { statusCode: 403, errorMessage: "Forbidden" },
    contentType: "application/json",
    statusCode: 403,
  },
  permission_denied: {
    body: { statusCode: 403, errorMessage: "Permission denied" },
    contentType: "application/json",
    statusCode: 403,
  },
} as const;
export const getUsersMockHandler = (
  name: keyof typeof getUsersExamples | undefined = "default",
  delayMs: number | undefined = 1000,
) => {
  return http.get("/users", async (_info) => {
    const example = getUsersExamples[name];
    await delay(delayMs);
    return new HttpResponse(JSON.stringify(example.body), {
      status: Number(example.statusCode),
      headers: { "Content-Type": example.contentType },
    });
  });
};

export const getUsersDetailExamples = {
  success: { body: "userA", contentType: "application/json", statusCode: 200 },
  errors: {
    body: { statusCode: 404, errorMessage: "User not found" },
    contentType: "application/json",
    statusCode: 404,
  },
} as const;
export const getUsersDetailMockHandler = (
  name: keyof typeof getUsersDetailExamples | undefined = "success",
  delayMs: number | undefined = 1000,
) => {
  return http.get("/users/:id", async (_info) => {
    const example = getUsersDetailExamples[name];
    await delay(delayMs);
    return new HttpResponse(JSON.stringify(example.body), {
      status: Number(example.statusCode),
      headers: { "Content-Type": example.contentType },
    });
  });
};

export const getUsersMock = () => [
  getUsersMockHandler(),
  getUsersDetailMockHandler(),
];

脚注
  1. 6.27.0からステータス別にはモックを生成できるようになったようです🙌 ↩︎

Discussion