🌋

Next.js の非同期関数 piping と Error 設計

2022/06/10に公開約14,600字

getServerSideProps や API Routes で未認証リクエストを弾く方法はいくつかあります。例えば、公式では HOF(Higher-Order Function)を使った例が紹介されていて(withSession関数)認証要件ページで利用できるアプローチです。今回は別のアプローチとして、関数合成を用いたものを紹介します。

関数合成とは

現在 stage2 の Pipe Operator でご存じの方も多いと思いますが、reduce を使って複数の関数を合成するテクニックがあります。合成された関数はシリアルに実行され、戻り値が次関数の引数となり、演算することができます。例えば以下の例では、0 から順番に 100,200,300 と加算していき、最終的に 600 を得ることができます。

function pipeSync(...fns) {
  return (args) => {
    return fns.reduce((val, fn) => fn(val), args);
  };
}
function sum(prev, value) {
  console.log(`sum = ${prev + value}`);
  return prev + value;
}
pipeSync(
  (val) => sum(val, 100),
  (val) => sum(val, 200),
  (val) => sum(val, 300)
)(0);

// sum = 100
// sum = 300
// sum = 600
// 600

非同期関数合成とは

同じように、非同期関数(async function)を合成することができます。pipeAsync関数では、与えられた ms だけ待機し、経過時間をログ出力します。このように、上から順番に非同期関数を直列実行することによって、初めの非同期関数で「認証されているか」をチェックすることに応用できます。

function pipeAsync(...fns) {
  return (args) => {
    return fns.reduce(
      (current, next) => current.then(next),
      Promise.resolve(args)
    );
  };
}
async function wait(passed, delay) {
  await new Promise((resolve) => {
    setTimeout(resolve, delay);
  });
  console.log(`passed ${passed + delay}ms`);
  return passed + delay;
}
await pipeAsync(
  async (passed) => wait(passed, 100),
  async (passed) => wait(passed, 200),
  async (passed) => wait(passed, 300)
)(0);

// passed 100ms
// passed 300ms
// passed 600ms
// 600

完成例

下の例は、前回投稿で紹介したものです。/api/usersの API Routes 定義はauth関数を挟むことによって、未認証リクエストを弾いています。combineHandlers関数が、上記のpipeAsync関数を応用したものになります。

src/pages/api/users/index.ts
import { auth, combineHandlers, handle, methods } from "@/lib/next/api";
import { createUser, getUsers } from "@/services/api.example.com/users";

export default combineHandlers(
  auth,
  methods({
    GET: handle(() => getUsers()),
    POST: handle(({ body }) => createUser(body)),
  })
);

API Routes の関数合成(combineHandlers 関数)

API Routes のハンドラーは、先のpipeAsync関数と異なり、2 つの引数(req,res)をとります。

node_modules/next/dist/shared/lib/utils.d.ts
export declare type NextApiHandler<T = any> = (
  req: NextApiRequest,
  res: NextApiResponse<T>
) => unknown | Promise<unknown>;

このため、残余引数構文(...args)を利用した piping を施す必要があります。current.thennext関数をそのままバインドせず、以下の様に処理します。

src/lib/next/api/middlewares/combineHandlers.ts
export function combineHandlers(...handlerMiddlewares) {
  return async (...args) => {
    // 可変長引数に渡された Middleware 関数を順次実行
    return await handlerMiddlewares.reduce(
      (current, next) =>
        current.then((args) => {
          if (typeof next !== "function") return args;
          return next(...args);
        }),
      Promise.resolve(args)
    );
  };
}

直列処理の中断

さて、このままでは未認証の場合でも直列処理を続けてしまうことになります。未認証の場合は、即座に処理を中断し、エラーレスポンスを返す必要があると思います。これを実現するために try...catchthrow Error が活用できます。

  • Middleware 内部 で HttpError クラスインスタンスを throw
  • 意図した HttpError クラスインスタンスを catch した場合はレスポンスを返す
src/lib/next/api/middlewares/combineHandlers.ts
export function combineHandlers(...handlerMiddlewares) {
  return async (...args) => {
    try {
      return await handlerMiddlewares.reduce(...); // 省略
    } catch (err) {
      // いずれかの Middleware 関数内部で HttpError インスタンスがスローされた場合
      if (err instanceof HttpError) {
        args[1].status(err.status).json(err.serialize());
        return;
      }
      throw err; // 想定外のエラーはリスロー
    }
  };
}

args[1]は(req, res)のresですね。Error クラスを少し拡張した HttpError クラスは、Http レスポンスを返すことを想定して設計しています。

HttpError クラス内訳
src/errors.ts
export const errors = {
  VALIDATION: { message: "Validation Error", status: 400 },
  INVALID_PATH_PARAM: { message: "Invalid Path Param Error", status: 400 },
  INVALID_PARAMS: { message: "Invalid Params Error", status: 400 },
  UNAUTHORIZED: { message: "Unauthorized Error", status: 401 },
  FORBIDDEN: { message: "Forbidden Error", status: 403 },
  NOT_FOUND: { message: "Not Found Error", status: 404 },
  METHOD_NOT_ALLOWED: { message: "Method Not Allowed Error", status: 405 },
  CONFLICT: { message: "Conflict Error", status: 409 },
  INTERNAL_SERVER: { message: "Internal Server Error", status: 500 },
  NOT_IMPLEMENTED: { message: "Not Implemented", status: 501 },
};
export class HttpError extends Error {
  status: number = 400;
  constructor(key: keyof typeof errors) {
    super(key);
    this.message = errors[key].message;
    this.status = errors[key].status;
  }
  serialize() {
    return { message: this.message, status: this.status };
  }
}
export class ValidationError extends HttpError {
  constructor() {
    super("VALIDATION");
  }
}
export class InvalidPathParamError extends HttpError {
  constructor() {
    super("INVALID_PATH_PARAM");
  }
}
export class InvalidParamsError extends HttpError {
  constructor() {
    super("INVALID_PARAMS");
  }
}
export class UnauthorizedError extends HttpError {
  constructor() {
    super("UNAUTHORIZED");
  }
}
export class ForbiddenError extends HttpError {
  constructor() {
    super("FORBIDDEN");
  }
}
export class NotFoundError extends HttpError {
  constructor() {
    super("NOT_FOUND");
  }
}
export class MethodNotAllowedError extends HttpError {
  constructor() {
    super("METHOD_NOT_ALLOWED");
  }
}
export class ConflictError extends HttpError {
  constructor() {
    super("CONFLICT");
  }
}
export class InternalServerError extends HttpError {
  constructor() {
    super("INTERNAL_SERVER");
  }
}
export class NotImprementedError extends HttpError {
  constructor() {
    super("NOT_IMPLEMENTED");
  }
}

さきの auth 関数内訳は次の様になっていました。session.userが未定義であれば、UnauthorizedErrorクラスインスタンスを throw します。UnauthorizedErrorクラスはHttpErrorクラスのサブクラスにあたるので、err instanceof HttpErrorでも捉えることができ、こういった場面でクラス継承を活用することができます。

src/lib/next/api/middlewares/auth.ts
import { UnauthorizedError } from "@/errors";
import { getSession } from "@/lib/next-session";
import { HandlerMiddleware } from "../type";

export const auth: HandlerMiddleware = async (...args) => {
  const session = await getSession(args[0], args[1]);
  if (!session.user) throw new UnauthorizedError();
  return [...args, session.user]; // [req, res, session.user] になる
};

Middleware 関数の実装制約として、[req, res, ...unknown[]]を返す必要があります。この戻り値が次関数以降の引数となりますから、次関数以降では第三引数にsession.userをとることができます。ここまで、型まで含めた実装内訳は以下に貼っておきます。

combineHandlers 関数内訳
src/lib/next/api/middlewares/combineHandlers.ts
import { HttpError } from "@/errors";
import type { NextApiRequest, NextApiResponse } from "next";

export type HandlerMiddlewareArgs<T = any> = [
  NextApiRequest,
  NextApiResponse<T>,
  ...unknown[]
];

export type HandlerMiddleware<T = any> = (
  ...args: HandlerMiddlewareArgs<T>
) => Promise<HandlerMiddlewareArgs<T>>;

export function combineHandlers(...handlerMiddlewares: HandlerMiddleware[]) {
  return async (...args: HandlerMiddlewareArgs) => {
    try {
      // 可変長引数に渡された Middleware 関数を順次実行
      return await handlerMiddlewares.reduce(
        (current, next) =>
          current.then((args) => {
            if (typeof next !== "function") return args;
            return next(...args);
          }),
        Promise.resolve(args)
      );
    } catch (err) {
      // いずれかの Middleware 関数内部で HttpError インスタンスがスローされた場合
      if (err instanceof HttpError) {
        args[1].status(err.status).json(err.serialize());
        return;
      }
      throw err;
    }
  };
}
methods 関数内訳
src/lib/next/api/middlewares/methods.ts
import { HttpError, MethodNotAllowedError } from "@/errors";
import { HandlerMiddleware } from "../type";

type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type Handlers = { [K in Method]?: HandlerMiddleware };

function checkHandler(handler?: unknown) {
  function assertImplemented(fn?: unknown): asserts fn is HandlerMiddleware {
    if (!fn) throw new MethodNotAllowedError();
  }
  assertImplemented(handler);
  return handler;
}

export const methods = (handlers: Handlers): HandlerMiddleware => {
  return async (...args) => {
    try {
      const handler = checkHandler(handlers[args[0].method as Method]);
      return handler(...args);
    } catch (err) {
      if (err instanceof HttpError) {
        args[1].status(err.status).json(err.serialize());
      }
    }
    return args;
  };
};
handle 関数内訳
src/lib/next/api/middlewares/handle.ts
import { HttpResponse } from "@/services/api/fetcher/type";
import { HandlerMiddleware, HandlerMiddlewareArgs } from "../type";

export function handle<T>(
  next: (...args: HandlerMiddlewareArgs<T>) => Promise<HttpResponse<T>>
): HandlerMiddleware<T> {
  return async (...args: HandlerMiddlewareArgs) => {
    const { data, err, status } = await next(...args);
    if (data) args[1].status(status).json(data);
    if (err) args[1].status(status).json(err);
    return args;
  };
}

API Routes の関数合成は以上です。

getServerSideProps の関数合成(combineGssp 関数)

つぎに、getServerSideProps の関数合成です。combineGssp 関数が getServerSideProps 用のもので、完成例の Page 実装は次のようになります。API Routes と同じように、専用のauth関数で未認証アクセスを弾きます。

src/pages/users/index.tsx
import { BasicLayout } from "@/components/layouts/BasicLayout/BasicLayout";
import { Error } from "@/components/organisms/Error";
import { Users } from "@/components/templates/Users";
import { auth, combineGssp } from "@/lib/next/gssp";
import { HttpResponse } from "@/services/api.example.com/fetcher/type";
import { getUsers, UsersData } from "@/services/api.example.com/users";
import { NextPageWithLayout } from "@/types";

type Props = HttpResponse<UsersData>;

const Page: NextPageWithLayout<Props> = ({ data, err }) =>
  err ? <Error {...err} /> : <Users {...data} />;
Page.getLayout = BasicLayout;

export const getServerSideProps = combineGssp<Props>(auth, async () => ({
  props: await getUsers(),
}));

export default Page;

API Routes の関数合成と異なる点

API Routes のものと異なりややこしいのは、合成関数の実行戻り値が、以下のGetServerSidePropsResult型に相当する必要があるということです。データ取得に成功・失敗した場合は{ props: P | Promise<P> }を返せばよいのですが、それ以外の結果に対しては{ redirect: Redirect }{ notFound: true }を返す必要があります。

node_modules/next/types/index.d.ts
export type GetServerSidePropsResult<P> =
  | { props: P | Promise<P> }
  | { redirect: Redirect }
  | { notFound: true };

この戻り値となったとき、Next.js は内部でリダイレクト処理をかけたり、404 ページを強制表示させたりします。「未認証の場合はログインページへリダイレクト」 という実装がまさにこのケースに相当します。

API Routes の場合は Middleware を pipe するために[req, res, ...unknown[]]を返すこととしていましたが、この点に関しても終点の非同期関数ではGetServerSidePropsResultを返さなければいけません。

また、getServerSideProps 用の Middleware は、[ctx, ...unknown[]]を返すように、制約します(ctx は GetServerSidePropsContext)

combineGssp 関数の catch 処理

上記観点から、combineGssp 関数内部で例外を catch した場合は、GetServerSidePropsResult型相当の戻り値に畳み込みます。HttpErrorサブクラスインスタンスが throw された場合は{ props: P | Promise<P> }に変換、ページ単位で用意した任意のエラー表示 Component に、データ取得が失敗した旨の props を返却します。

src/lib/next/gssp/middlewares/combineGssp.ts
export function combineGssp(...gsspMiddlewares) {
  return async (...args) => {
    try {
      return await gsspMiddlewares.reduce(
        (curent, next) =>
          curent.then((args) => {
            if (typeof next !== "function") return args;
            return next(...args);
          }),
        Promise.resolve(args)
      );
    } catch (err) {
      if (err instanceof HttpError) {
        const props: ErrResponse = {
          data: null,
          err: err.serialize(),
          status: err.status,
        };
        return { props };
      }
      if (err instanceof RedirectError) return err.toProps();
      throw err;
    }
  };
}

ここで登場するRedirectErrorは、HttpErrorのサブクラスではありません。GetServerSidePropsResultのうちの{ redirect: Redirect }を返すためのクラスで、Redirect props を構築するための関数を備えています。

src/lib/next/gssp/error.ts
import { GetServerSidePropsResult } from "next";

export class RedirectError extends Error {
  destination: string;
  basePath?: false;
  permanent?: boolean;

  constructor(redirect: {
    permanent: boolean;
    destination: string;
    basePath?: false;
  }) {
    super("redirect error");
    this.destination = redirect.destination;
    this.basePath = redirect.basePath;
    this.permanent = redirect.permanent;
  }

  toProps(): GetServerSidePropsResult<unknown> {
    return {
      redirect: {
        destination: this.destination,
        basePath: this.basePath,
        permanent: !!this.permanent,
      },
    };
  }
}

この様な設計としているので、未認証を弾くauth関数は次の様になっています。状況に応じて、Redirect props のパラメーターを調整します。

src/lib/next/gssp/middlewares/auth.ts
import { getSession } from "@/lib/next-session";
import { RedirectError } from "../error";
import { GsspMiddleware } from "../type";

export const auth: GsspMiddleware = async (...args) => {
  const session = await getSession(args[0].req, args[0].res);
  if (!session.user)
    throw new RedirectError({ permanent: false, destination: "/" });
  return [...args, session.user];
};
combineGssp 関数内訳
src/lib/next/gssp/middlewares/combineGssp.ts
import { HttpError } from "@/errors";
import { ErrResponse } from "@/services/api.example.com/fetcher/type";
import { PreviewData } from "next";
import { ParsedUrlQuery } from "querystring";
import { RedirectError } from "../error";
import {
  GetServerSideProps,
  GsspMiddleware,
  GsspMiddlewareArgs,
} from "../type";

export function combineGssp<
  T,
  Q extends ParsedUrlQuery = ParsedUrlQuery,
  D extends PreviewData = PreviewData
>(...gsspMiddlewares: [...GsspMiddleware[], GetServerSideProps<T, Q, D>]) {
  return async (...args: GsspMiddlewareArgs) => {
    try {
      return await gsspMiddlewares.reduce(
        (curent, next) =>
          curent.then((args) => {
            if (typeof next !== "function") return args;
            return (next as GsspMiddleware)(...args);
          }),
        Promise.resolve(args)
      );
    } catch (err) {
      if (err instanceof HttpError) {
        const props: ErrResponse = {
          data: null,
          err: err.serialize(),
          status: err.status,
        };
        return { props };
      }
      if (err instanceof RedirectError) return err.toProps();
      throw err;
    }
  };
}

Next.js 組み込みの 404 ページへ飛ばしたい場合は同じような Middleware を用意し、{ notFound: true }を展開するエラークラスを設けると実現できるでしょう。

まとめ

普段のフロントエンド実装ではあまりクラスを使用することはありませんが、今回の Error を throw する様な設計の場合、try...catchinstanceof、クラス継承は有効に活用することができます。

Discussion

ログインするとコメントできます