🛣️

デッドコード撲滅のためにエンドポイントの棚卸し機能を作ってフロントエンドでの権限チェックに応用した話

2024/03/07に公開

導入

CloudbaseではAPIサーバにExpressを、フロントエンドにNext.jsを使用し、SWRをデータ取得ライブラリとして用いています。また、バックエンド、フロントエンドともにTypeScriptを使用しています。さらに、モノレポを採用しており、バックエンドとフロントエンドのコードに加えて、ORMのPrismaのコードやESLintのカスタムルールなどを一つのリポジトリで管理しています。Primsaといえば、弊社のエンジニアが書いたPrisma ORMを使いこなす ~歴史と対RDB運用の知見を添えて~に、Prismaを運用する中で得た知見がまとめられているので、興味がある方はぜひご覧ください。

本記事では、エンドポイント棚卸し機能を作った話と、その機能をフロントエンドでの権限チェックにうまく活用できた話について述べます。最終的には、以下に示すように、フロントエンドから呼び出されていないエンドポイントを検出し、PRに対してコメントするGitHub Actionsを作成できました。また、フロントエンドでは、ちょっとコードを追加するだけで権限によるコンポーネントの出し分けを実装することができるようになりました。


GitHub Actionsのコメント

対象読者

  • 使用されていないエンドポイントの実装が残っているけど、本当に使用されていないか確認できずに実装を消したいけど消せていない人
  • フロントエンドで権限によってコンポーネントを出し分けたいけど、良い方法を思いついていない人

本記事では、Express、Next.js、SWRの使い方などの基本的な説明はしませんが、他のフレームワークであったりライブラリを使ったことがある人であれば十分書いてある内容は理解できると思います。

弊社の開発スタイルと悩み

まずは、弊社の開発スタイルとそれによって生じる悩みについて述べます。

モノレポを採用した理由の一つとして、弊社の開発スタイルが挙げられます。それは、機能開発において、DBの設計からバックエンドの実装、フロントエンドの実装まで、全てを一人のエンジニアが担うという開発スタイル[1]です。そのような開発スタイルであるため、バックエンドとフロントエンドを行き来しながら実装する機会がかなり多いです。また、UIが変わるような変更を加える場合、Feature Flagを用いてコンポーネントを出し分けることがあり、それに伴いAPIのレスポンスを大きく変える必要がある場合には、新旧二つのエンドポイントを共存させることがあります。より具体的なイメージを持ってもらうために、簡略化したモノレポの構成と実装例を以下に示します。

$ tree .
.
├── backend // Expressを用いて実装されたバックエンドのコード
│   ├── package.json
│   └── src
│       └── index.ts
├── frontend // Next.jsを用いて実装されたフロントエンドのコード
│   ├── package.josn
│   └── src
│       └── pages
│           └── index.tsx
└── package.json
backend/src/index.ts
const handler = (req, res) => { ... };
app.get('/v1/foo/:fooId', handler);

const handlerV2 = (req, res) => { ... };
app.get('/v2/foo/:fooId', handlerV2);
frontend/src/pages/index.tsx
const ChildComponent = () => {
  // 以下では、簡単のためkeyのみを渡す実装を示します
  const { data } = useSWR(`/v1/foo/${fooId}`);
  ...
};

const ChildComponentV2 = () => {
  const { data } = useSWR(`/v2/foo/${fooId}`);
  ...
};

const ParentComponent = () => {
  // Feature Flagによって新旧のコンポーネントを出し分ける
  return isFeatureEnabled ? <ChildComponentV2 /> : <ChildComponent />
}

このように、Feature FlagのON/OFFによってコンポーネントを出し分けるとともに、それぞれのコンポーネントが呼び出すエンドポイントを変えるような実装があるとします。ここで、Feature Flagを消すことを考えます。

backend/src/index.ts
const handler = (req, res) => { ... };
// どこからも呼び出されなくなったエンドポイント
app.get('/v1/foo/:fooId', handler);

const handlerV2 = (req, res) => { ... };
app.get('/v2/foo/:fooId', handlerV2);
frontend/src/pages/index.tsx
const ChildComponentV2 = () => {
  const { data } = useSWR(`/v2/foo/${fooId}`);
  ...
};

const ParentComponent = () => {
  // Feature Flagを消す
  return <ChildComponentV2 />
}

フロントエンドの変更に気を取られて、/v1/foo/:fooIdのエンドポイントの関係する実装を消し忘れてしまい、どこからも使用されていないデッドコードが生まれてしまいました、、、!
この例では、一つのコンポーネントしか示していないため、バックエンドの実装も忘れずに削除するのは簡単な作業のように思えますが、実際には複数のコンポーネントから呼び出されている可能性もあり[2]、しっかりと/v1/foo/:fooIdへのリクエストがないかを確認した上で削除する必要があり、割と大変な作業になり得ます。

型定義を用いることで確認が容易な場合もあります

例からは省きましたが、実際には以下のようにリクエストボディやレスポンスの型を定義するためのパッケージを用意しています。

$ tree .
.
├── backend
│   ├── package.json
│   └── src
│       └── index.ts
├── frontend
│   ├── package.josn
│   └── src
│       └── pages
│           └── index.tsx
├── package.json
└── types // リクエストボディやレスポンスの型を定義
    ├── package.json
    └── src
        └── index.ts
types/src/index.ts
export type GetFooResponse = { ... };
backend/src/index.ts
import type { RequestHandler } from "express";
import type { GetFooResponse } from "@cloudbase/types";

// クエリ(データ取得のみ)の場合はレスポンスの型を指定している
const getHandler: RequestHandler<{ fooId: string }, GetFooResponse> = (req, res) => { ...};
app.get('/v1/foo/:fooId', getHandler);

// ミューテーションの場合はレスポンスの型を指定していない場合がある
const createHandler: RequestHandler<{ fooId: string }> = (req, res) => {
  ...
  res.json({ message: "ok" })
};
app.post('/v1/foo', createHandler);
frontend/src/pages/index.tsx
import { GetFooResponse } from "@cloudbase/types";

const Component = () => {
  const { data } = useSWR<GetFooResponse>(`/foo/${fooId}`);
  const { trigger } = useSWRMutation(`/foo/${fooId}`)
  ...
};

この場合、GetFooResponseの使用状況を確認することで、/v1/foo/:fooIdのエンドポイントが使用されていないかを確認することができます。ただし、useSWRの型引数の指定は必須ではないため、厳密には、GetFooResponseが使用されていないからといって、/v1/foo/:fooIdのエンドポイントが使用されていないとは限りません。
また、POSTPUTのようなエンドポイントにおいては、フロントエンドにて変更後のデータが不要な場合、レスポンスの型は指定しないことがあります。この場合は、上述の例と同様に、エンドポイントの実装を削除するためには十分な確認作業が必要となります。

このような背景から、以下では、引き続きクエリのエンドポイントを例として用います。

例示が長くなりましたが、弊社Cloudbaseでは、バックエンドにはすでに使用されていないエンドポイントの実装が残っていることがあり、その結果、新規参入エンジニアの認知負荷が高まったり、不要なテストの存在によりCIの実行時間が伸びたり、といった問題が生じていました。このような状況を解決すべく、エンドポイント棚卸しプロジェクトを始動させました。

このプロジェクトの目的は、バックエンドで定義しているエンドポイントのうち、フロントエンドから呼び出されていないものを特定し、安心して不要な実装を削除できる環境にすることです。バックエンドで定義しているエンドポイント全体から、フロントエンドから呼び出しているエンドポイントを引くことで、呼び出されていないエンドポイントを知るという大方針で進めました。

次章からは、その過程を順に示します。

エンドポイント棚卸しプロジェクトの歩み

実際に取り組んだ改善によるコードベースの状態の変化を時系列に沿って述べていきます。解決できた課題や残っている課題をトラッキングしやすくするために、「残された課題」を各状態の最後に示しています。

つらつらと書いたところ、割と膨大な量になってしまったのですが、実は最後の状態7: フロントエンドでの権限チェックが一番読んで頂きたい内容です。しかし、それまでの取り組みをすっ飛ばして状態7だけについて書くと、前提が共有されなさすぎ理解が難しいと思い、状態0から順に書いていくことにしました。お時間がある方はぜひ状態0から読み進めていただけると幸いです。

状態0

バックエンドで定義されているエンドポイントが、フロントエンドで用いられているかを確認することがかなり難しい状態です。フロントエンドでは、テンプレートリテラルを用いてリクエスト先を指定しているため、 /v1/foo/:fooId でリポジトリ全体を検索しても、フロントエンドから呼び出されていないことを確認することができません。

// backend/src/index.ts
const handler = (req, res) => { ... };
app.get('/v1/foo/:fooId', handler);

// frontend/src/pages/index.tsx
const Component = () => {
  const { data } = useSWR(`/v1/foo/${fooId}`);
  //                       ^^^^^ テンプレートリテラルを用いている
  ...
};

残された課題

  • 課題1: 呼び出されていないエンドポイントを知ることが難しい

状態1: urlcatとの出会い

いつものようにXを眺めていたら、urlcatについて言及しているポストを見つけました。urlcatを用いることで、

frontend/src/pages/index.tsx
const Component = () => {
  const { data } = useSWR(urlcat("/v1/foo/:fooId", { fooId }));
  ...
};

のように、テンプレートリテラルを用いずに、Expressでのエンドポイント定義に用いる文字列と全く同じ文字列を使うことができるようになりました!
これにより、 /v1/foo/:fooId でリポジトリ全体を検索することで、他のコンポーネントでも使われていないか、フロントエンドではすでに使われなくなっているかを確認できるようになりました。


VSCodeでの検索例

あとは、既存実装を全てurlcatに置き換えておけば、定期的にパスの文字列で検索をかけてデッドコードを検出・削除ができるだろう!と思っていたのですが、そう簡単な話ではなく

urlcatを用いた実装と用いていない実装が混在している中で、urlcatを用いることを徹底・強制することができず、PRで都度コメントをしなければならない状況でした。urlcatの使用が徹底できていない状況だと、/v1/foo/:fooIdで検索をかけてフロントエンドで使用されていないことを確認したとしても、まだ/v1/foo/${fooId}を用いた実装が残っている可能性があり、依然注意深くコードを確認する必要がり、改善されたとは言えない状況でした。

残された課題

というわけで、この状態では全く課題を解決するには至らず、新たな課題を生み出してしまいました。

  • 課題1: 呼び出されていないエンドポイントを知ることが難しい

  • 課題2: urlcatの使用を強制できていない

  • 課題3: タイポを防げない

    • urlcatでは、第二引数に渡すオブジェクトは、パスパラメータとクエリパラメータをまとめたものとして扱われます。したがって、以下のようなタイポをしていたとしても、ランタイムまでエラーが発生することはありません。簡単な動作検証で気づくことができますが、できれば静的にエラーを検知したいところです。

      // fooIdをタイポしてwrongIdとしてしまっている
      const { data } = useSWR(urlcat("/v1/foo/:fooId", { wrongId }));
      
  • 課題4: バックエンドとは異なるプレースホルダを使用できる

    • urlcatを用いた場合、最終的には/v1/foo/1のようなパスに変換されるので、プレースホルダの部分は任意の値を使用することができてしまいます。これでは、正確にエンドポイントの使用状況を把握することができません。

      // backend/src/index.ts
      app.get("/v1/foo/:fooId", handler);
      
      // frontend/src/pages/index.tsx
      // プレースホルダは必ずしも:fooIdである必要はない
      const { data } = useSWR(urlcat("/v1/foo/:wrongId", { wrongId }));
      

状態2: honoとの出会いとPathクラスの導入

いつものように新しい技術の素振りの一環でhonoを触っていたら、ハンドラを定義しているときに、ちょっとした驚きがありました。

上図が示すように、パスで指定したパラメータがなぜかサジェストされたのです!すぐさま実装を見に行ったところ、見事な型が定義されており、TypeScriptはこんなことができるのか、とちょっと感動したのを覚えています。

https://github.com/honojs/hono/blob/440d31b66534ddb4fbc6be99f65298d0eaf53122/src/types.ts#L1598-L1604

というわけで、この型定義から着想を得て、独自のPathクラスおよびPathクラスを引数とするuseSWRのラッパー関数を定義しました。

// /foo/:fooId/bar/:barId のような文字列から
// { fooId: string } & { barId: string } のような型を生成する型
type PathParams<T extends string> = ...;

class Path<T extends string> {
  constructor(
    private pathname: T,
    private params: PathParams<T>,
    private searchParams: Record<string, any>) {}

  toString() {
    return urlcat(this.pathname, { ...this.params, ...this.searchParams });
  }
}

const useSWRWrapper = <T>(path: Path<T>) => {
  return useSWR(path.toString());
}

const Component = () => {
  const { data } = useSWRWrapper(new Path("/v1/foo/:fooId", { fooId }));
  ...
}

この変更により、以下のような実装はエラーになるようになりました!

const Component = () => {
  const { data } = useSWRWrapper(`/v1/foo/${fooId}`);
  //                             ^^^^^ 型がstringなのでエラーになる
  const { data } = useSWRWrapper(urlcat("/v1/foo/:fooId", { fooId }));
  //                             ^^^^^ 型がstringなのでエラーになる
  const { data } = useSWRWrapper(new Path("/v1/foo/:fooId", { wrongId }));
  //                             ^^^^^ fooIdが渡されていないのでエラーになる
};

あとは、useSWRurlcatの使用を禁止するために、ESLintのno-restricted-importsを利用し、それらをimportしている場合はエラーとなるようにしました。

.eslintrc.json
{
  "rules": {
    "no-restricted-imports": [
      "error",
      {
        "patterns": [
          {
            "group": ["urlcat"],
            "message": "use Path class instead of urlcat"
          },
          {
            "group": ["swr"],
            "importNames": ["default"],
            "message": "use useSWRWrapper instead of useSWR"
          }
        ]
      }
    ]
  }
}

この変更により、

  • Pathクラス使用の強制を通して、urlcatの使用が強制された
  • プレースホルダのタイポを防ぐことができるようになった

というように、課題が解決されました、と言いたいところですが、

const Component = () => {
  // Pathのコンストラクタにはテンプレートリテラルを渡せる😭
  const { data } = useSWRWrapper(new Path(`/v1/foo/${fooId}`));
  ...
};

当然ですが、Pathクラスのコンストラクタには、テンプレートリテラルを渡すことができま。これでは、結局エンドポイントの文字列で検索するだけでは、呼び出されていないことを確認できません。

残された課題

というわけで、課題2と課題3は解決できたものの、課題4が残っている他、新たに課題5を生じさせてしまいました。

  • 課題1: 呼び出されていないエンドポイントを知ることが難しい
  • 課題2: urlcatの使用を強制できていない
  • 課題3: タイポを防げない
  • 課題4: バックエンドとは異なるプレースホルダを使用できる
  • 課題5: Pathクラスの引数にテンプレートリテラルを渡すことができてしまう

状態3: ESLintのカスタムルールとの出会い

きっかけは忘れてしまったのですが、以下の記事に出会い、ESLintのカスタムルールを使うことを選択肢の一つとして持っていました。

https://speakerdeck.com/sansantech/sansan-20230912-2

このスライドには「なるべくTypeScriptの型を用いて静的なチェックをするものの、それでは要求を満たせない場合はESLintのルールを自作する発想を持っておくと良いのでは」ということが書かれています。といわけで、ESLintを使ってテンプレートリテラルを渡すことを禁止できないか調べてみると、

https://github.com/typescript-eslint/typescript-eslint/blob/1807d555aeb99873c665b34b75a4e5bc89bc1f86/packages/ast-spec/src/ast-node-types.ts#L80

テンプレートリテラルを表すASTノードの型がありました!これを使えば課題を解決できそうです。まずは、上述のスライドにしたがって、既存のルールが存在する場合はそれを使わせていただこうと思って調べたのですが、残念ながら見つけることができず、ルールを自作する必要がありました。リンターのルールの自作は難しいイメージがありましたが、@typescript-eslint/utilsを利用することで、恐ろしく簡単にルールを実装をすることができました。多少改変していますが、以下に示すような実装だけで「Pathクラスのコンストラクタの第一引数にテンプレートリテラルを渡すことを禁止する」ルールを自作することができました。

import { ESLintUtils } from "@typescript-eslint/utils";

const rule = ESLintUtils.RuleCreator.withoutDocs({
  create: (context) => {
    return {
      NewExpression(node) {
        if (
          node.callee.type === "Identifier" &&
          node.callee.name === "Path" &&
          node.arguments[0].type === "TemplateLiteral"
        ) {
          context.report(...)
        }
      },
    };
  },
  ...
});

実際は、ルールの実装よりも、実装したルールをnpmなどで公開せずにプライベートなモノレポの中で管理し、ローカルルールとして導入することにより多くの時間を費やしたのですが、話の本筋から逸脱するので、ここでは割愛します。

残された課題

というわけで、課題5は比較的簡単に解決することができました。

  • 課題1: 呼び出されていないエンドポイントを知ることが難しい
  • 課題2: urlcatの使用を強制できていない
  • 課題3: タイポを防げない
  • 課題4: バックエンドとは異なるプレースホルダを使用できる
  • 課題5: Pathクラスの引数にテンプレートリテラルを渡すことができてしまう

状態4: フロントエンドでエンドポイントの棚卸しを自動化

ここで、改善を始めるきっかけとなった課題である課題1: 呼び出されていないエンドポイントを知ることが難しいという課題に立ち返ろうと思います。改善を始めた頃は、手作業でのエンドポイント棚卸しの効率が向上すればそれで良いと思っていましたが、新たに得たESLintの知識と、新たに導入したPathクラスを組み合わせることで、フロントエンドでのエンドポイントの棚卸しの自動化が可能であることに気づきました。

ESLintは、言うまでもなくlinterなので、ファイルの保存、pre-commitフック、CIなどの場面で実行されます。しかし、TypeScriptに対してESLintを適用する際に用いられるtypescript-eslintが提供する@typescript-eslint/typescript-estreeを使用することで、lintを実行するタイミングでなくても、AST(ESTree)を取得することができ、ESLintのカスタムルールと同じような実装でコードベースを解析することができます。大幅に単純化された実装ではありますが、以下のような実装によりPathクラスのコンストラクタの第一引数をリストアップすることが可能です。

import fs from "fs";

import {
  parseAndGenerateServices,
  simpleTraverse,
  type TSESTree,
} from "@typescript-eslint/typescript-estree";

const extractPathFromNewExpression = (
  newExpression: TSESTree.NewExpression
): string | undefined => {
  if (
    newExpression.callee.type === "Identifier" &&
    newExpression.callee.name === "Path"
  ) {
    return extractPath(newExpression.arguments[0]);
  }
};

const extractPath = (node: TSESTree.Node): string | undefined => {
  switch (node.type) {
    case ...:
    ...
    case "Literal":
      if (typeof node.value === "string") {
        return node.value;
      }
  }
};

const main = (file: string) => {
  try {
    const f = fs.readFileSync(file, "utf-8");
    const { ast } = parseAndGenerateServices(f, { jsx: file.endsWith("tsx") });

    const paths: string[] = [];
    simpleTraverse(ast, {
      enter: (node) => {
        if (node.type === "NewExpression") {
          const path = extractPathFromNewExpression(node);
          if (path !== undefined && !paths.includes(path)) {
            paths.push(path);
          }
        }
      },
    });
  } catch (e) {
    console.error(`file: ${file}`);
    console.error(e);
  }
};
上記実装の簡単な解説

簡単に解説すると、まず以下のように、ファイルを読み込み、ASTを取得することから始めます。

const f = fs.readFileSync(file, "utf-8");
const { ast } = parseAndGenerateServices(f, { jsx: file.endsWith("tsx") });

その後、@typescript-eslint/typescript-estreeからexportされているsimpleTraverseを用いて、ASTを探索し、NewExpressionの場合だけ次の処理extractPathFromNewExpressionに進みます。

const paths: string[] = [];
simpleTraverse(ast, {
  enter: (node) => {
    if (node.type === "NewExpression") {
      const path = extractPathFromNewExpression(node);
      if (path !== undefined && !paths.includes(path)) {
        paths.push(path);
      }
    }
  },
});

extractPathFromNewExpressionでは、Pathクラスに対するNewExpressionであるかを検証し、該当する場合は第一引数を次の処理extractPathに渡します。

const extractPathFromNewExpression = (
  newExpression: TSESTree.NewExpression
): string | undefined => {
  if (
    newExpression.callee.type === "Identifier" &&
    newExpression.callee.name === "Path"
  ) {
    return extractPath(newExpression.arguments[0]);
  }
};

extractPathでは、そのノードのtypeによって次の処理を振り分けており、Literalの場合はその値を返し、目的の/v1/foo/:fooIdといった文字列を取得することができます。

const extractPath = (node: TSESTree.Node): string | undefined => {
  switch (node.type) {
    case ...:
    ...
    case "Literal":
      if (typeof node.value === "string") {
        return node.value;
      }
  }
};

ところで、これまでエンドポイントについて言及する際に、URLについてのみ言及していましたが、GET /v1/foo/:fooIdPUT /v1/foo/:fooIdのように同じURLで異なるメソッドであることがあり得るため、エンドポイントの棚卸しのためには、URLだけではなくメソッドも取得する必要があります。上記実装例では省略しましたが、そのような処理が実際の実装には含まれており、最終的には以下のようなjsonを得られるように実装しました。

[
  {
    "method": "GET",
    "path": "/v1/foo/:fooId"
  },
  {
    "method": "PUT",
    "path": "/v1/foo/:fooId"
  },
  ...
]

残された課題

というわけで、課題1の解決につながる実装を追加することができました。しかし、依然以下に示すような課題が残っているような状況です。

  • 課題1: 呼び出されていないエンドポイントを知ることが難しい (ただし、フロントエンドから呼び出しているエンドポイントを知ることは可能になった)
  • 課題2: urlcatの使用を強制できていない
  • 課題3: タイポを防げない
  • 課題4: バックエンドとは異なるプレースホルダを使用できる
  • 課題5: Pathクラスの引数にテンプレートリテラルを渡すことができてしまう

状態5: バックエンドでエンドポイントの棚卸しを自動化

これまで、フロントエンドにおける改善の話を続けていましたが、最初に述べたように、バックエンドで定義されているエンドポイントから、実際に呼び出されているエンドポイントを引くことで使用されていないエンドポイントを知ろうとしています。したがって、最終的にはバックエンドでのエンドポイントの棚卸しが必要です。実は、Expressを使用している場合、ランタイムでの棚卸しにはなってしまいますが、エンドポイントの棚卸しは容易でapp._router.stackから得ることができます。ここでは実装詳細は割愛し、参考にした記事の紹介に留めておきます。

https://note.com/shift_tech/n/ne1c160a36aa9

Express以外の場合でもエンドポイントを棚卸しするのは容易だろうと思います。honoであれば、showRoutesと言う関数が提供されており、自分で実装する必要もなさそうですね。

バックエンドでのエンドポイント棚卸し機能の実装により、以下のようなjsonを得られるようになりました。

[
  {
    "method": "GET",
    "path": "/v1/foo/:fooId",
  },
  {
    "method": "PUT",
    "path": "/v1/foo/:fooId",
  },
  ...
]

というわけで、これにてバックエンドとフロントエンド双方で、エンドポイントの棚卸しを自動化するという当初の目的(以上のもの)を達成することができました!

あとは、生成したjsonを突合することで、フロントエンドから呼び出されていないエンドポイントを知ることができます。突合のタイミングはいつでも良いですが、とりあえずはCIに組み込み、冒頭で紹介したようにPRにコメントを投稿する形を取っています。


GitHub Actionsのコメント

残された課題

ところで、課題4: バックエンドとは異なるプレースホルダを使用できるを解決できていません。当初の目的である、エンドポイントの棚卸し機能の実現のみならず、当初は予定していなかった自動化まで実現することができましたが、ここまで来たら残りの課題も解決したいですね。

  • 課題1: 呼び出されていないエンドポイントを知ることが難しい
  • 課題2: urlcatの使用を強制できていない
  • 課題3: タイポを防げない
  • 課題4: バックエンドとは異なるプレースホルダを使用できる
  • 課題5: Pathクラスの引数にテンプレートリテラルを渡すことができてしまう

状態6: chokidarとの出会い、そして、エンドポイントの型化

いつものように新しい技術の素振りの一環でPanda CSSを触っていたら、ファイルの編集に伴って、型が自動生成されていることに気づきました。

これはエンドポイントの棚卸し機能に取り入れらるのではと思い、実装を見にくと、chokidarというライブラリを使ってファイルの変更を監視し、変更ががあった場合に型を自動生成していることが分かりました。

ということで、バックエンドのエンドポイント棚卸しのタイミングを、上述したようなCIの実行時から、Expressのルーティングが変更時へと変更し、さらに、jsonを生成するのではなく、以下のようなTypeScriptの型を生成するように変更しました。

generated.ts
export type GetPath =
  | "/v1/foo/:fooId"
  | "/v1/bar/:barId"
  | ...;

export type PutPath =
  | "/v1/foo/:fooId"
  | "/v1/bar/:barId"
  | ...;

export type PostPath =
  | "/v1/foo"
  | "/v1/bar"
  | ...;

このように、エンドポイントをリテラル型のユニオン型で表すようにしたほか、メソッドごとに型を分けました。


ルーティングの変更を監視している様子

その結果として、useSWRWrapperの型引数に制約を加えることが可能となり、バックエンドで定義したエンドポイント以外の指定を型レベルで禁止することができるようになりました。

// GETで定義されているエンドポイントのみを許容
const useSWRWrapper = <T extends GetPath>(path: Path<T>) => {
  return useSWR(path.toString());
};

// PUTで定義されているエンドポイントのみを許容
const useSWRPutWrapper = <T extends PutPath>(path: Path<T>) => {
  return useSWRMutation(path.toString());
};

// POSTで定義されているエンドポイントのみを許容
const useSWRPostWrapper = <T extends PostPath>(path: Path<T>) => {
  return useSWRMutation(path.toString());
};

残された課題

というわけで、ついに全ての課題を解決することができました🎉

  • 課題1: 呼び出されていないエンドポイントを知ることが難しい
  • 課題2: urlcatの使用を強制できていない
  • 課題3: タイポを防げない
  • 課題4: バックエンドとは異なるプレースホルダを使用できる
  • 課題5: Pathクラスの引数にテンプレートリテラルを渡すことができてしまう

状態7: フロントエンドでの権限チェック

以上の改善により、エンドポイントの自動棚卸しや、フロントエンドでの型レベルの制約の追加などを達成することができたのですが、もう少しお付き合いください。この改善が、当初は全く意図していなかった活用につながりました。

詳細に触れる前に、少し前提を共有させてください。Cloudbaseでは、ユーザーの権限管理機能を提供しており、例えば管理者ユーザーのみがリクエストできるエンドポイントなどがあります。そのような機能を実現するために、AWSにおけるs3:GetObjectといったIAMポリシーのような形式で、各エンドポイントに対してアクションを設定し、リクエストしたユーザーがそのアクションを許可されているかを検証しています。その検証は以下のように、Expressのハンドラとして実装しています。

// エンドポイントごとにアクションを定義
app.post("/v1/foo", authorize("Foo:Create"), handler);

// 権限ごとに許可されているアクションを定義
const admin = ["Foo:Get", "Foo:Create", "Bar:Get", ...];
const editor = ["Foo:Get", "Bar:Get", ...];

// リクエストしたユーザーの権限が不足していないかを検証
const authorize = (action: string) => {
  // Expressのハンドラを返す
  return (req: Request, res: Response, next: NextFunction) => {
    // リクエストしたユーザーの権限を取得。
    const role = getRequestUserRole(req); // admin or editor or ...

    // リクエストしたユーザーが許可されているアクションではなかった場合はエラーを投げる
    if (!role.includes(action)) {
      throw new Error("forbidden");
    }
  }
}

この例の場合、POST /v1/fooに対してリクエストしたユーザーがadminの権限を持っていない場合は権限が不足しているとみなされ、エラーが発生します。

では、Cloudbaseにおいて、このような権限によって実行できるかが異なるような機能のUIはどうなっていたのでしょうか。実は、権限によって表示する内容に差分はなく、ボタンをクリックするなどの操作をした後に、403が帰ってきた場合には「権限がありません」といったエラートーストを表示し、権限が不足していることをフィードバックするだけでした。これは、良いUXとは言えず、何か手を打ちたいと思っていました。例えば、SmartHR Design Systemでは、

権限がない場合、操作に関わるUI(アクションボタンやオブジェクトそのもの等)は非表示とする。

というような対応を求めています[3]。しかし、うまい方法が思い浮かばず、なかなか対応できないでいました。ところが、全く別の文脈で導入した、上述したエンドポイント棚卸し機能を利用することで、かなり簡単に権限による表示の出し分けを実現することができました。

まず、バックエンドのエンドポイント棚卸し機能をちょっと改修して、以下のようなオブジェクトを生成するようにしました。 {method} {URL} をキーとして、対応するアクションを値として持つオブジェクトです。

type Action =
  | "Foo:Get"
  | "Foo:Create"
  | "Foo:Update"
  | "Bar:Get"
  | "Bar:Create"
  | "Bar:Update"
  | ...;

type GetPath =
  | "/v1/foo/:fooId"
  | "/v1/bar/:barId"
  | ...;

export const routes: {
  [key: string]: Action
} = {
  "GET /v1/foo/:fooId": "Foo:Get",
  "POST /v1/foo": "Foo:Create",
  "PUT /v1/foo/:fooId": "Foo:Update",
  "GET /v1/bar/:barId": "Bar:Get",
  "POST /v1/bar": "Bar:Create",
  "PUT /v1/bar/:barId": "Bar:Update",
};

そして、フロントエンドでは、以下のようなカスタムフックを実装しました。

class Path<T extends string> {
  constructor(
    public pathname: T,
    private params: PathParams<T>,
    private searchParams: Record<string, any>
  ) {}

  toString() {
    return urlcat(this.pathname, { ...this.params, ...this.searchParams });
  }
}

const useSWRPutWrapper = <T extends PutPath>(path: Path<T>) => {
  // 操作しているユーザーの権限はコンテキスト等で管理している
  const { role } = useUser();

  // Pathクラスの情報からアクションを取得
  const isPermitted = useMemo(() => {
    const action = routes[path.pathname];
    return role.includes(action);
  }, [role, path]);

  const { trigger } = useSWRMutation(path.toString());

  return { trigger, isPermitted };
};

カスタムフックの呼び出し側は、以下のようになります。

const Component = () => {
  const { trigger, isPermitted } = useSWRPutWrapper(
    new Path("/v1/foo/:fooID", { fooId })
  );

  // 権限がない場合はコンポーネント自体を表示しない
  if (!isPermitted) {
    return null;
  }

  return (
    <button
      onClick={() => {
        doSomething();
        trigger();
      }}
    >
      Fooを更新
    </button>
  );
};

このように、useSWRPutWrapperに渡す値から、必要な権限を持っているのかを簡単に取得でき、その結果をもとにしてコンポーネントの出し分けをすることが可能になりました。必要な変更は、これまでは、triggerのみを取得していた箇所に、isPermittedを追加することだけです!ポイントは二つで、

  • エンドポイントとアクションの対応を自動的に生成できること
  • Pathクラスを導入したことにより、/v1/foo/:fooIdのようにプレースホルダーを含むURL指定となったので正規表現などを頑張らなくても対応するアクションを取得できること

です。これはまさに上述したエンドポイントの棚卸し機能実現の過程で取り入れたものであり、このような活かし方ができるとは全く思っていませんでした。

既存ライブラリとの比較

以上、いくつかの仕組みを自分で実装しましたが、実は既存のライブラリの導入で簡単に解決できるのではないでしょうか。独断ではありますが、思い浮かんだライブラリとの比較を簡単に書いておきます。ただし、比較対象のライブラリは趣味程度にしか触ったことがなく、十分に理解しているとはいえないことをご了承ください。

tRPCとの比較

最近では、tRPChonoのRPCなど、バックエンドで定義したrouterから型を生成し、フロントエンドからはその型をimportすることで、バックエンドとフロントエンドの型の整合性を担保するという(画期的な!)仕組みがあります。これらを導入することで、上述した目的を果たすことができるのではないでしょうか。
(Expressからのリプレイスはそれなりに大変だろうという観点を除くと)状態5: バックエンドでエンドポイントの棚卸しを自動化および状態6: chokidarとの出会い、そして、エンドポイントの型化で述べたバックエンドのエンドポイント棚卸しおよびエンドポイントの型化に関しては、tRPCを導入した方が確実に楽かと思います。そもそもURLを意識する必要もないですし、routerオブジェクトのキーを見れば実装されているプロシージャの取得も容易です。一方で、最後に述べたフロントエンドでの権限チェックの実現に関しては、tRPCを用いたとしても本記事で述べたような実装が必要になるかと思います。

knipとの比較

デッドコードの削除といえば、knipなどのライブラリを使用する方法を思いつくと思います。しかし、Expressのエンドポイントは、オブジェクトや型としてexportされているものではないため、それらの使用状況を判断することは不可能ではないかと思います。また、状態6: chokidarとの出会い、そして、エンドポイントの型化で述べたように、エンドポイントをユニオン型で定義するようになったので、knipを使ってフロントエンドで使用していないエンドポイントを検出できるかと思いましたが、kipでは、ユニオン型のうち、使用していない値があることを検出することは現時点では不可能なようでした。間違っていたらすみません。

// types.ts
// knipではbazを使用していないことを検出できない
export type TypeList = "foo" | "bar" | "baz";

// main.ts
const foo: TypeList = "foo";
const bar: TypeList = "bar";

というわけで、状態4: フロントエンドでエンドポイントの棚卸しを自動化で述べたESTreeを利用したエンドポイントのリストアップは依然必要になりそうです。

今後の展望

上述の仕組みには、まだまだ改善の余地があるので備忘のためにも今後の展望を簡単に示しておきます。

一つ目は、バックエンドのエンドポイント棚卸し機能を静的に行えるようにすることです。現状の実装だと、毎回Expressのサーバーを立ち上げる必要があり、オーバーヘッドがかなり大きいです。Expressは古くからあるライブラリで、静的な解析はなかなか大変です。tRPCにおいては、routerを定義した時点で、プロシージャ名とその入出力の型が静的に入手可能なため、何かしら改善につながるヒントがあるかもしれません。
二つ目は、エンドポイントの型化は自動で行われるようになりましたが、リクエストボディやレスポンスの型定義は依然手作業である点です。こちらも、tRPCの実装を参考に何かしらの改善を取り入れられないか妄想しています。ここまで来るとtRPCの導入を検討するべきかもしれませんが。
三つ目は、エディタの拡張機能開発です。例えば、以下のような実装において、/v1/foo/:fooIdの部分をcmdを押しながらクリックすると、そのエンドポイントの実装にジャンプすることができたら結構便利そうな気がします。

const { data } = useSWRPutWapper(new Post("/v1/foo/:fooId", { fooId }));

まとめ

本記事では、使用されていないエンドポイントの検出機能を実現したことや、その機能を活かしたフロントエンドの権限チェック機能を実装したことについて述べました。

お気づきのように、一つ一つの実装はそこまで難しいものではなく、また、既存のOSSなどを参考にしたものがほとんどです。しかし、逆に言うと、普段から情報を収集し手札を増やしておかないと、より良い実装には繋がらないということを実感しました。もしかしたら、本記事で述べた方法をより良いものにする実装がすでにどこかに存在しているかもしれません。これからもキャッチアップは続けようと思います。最終的には、誰かを参考にするだけでなく誰かの参考になるような実装を取り入れられたら最高ですね。

また、今回の取り組みを通して、型やリンターの力を実感するとともに、実装方針やコーディング規約についての指摘をPR上でコメントし続けるのは限界があると感じました。これからも可能な限り、型やリンターに頼っていきたいですね。

エンジニア募集中!!

Cloudbaseではエンジニアを募集しています!

フロントエンドやバックエンドといった縛りは存在せず、幅広い領域に手を拡げることができます!本記事で紹介した取り組みも、バックエンドエンジニア/フロントエンドエンジニアといった分け方がされていたら実現できていなかったかもしれません。

少しでも興味を持っていただけた方は、ぜひご連絡ください!!

https://levetty.notion.site/Cloudbase-Engineer-Entrance-Book-1f51dcaf9edd490e934780f526f833a3

脚注
  1. もっというと、開発の前段の要件定義のフェーズからエンジニアも関わりますし、過去には、CSの一環として、新機能のリリース前に、エンジニアが直接機能の説明をお客様に対して行うということもありました。それぐらい、エンジニアがオーナーシップを持って機能開発をしています。 ↩︎

  2. 同じエンドポイントに対するリクエストはカスタムフックとして切り出すことでこの課題は解決できますが、残念ながら、複数のコンポーネントに点在してしまっている状況にあります。 ↩︎

  3. 参考: 権限による表示制御 | デザインパターン(共通) | SmartHR Design System ↩︎

Discussion