動機と概要
TypeScriptでバックエンドを実装する際に、エンドポイントのパスパラメータやリクエストボディ・レスポンスボディに型がついていなくて嫌だと思っています。同様にフロントエンドからリクエストするときに型がついていない( or 自分で型パラメータを渡す)のが嫌だと思っています。そもそもそれらの定義は集約されているべきではと思います。1
エンドポイントの定義を集約して、その定義をもとにエンドポイントを実装したり、リクエストしたりできると嬉しいので、実装しました。
構成
前提
バックエンドにExpress、フロントエンドからのHTTPクライアントにAxios、型定義にzodを使いました。それぞれ他のライブラリでも実現可能だと思います。
構成要素
- エンドポイントの定義
エンドポイントを定義するための型type Route
と、そのファクトリを担うdefineRoute: (param) => Route
- エンドポイントの実装
Route
を元に、Expressにハンドラを設定するregister: (router: express.Router, route: Route, handler: Callback) => void
- リクエストの実装
Route
をもとにリクエストを投げるrequest: (axios:Axios, route:Route, params) => responseBody
実装
エンドポイントの定義 (Route
, defineRoute
)
Routeの型から。パスパラメータとリクエストボディ、レスポンスボディに型をつけたいので、それらをジェネリクスにしています。
export type MethodsAcceptingBody = "post" | "put" | "patch";
export type MothodsNotAcceptingBody = "get" | "delete" | "options";
export type Methods = MethodsAcceptingBody | MothodsNotAcceptingBody;
export type Route<
Path extends string,
Method extends Methods,
RequestBody extends Method extends MethodsAcceptingBody
? z.AnyZodObject
: z.ZodUndefined,
ResponseBody extends z.AnyZodObject
> = {
readonly method: Method;
readonly path: Path;
readonly requestBody: RequestBody;
readonly responseBody: ResponseBody;
};
このように型を用意してあげた上で、以下のようなファクトリ関数を作成しました。
const defineRoute = <
Method extends Methods,
Path extends string,
RequestBody extends Method extends MethodsAcceptingBody
? z.AnyZodObject
: z.ZodUndefined,
ResponseBody extends z.AnyZodObject
>(
path: Path,
method: Method,
requestBody: RequestBody,
responseBody: ResponseBody
): Route<Path, Method, RequestBody, ResponseBody> => ({
path,
method,
requestBody,
responseBody,
});
これにより、エンドポイントの定義を集約して実現できます。
export const ENDPOINTS ={
getUsers: defineRoute(
"/users",
"get",
z.undefined(),
z.object({ users: z.array(z.object({ id: z.string() })) })
),
getUser: defineRoute(
"/users/:id",
"get",
z.undefined(),
z.object({ user: z.object({ id: z.string(), name: z.string() }) })
),
createUser: defineRoute(
"/users",
"post",
z.object({ name: z.string() }),
z.object({ user: z.object({ id: z.string(), name: z.string() }) })
),
}
ぱっと見で何が定義されているかわかりやすいと感じます。zodにも慣れている型であればリクエスト、レスポンスの型もパッと見でわかりやすそうです。
パスパラメータ部分にはExpressでの記法(:
から始まる部分がパラメータ)から借用しています。
エンドポイントの実装 (register
)
このRouteの定義をもとに、Expressで実際にハンドラを設定する関数、registerを以下のように実装しました。
// import { RequestHandler, RouteParameters } from "express-serve-static-core";
interface Queries {
[key: string]: undefined | string | string[] | Queries | Queries[];
}
const register = <
Method extends Methods,
Path extends string,
RequestBody extends Method extends MethodsAcceptingBody
? z.AnyZodObject
: z.ZodUndefined,
ResponseBody extends z.AnyZodObject
>(
router: express.Router,
route: Route<Path, Method, RequestBody, ResponseBody>,
callback: (
pathParmas: RouteParameters<Path>,
body: z.SafeParseReturnType<z.infer<RequestBody>, z.infer<RequestBody>>,
queries: Queries
) => Promise<z.infer<ResponseBody>>
) => {
const handler: RequestHandler = (req, res) => {
try {
const parsed =
"shape" in route.requestBody
? await route.requestBody.parseAsync(req.body)
: undefined;
const response = await callback(
req.params as RouteParameters<Path>,
parsed,
req.query,
req.headers
);
const parsedResponse = await route.responseBody.parse(response);
res.json(parsedResponse);
} catch (err: any) {
res.status(err["__userStatus"] ?? 500).send(err["__userMessage"] ?? "");
console.warn(err);
}
};
router[route.method](route.path, handler);
};
これで渡されたroute
に基づくパスにハンドラを設定することができます。
ポイントはcallbackの型で、pathParmas
, body
ともにちゃんと型を与えることができています。
register
を使って以下のようにハンドラを設定できます。params
もbody
も型がついていて値を取り出すことができます。また、返り値の型がz.infer<ResponseBody>
に適合しない場合もコンパイルエラーとなるため、安全にコーディングを行うことができます。
register(app, ENDPOINTS.getUser, async (params) => {
const {id} = params;
const user = await db.users.find(id);
return {user};
});
register(app, ENDPOINTS.createUser, async (_, body) => {
if(!body.success){
throw new Error('invalid body');
}
const {name} = body.data;
const user = await db.users.insert(name);
return {user};
}).
リクエストの実装 (request
)
クライアント側でRoute
をもとにリクエストを投げる実装です。
const request = async <
Path extends string,
Method extends Methods,
RequestBody extends Method extends MethodsAcceptingBody
? z.AnyZodObject
: z.ZodUndefined,
ResponseBody extends z.AnyZodObject
>(
axios: Axios,
route: Route<Path, Method, RequestBody, ResponseBody>,
pathParams: RouteParameters<Path>,
body: z.infer<RequestBody>,
queries: Queries
): Promise<z.TypeOf<ResponseBody>> => {
const keys = Object.keys(pathParams).sort((a, b) => a.length - b.length);
const path = keys
.filter((key) => key in pathParams)
.reduce(
(acc, key) => acc.replace(`:${key}`, (pathParams as any)[key]),
route.path
);
const bodyParsed = route.requestBody.parse(body);
const responsedData = await (async () => {
switch (route.method) {
case "get":
case "delete":
case "options":
return await axios[route.method](path, { params: queries });
case "post":
case "put":
case "patch":
return await axios[route.method](path, bodyParsed, { params: queries });
default:
throw new Error("invalid method");
}
})();
const responsed = route.responseBody.parse(responsedData.data);
return responsed;
};
パスパラメータの部分を展開して、リクエストしています。また、返り値をresponseBody.parse
することで、responseBodyの型のオブジェクトとして返り値を扱うことができます。
第一引数として渡すaxiosは
const axiosInstance = new Axios({
transformRequest: axios.defaults.transformRequest,
transformResponse: axios.defaults.transformResponse,
baseURL: "http://localhost:3000/",
});
などとして、baseURLを設定しておきます。すると以下のような形でリクエストを送ることができます。
request(
axiosInstance,
ENDPOINTS.createUser,
{}, // Path Parameters
{name: "taro"}, // Request Body
{} // Query Parameters
).then(console.log);
request(
axiosInstance,
ENDPOINTS.getUser,
{id: "1"},
undefined,
{}
).then(console.log);
実際に使用した感想
メリットとして第一に、動機の繰り返しですが、エンドポイントの定義を集約することができます。これによって、「バックエンドだけ修正したのにフロントエンドを修正し忘れた!」とか、「エンドポイントのURLが違う!」とか、「レスポンスで返ってくるオブジェクトがいつの間にか変わっている!」などをなくすことができます。
さらに副次的な効果として、デザインパターンとして固定化することで、だいぶユニットテストが書きやすくなったというのを感じました。
導入前はHTTPのレベルでソフトウェアの(バックエンドとフロントエンドの)境界を設けてしまうと、自由度が上がりすぎて、無数にリクエストのフィールドを足していってしまったりといったことに陥りやすいと感じていました(=バックエンド側で好き勝手にエンドポイントを変更したり追加したりしてしまいやすい)。共通のコード基盤に定義を移動できることで、定義の網羅性や見通しが良くなり、よりシンプルな設計が実現できると思いました。
注意点
今回挙げた実装の注意点として、
- Dateを含む型をそのまま扱えない(String <=> Dateを変換できるようにしてあげる必要がある)
- Expressのパスパラメータの正規表現などは実装していないのでしようできない(requestを改修する必要がある)
があります。開発中のプロダクトに影響がなかったため修正していませんが、上記を含むプロダクトに展開する場合は注意が必要です。
-
protobufやtrpc、Swaggerなどで一元的な定義は実現できるのでそれらが導入されていれば本文の議論は不要です。(ただしoverkillになりがちなので、私はそれらの導入には慎重なスタンスですが本文と関係ないので本記事では割愛します) ↩