Aspida で型安全な MSW ハンドラーを書く
MSW のハンドラー型推論
テストや Storybook で利用事例が増えている MSW。GraphQL の場合、@graphql-codegen/typescript-msw
というプラグインを使えば、良い感じの MSW ハンドラーを生成してくれます。これがあれば、MSW ハンドラー関数起因のミス削減が期待できます。
query GetUser($id: ID!) {
getUser(id: $id) {
name
id
}
}
import { mockGetUserQuery } from "./generated";
const worker = setupWorker(
mockGetUserQuery((req, res, ctx) => {
// id の型推論が効いている
const { id } = req.variables;
return res(
ctx.data({
// 型制約が効いており、スキーマに沿ったレスポンスしか返せない
getUser: { name: "John Doe", id: id },
})
);
})
);
worker.start();
mock<OperationName><OperationType>[LinkName]
といった命名規則で、ハンドラーファクトリー関数が generate されます。詳細は以下。
REST x Aspida で同等の型安全を実現する
REST API の場合、OpenAPI から MSW ハンドラー関数を生成してくれるエコシステムは、筆者の知る限りでは今のところありません。そこで、Aspidaと型パズルでなんとかならないかな?と検証したらすんなり出来たので紹介します。
POST:/v1/my_api
という API が定義されている場合、以下の様なコードになります。"../api/$api"
は自動生成コードに相当し、OpenAPI 以外に型情報を書く必要はありません。
import aspida from "@aspida/axios";
import api from "../api/$api";
import { restPost } from "./handlerFactory";
const client = api(aspida());
export const handlers = [
// restPost という、POST用のファクトリー関数をつかう。
// 第一引数に AspidaClient の該当 API を渡す。
// 第一引数に応じて、第二引数のリゾルバー関数推論が変わる。
restPost(client.v1.my_api, (req, res, ctx) => {
// name の型推論が効いている
const { name } = req.body;
// ctx.json は { message: string } しか返せない
return res(ctx.json({ message: `hello ${name}` }));
}),
];
メリットをまとめると次の通りです。
- 型注釈する必要がなく、スッキリ書ける
- 型注釈する必要がなく、OpenAPI の内容が型推論に適用される
- API Path と Request/Response が紐づいているため、マッピングでミスがない
- ランタイムで実際に使う、AspidaClient と同じものを使える
ハンドラーファクトリー関数の型推論
先の例に示したrestPost
というハンドラーファクトリー関数。型定義が込み入っているので、ひとまず JavaScript で表現した場合の内訳を見ていきます。第一引数のapi
は、AspidaClient の API を指しています。api.$path
関数で API path を取得できるので、MSW のrest.post
第一引数にはこれを与えます。rest.post
第二引数のresolver
は、与えられた resolver 関数をそのままバインドします。非常に簡単な関数ですね。
import { rest } from "msw";
export function restPost(api, resolver) {
return rest.post(api.$path(), resolver);
}
このハンドラーファクトリー関数に型推論をかけていきます。「与えられた第一引数に応じて、第二引数の推論を導く」という点がポイントです。
import { rest, ResponseResolver, RestContext, RestRequest } from "msw";
type A1<T> = T extends (a1: infer I) => unknown ? I : never;
//
// Aspida で生成される Methods型を模倣したもの。型をキャプチャするために用意
//
type Method = {
reqHeaders: any;
query: any;
status: number;
resBody: any;
reqBody: any;
};
type MethodNames = "get" | "post" | "put" | "patch" | "delete";
type Methods = { [K in MethodNames]: Method };
//
// restPost 関数第一引数の期待値(型)
//
type Post = {
post: (option: {
body: Methods["post"]["reqBody"];
query: Methods["post"]["query"];
config?: any;
}) => Promise<Methods["post"]["resBody"]>;
$path: () => string;
};
//
// 型の lookup で第二引数の resolver に型注釈を与える
//
export function restPost<T extends Post>(
api: T,
resolver: ResponseResolver<
RestRequest<A1<T["post"]>["body"]>,
RestContext,
Awaited<ReturnType<T["post"]>>["body"]
>
) {
return rest.post(api.$path(), resolver);
}
あとは必要に応じて Http メソッドに対応するハンドラーファクトリー関数を用意すれば OK。便利な Aspida から型情報を吸い上げる作例でした。
Discussion