🙆

Strategy & Factoryパターンで、複数authメソッドを簡潔に

に公開

サーバーに必要な要件例

  • clientからのアクセスに対して、jwt tokenを確認。access tokenからuser idを取ってreqに詰め込む
  • 外部サーバーからのwebhookによるアクセスは、指定されたドメインからのreqの場合チェックなしで通す
  • ローカル開発中は、もともと指定していたuser idを取ってreqに詰め込む

これらの条件分岐を全てmiddlewareの内部のif文とかで捌くと、複雑になる。

strategy & factoryパターンを導入するメリット:

  • 責務を分割して見やすくできる
  • 新しいauthのハンドリング要件が追加されても柔軟に対応できる

strategy & factoryの説明

strategy: interfaceを実装して、それを呼び出す側が具体の実装から独立する
factory: interfaceを実装する複数のインスタンスからどれを返すかを判定する

strategy & factory無しの場合

(tsrynge使ってdependency injectionしてる)

@injectable()
class Auth {
    constructor(
    @inject("Config") private config Config,
    @inject("IVerifyTokenUC") private verifyTokenUC IVerifyTokenUC,
    ) {}

    public execute = async (req Request, res Response): Promise<void> => {
        if (this.config.env === Env.DEV) {
            req.userId = this.config.userIdForDev;
            return;
        }

        const { authorization } = req.headers;

        if (!authorization) {
            throw new Error("No token provided");
        }

        if (req.path.startWith("/webhooks") && authorization === this.config.accessToken) {
            return;
        }

        const token = authorization.split(" ")[1];
        const userId = await this.verifyTokenUC.execute(token); // might throw an error

        req.userId = userId
    }
}

これでも良いっちゃ良い。
ただ、これだと、次の両方の責務をexecuteメソッドが兼任してしまう。

  • どのauthロジックを使用するかの判定
  • 全authロジックの実行

これをstrategy & factoryパターンを使うと、綺麗に切り分けられる

strategy & factoryありの場合

export interface IAuthStrategy {
    authenticate(req: Request): Promise<void>;
}

@injectable()
class AuthStrategyFactory {
    constructor(
        @inject("JwtAuthStrategy") private readonly jwtAuthStrategy: JwtAuthStrategy,
        @inject("LocalAuthStrategy") private readonly localAuthStrategy: LocalAuthStrategy,
        @inject("ApiKeyAuthStrategy") private readonly apiKeyAuthStrategy: ApiKeyAuthStrategy,
        @inject("Config") private readonly config: Config,
    ) { }

    public getStrategy = (req: Request): IAuthStrategy => {
        if (this.config.env === Env.DEV) {
            return this.localAuthStrategy;
        }

        if (req.path.startsWith("/webhooks")) {
            return this.apiKeyAuthStrategy;
        }

        return this.jwtAuthStrategy;
    }
}

@injectable()
class ApiKeyAuthStrategy implements IAuthStrategy {
    constructor(
        @inject("Config") private readonly config: Config,
    ) { }

    authenticate = async (req: Request): Promise<void> => {
        const { authorization } = req.headers;

        if (!authorization) {
            throw new Error("No token provided");
        }

        if (authorization !== this.config.revenueCatApiKey) {
            throw new Error("Invalid token");
        }
    }
}

@injectable()
class JwtAuthStrategy implements IAuthStrategy {
    constructor(
        @inject("IVerifyTokenUC") private readonly verifyTokenUC: IVerifyTokenUC,
    ) { }

    authenticate = async (req: Request): Promise<void> => {
        const { authorization } = req.headers;

        if (!authorization) {
            throw new Error("No token provided");
        }

        const token = authorization.split(" ")[1];

        const cmd: VerifyTokenCmd = {
            token,
        };

        const res = await this.verifyTokenUC.execute(cmd);

        req.userId = res.userId;
    }
}

@injectable()
class LocalAuthStrategy implements IAuthStrategy {
    constructor(
        @inject("Config") private readonly config: Config,
    ) { }

    authenticate = async (req: Request): Promise<void> => {
        req.userId = this.config.devUserId;
    }
}

長くなるし冗長やけど、それぞれのクラスが超具体的な単一の責務を持つ。
SOLID則にも沿う。
学習コストや実装コストこそかかれど、安全で分かりやすいコードになる。

俺はこっちの方が良いと思う。

Discussion