🌲

Next.js App Router の API を宣言的に実装できるようにする

2023/10/31に公開

はじめに

Next.js 14 で Server Action は stable となり、 Server Components と組み合わせて利用すれば、もはや API は必要ないのでは?という話もあると思いますが、この記事は以下のようなユースケースを想定しています。

  • App Router は採用しているものの、基本的には CC (Client Components) がメインで Route Handlers を利用している
  • 独立したAPIサーバーを持たない小規模なプロジェクトや BFF としてライトに Route Handlers を利用している

課題感

https://nextjs.org/docs/app/building-your-application/routing/route-handlers
Route Handlers は app/api/**/route.ts のようなパスでファイルを配置し、 export async function HTTP メソッド名(request: NextRequest) { ... } のように関数を export すると、関数名のメソッドのみを許可したエンドポントとそのハンドラ関数が簡単に定義できる機能です。

サクッと REST API を実装できるのでとても便利ではあるのですが、真面目に認証やバリデーション、エラーハンドリングを実装すると以外とコード量が多くなります。

以下のコードは、ユーザー情報を更新するAPIのサンプルです。

// app/api/users/[userId]/route.ts (Before)

export interface PutUserResponse {
  user: {
    id: number;
    name: string;
    email: string;
  };
}

const userIdSchema = z
  .string()
  .transform((v) => z.number().int().positive().parse(Number(v)));

const bodySchema = z.object({
  name: z.string().min(1).optional(),
  email: z.string().email().optional(),
});

export async function PUT(
  req: NextRequest,
  { params }: { params: { userId: string } }
) {
  const session = await getSession() // 認証サービスからセッションを取得
  if (!session) {
    return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
  }

  let userId: number;
  let body: z.infer<typeof bodySchema>;
  try {
    userId = userIdSchema.parse(params.userId);
    body = bodySchema.parse(req.body);
  } catch (err) {
    return NextResponse.json({ message: "Bad Request" }, { status: 400 });
  }
  try {
    const updatedUser = await updateUser({
      id: userId,
      ...body,
    });
    return NextResponse.json<PutUserResponse>({ user: updatedUser }, { status: 200 });
  } catch {
    return NextResponse.json(
      { message: "Internal Server Error" },
      { status: 500 }
    );
  }
}

解決したいこと

  • 認証やバリデーションを宣言的に実装したい
    • 入力データは、リクエストボディ、URLパスパラメータ、URLサーチパラメータの3種類あり、都度手続的にバリデーションを実装するのが辛い
  • 動的な型付けも行いたい
  • ロジック内に不必要に try / catch を書きたくない
  • 他のハンドラにも横展開できるように共通化したい

改善案

route.ts とは別に再利用可能なヘルパーを用意します。

// app/api/helpers/HttpException.ts

export class HttpException extends Error {
  public status: number;
  constructor(status: number, message?: string) {
    super(message);
    this.name = "HttpException";
    this.status = status;
  }
}
// app/api/helpers/handler.ts

type ResponseJson = Record<string, unknown>;
type ErrorResponseJson = { message: string };

type Context<S extends z.ZodSchema, A extends boolean = boolean> = {
  req: NextRequest;
  input: z.infer<S>;
  session: A extends true ? Session : Session | null;
};

type CreateArgs<S extends z.ZodSchema = z.Schema<unknown>> =
  | {
      authentication?: false;
      inputSchema?: S;
      handler: (ctx: Context<S, false>) => Promise<ResponseJson> | ResponseJson;
    }
  | {
      authentication: true;
      inputSchema?: S;
      handler: (ctx: Context<S, true>) => Promise<ResponseJson> | ResponseJson;
    };

export const apiHandler = {
  create: <S extends z.Schema<unknown> = z.Schema<unknown>>({
    authentication,
    inputSchema,
    handler,
  }: CreateArgs<S>) => {
    return async (
      req: NextRequest,
      { params }: { params: Record<string, unknown> }
    ): Promise<NextResponse<ResponseJson | ErrorResponseJson>> => {
      const session = await getSession();
      if (authentication && !session) {
        return NextResponse.json(
          { message: "Unauthorized" },
          { status: 401 }
        );
      }
      const context = {
        req,
        session,
      } as Context<S, true> & Context<S, false>;

      const searchParams = Object.fromEntries(req.nextUrl.searchParams);
      const body = req.body
        ? ((await req.json()) as Record<string, unknown>)
        : {};

      const input = {
        ...searchParams,
        ...params,
        ...body,
      };
      if (inputSchema) {
        try {
          context.input = inputSchema.parse(input);
        } catch (err) {
          console.error(err);
          return NextResponse.json(
	    { message: "Bad Request" },
	    { status: 400 },
	  );
        }
      } else {
        context.input = input;
      }

      try {
        return NextResponse.json(await handler(context), {
          status: 200,
        });
      } catch (err) {
	console.error(err);
        if (err instanceof HttpException) {
          return NextResponse.json(
            { message: err.message },
            { status: err.status }
          );
        }
        return NextResponse.json(
          { message: "Internal Server Error" },
          { status: 500 }
        );
      }
    };
  },
};

route.ts では apiHandler.create にバリデータやハンドラを渡します。
route.ts 内の処理はかなりシンプルになりました。

// app/api/users/[userId]/route.ts (After)

export interface PutUserResponse {
  user: {
    id: number;
    name: string;
    email: string;
  };
}

const putInputSchema = z.object({
  userId: z
    .string()
    .transform((v) => z.number().int().positive().parse(Number(v)));
  user: z.object({
    name: z.string().min(1).optional(),
    email: z.string().email().optional(),
  }),
});

export const PUT = apiHandler.create({
  authentication: true,
  inputSchema: putInputSchema,
  handler: async ({ session, input }): Promise<PutUserResponse> => {
    // ↓ authentication の値によって null の可能性があるかどうか推論される
    console.log(session);
    // ↓ inputSchema のパース結果の型が推論される
    const { userId: id, name, email } = input;
    const updatedUser = await updateUser({
      id,
      name,
      email,
    });
    return { user: updatedUser };
  },
});

解説

コードの補足として、ポイントとなる部分を解説します。

エラーハンドリング

authentication: true のハンドラで認証が通っていないユーザーからのリクエストの場合 401 Unauthorized 、期待しない入力値を持つリクエスト場合は 400 Bad Request を共通で返すようにしています。
それ以外の 4xx 系のステータスを扱うために、標準の Error に HTTP ステータスコードを追加しただけの HttpException というクラスを用意し、ハンドラ内でスローされた例外が「明示的なエラーハンドリング」なのか「予期しない例外」なのかを判定し、レスポンスにステータスコードやエラーメッセージを渡すようにしています。
「予期しない例外」の場合、共通で 500 Internal Server Error を返します。

バリデーション

入力データのバリデータとして、 Zod のスキーマを受け取るようにしています。
REST API の場合、リクエストの入力データは3種類あり、それぞれ専用のスキーマを用意するのが辛そうだったので一つのスキーマにまとめています。
そのため、プロパティの重複が存在する場合上書きするような実装になっているので注意が必要です。
各入力値を厳密に取り扱いたい場合は、それぞれのスキーマを別々に渡せるように修正する必要があります。

Context

セッション情報やバリデーション(+パース)済みの入力データを含めて、ハンドラのコールバック関数から呼び出せるようにしています。
これによりハンドラ内での手続的な認証やパースの処理を実装せずに済みます。
今回のサンプルコードでは、 request, session, input を context に含めるようにしていますが、他に含めたい情報があれば追加することも可能です。

レスポンス

Before では、ハンドラ内で NextResponse.json(...) を return していましたが、 After では、レスポンスボディの構造体を直接リターンするようにしました。
これは、少なくとも自分のユースケースでは以下のような条件だったためです。

  • 正常系で JSON 以外を返すことがない
  • 正常系の HTTP ステータスコードは 200 固定で問題ない

最後に

今回は、 Next.js App Router での API (Route Handlers) のラッパーを作成し、ハンドラ個々の実装コストを下げることにチャレンジしました。
これから小規模なプロジェクトで実際に運用してみようと思っていますが、まだまだ改善点が出てくると思うので余裕があれば追記していこうと思います。

Discussion