0
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?

More than 1 year has passed since last update.

フロントエンドとバックエンドでエンドポイントの定義を集約する方法

Last updated at Posted at 2023-10-31

動機と概要

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を使って以下のようにハンドラを設定できます。paramsbodyも型がついていて値を取り出すことができます。また、返り値の型が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を改修する必要がある)

があります。開発中のプロダクトに影響がなかったため修正していませんが、上記を含むプロダクトに展開する場合は注意が必要です。

  1. protobufやtrpc、Swaggerなどで一元的な定義は実現できるのでそれらが導入されていれば本文の議論は不要です。(ただしoverkillになりがちなので、私はそれらの導入には慎重なスタンスですが本文と関係ないので本記事では割愛します)

0
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
0
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?