🦈
orvalでexamplesに定義した全てのレスポンスのモックを生成したい
こんにちは!
最近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.ts
でoutput.mock
へ渡す事で実現できます。
ただし、以下の注意点があります。
- ステータスコードは成功/失敗それぞれで1種類しか利用できない
- 例: 401, 403などのexamplesをそれぞれ定義してもモックは401しか生成されない😢
- 型が怪しい箇所がある
- Orvalの
ConfigExternal
の型が実態とずれているのかもしれない...?
- Orvalの
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(),
];
Discussion