🍣

Next.js:Prismaを使用するAPIのコードをクリーンかつ型安全に保つ

2024/12/01に公開

背景

こんにちは。Nextjsを使用してWebアプリ開発をしているけーじといいます。
Prismaを使ってAPIの処理を記述しているときに、次のように感じたことはありませんか?

  • なんかコードの行数多いな
  • コードが読みにくいな

今回はそんな方に向けて、Prismaを使用したデータ取得APIを対象に、型安全かつ綺麗なコード設計を考えていこうと思います。なお、このパターンが絶対というわけではないので、もっといい設計があるんじゃない?と思った方はぜひご意見ください。

この記事に含まれる内容

この記事では以下のような話をします。

  • PrismaのCRUD関数の引数を分離して定義する方法
  • NextjsのRoute handlersの綺麗なコード設計方針
  • 具体的なコード例

保守性の高いコードを目指している方はぜひ参考までにお茶でも飲みながら読んでいってください。

取り上げる例

本記事では、以下のような要件を例に説明していきます。

要件

よくあるTodoリストを表示することを考えます。ただし、タスクやサブタスクにかかる所要時間(予想値)をユーザーに入力させるといった要件があるとします。

テーブル構造

タスクの子テーブルにサブタスクが多数あるシンプルな構成になっています。deadlineはタスク(サブタスク)の締切日、estimateMinutesはタスク(サブタスク)にかかる所要時間の予想値を表しています。

Prismaを使用したAPIのコードが読みにくい原因

以下のコードを見てみましょう。

フロント側で取得したいタスクとサブタスクの型

src/app/types/Task.ts
import { SubTask } from "./SubTask";

export type Task = {
  id: number;
  title: string;
  estimateMinutes: number;
  subTasks: SubTask[];
};
src/app/types/SubTask.ts
export type SubTask = {
  id: number;
  title: string;
  estimateMinutes: number;
};

request bodyとresponse bodyの型定義

src/app/api/tasks/type.ts
import { Task } from "@/types/Task";

export type GetTasksRequestBody = {
  isDone?: boolean;
  deadlineLimit?: Date;
};

export type GetTasksResponseBody = {
  tasks: Task[];
};

APIのコード

src/app/api/tasks/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GetTasksRequest, GetTasksResponse } from "./type";
import { Task } from "@/types/Task";
import { prisma } from "../../../../prisma/prisma";

export async function GET(request: NextRequest) {
  // query paramsをパース
  const searchParams = await request.nextUrl.searchParams;
  const queryParams: Record<keyof GetTasksRequest, string | null> = {
    deadlineLimit: searchParams.get("deadlineLimit"),
    isDone: searchParams.get("isDone"),
  };
  const reqBody: GetTasksRequest = {
    deadlineLimit: queryParams.deadlineLimit ? new Date(queryParams.deadlineLimit) : undefined,
    isDone: queryParams.isDone ? Boolean(queryParams.isDone) : undefined,
  };

  // request bodyをvalidation
  const deadlineLimitIsValid = reqBody.deadlineLimit ? isNaN(reqBody.deadlineLimit.getTime()) : true;
  const isDoneIsValid = reqBody.isDone ? typeof reqBody.isDone === "boolean" : true;
  if (!deadlineLimitIsValid || !isDoneIsValid) {
    return NextResponse.json({ message: "validation error" }, { status: 400 });
  }

  // データをフェッチ
  const fetchedData = await prisma.task.findMany({
    where: {
      deadline: reqBody.deadlineLimit && {
        lte: reqBody.deadlineLimit,
      },
      isDone: reqBody.isDone,
    },
    select: {
      id: true,
      title: true,
      estimate_minutes: true,
      sub_tasks: {
        select: {
          id: true,
          title: true,
          estimate_minutes: true,
        },
      },
    },
  });

  // フェッチしたデータをレスポンス用に加工
  const tasks: Task[] = fetchedData.map((fetchedTask) => ({
    id: fetchedTask.id,
    title: fetchedTask.title,
    estimateMinutes: fetchedTask.estimate_minutes,
    subTasks: fetchedTask.sub_tasks.map((fetchedSubTask) => ({
      id: fetchedSubTask.id,
      title: fetchedSubTask.title,
      estimateMinutes: fetchedSubTask.estimate_minutes,
    })),
  }));

  // レスポンス生成
  const res: GetTasksResponse = {
    tasks,
  };
  return NextResponse.json(res);
}

はい、見てお分かりの通り多少誇張をしていますがAPI本体の処理が長くなっていて、流れが分かりにくいですね。大まかな流れとしては以下のようになっています。

  1. requestオブジェクトからrequestBodyを抽出
  2. requestBodyをバリデーション
  3. prismaでデータを取得
  4. 取得したデータをresponseの型に合うように加工
  5. responseオブジェクトを作成して返す

この流れ自体はシンプルなのですが、各処理が長くなることで明らかに読みにくくなっています。
大きな原因はデータ取得とデータ加工の部分なので、ここを関数に分離してみましょう。

  1. prisma.findManyの引数を分離
  2. データ加工部分を関数に分離

コード改善その1:prisma.findManyの引数を分離

prisma.findManyに渡す引数は、それぞれPrismaが生成してくれる型を利用して型安全に変数に分離させることができます。今回はwhereとselectを分離する例を見ていきましょう。

src/app/api/_utils/prismaArgsFindManyTask.ts
import { Prisma } from "@prisma/client";
import { GetTasksRequestBody } from "../type";

export const Select: Prisma.TaskSelect = {
  id: true,
  title: true,
  estimate_minutes: true,
  sub_tasks: {
    select: {
      id: true,
      title: true,
      estimate_minutes: true,
    },
  },
};

export function generateWhereInput(reqBody: GetTasksRequestBody): Prisma.TaskWhereInput {
  return {
    deadline: reqBody.deadlineLimit && {
      lte: reqBody.deadlineLimit,
    },
    isDone: reqBody.isDone,
  };
}

解説

Prismaが提供する以下の型を利用しています。

  • Prisma.{model名}WhereInput (今回はPrisma.TaskWhereInput)
    Taskテーブルに対してCRUDするとき引数に渡すwhereの型
  • Prisma.{model名}Select (今回はPrisma.TaskSelect)
    Taskテーブルからデータ取得するときに引数に渡すselectの型

なお、whereはrequest bodyによって動的に内容が変わるため、request bodyを引数に受け取る関数としています。

コード改善その2:データ加工部分を関数に分離

データ加工部分は関数に分離してあげましょう。引数にprismaで取得したデータを受け取り、フロント側で使用するデータ型の通りのデータを返すようにします。実際のコードは以下のようになります。

src/app/api/_utils/fetchedDataToTasks.ts
import { Task } from "@/types/Task";
import { Select } from "./prismaArgsFindManyTask";
import { Prisma } from "@prisma/client";

export type FetchedData = Prisma.TaskGetPayload<{ select: typeof Select }>;

export const fetchedDataToTasks = (fetchedData: FetchedData[]): Task[] => {
  return fetchedData.map((fetchedTask) => ({
    id: fetchedTask.id,
    title: fetchedTask.title,
    estimateMinutes: fetchedTask.estimate_minutes,
    subTasks: fetchedTask.sub_tasks.map((fetchedSubTask) => ({
      id: fetchedSubTask.id,
      title: fetchedSubTask.title,
      estimateMinutes: fetchedSubTask.estimate_minutes,
    })),
  }));
};

ポイントとしてはPrismaで取得したデータ型の定義方法です。Prismaでは、先ほど分離したPrisma.FindManyの引数のうちのSelectを使用して取得データの型を得ることができます。具体的な方法は以下の通りです。

// Prisma.{model名}GetPayloadとする
export type FetchedData = Prisma.TaskGetPayload<{ select: typeof Select }>;

ちなみに、型引数にはincludeも渡すことができるため必要に応じて渡してあげましょう。

まとめ

以上の改善により、最終的に出来上がるコードは以下のようになります。

src/app/api/tasks/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GetTasksRequestBodySchema, GetTasksResponseBody } from "./type";
import { prisma } from "../../../../prisma/prisma";
import { extractSearchParams } from "../_utils/extractSearchParams";
import { FetchedData, fetchedDataToTasks } from "./_utils/fetchedDataToTasks";
import { generateWhereInput, Select } from "./_utils/prismaArgsFindManyTask";

export async function GET(request: NextRequest) {
  // query paramsをパース
  const reqBody = await extractSearchParams(request, GetTasksRequestBodySchema);

  // request bodyをvalidation
  if (!reqBody) return NextResponse.json({ message: "validation error" }, { status: 400 });

  // データをフェッチ
  const fetchedData: FetchedData[] = await prisma.task.findMany({
    where: generateWhereInput(reqBody),
    select: Select,
  });

  // フェッチしたデータをレスポンス用に加工
  const tasks = fetchedDataToTasks(fetchedData);

  // レスポンス生成
  const res: GetTasksResponseBody = {
    tasks,
  };
  return NextResponse.json(res);
}

ちなみに、request bodyのパースとバリデーション部分は本題からずれるので説明しませんでしたが、こちらの記事を参考に共通化することができます。(zodを使用します。)
https://zenn.dev/kg_filled/articles/499397258fbbae

長くなりましたが、本記事を参考にnext.jsのroute handlersの設計がより良くなれば幸いです。

  • request bodyのパースとバリデーション → prismaでデータ取得 → データ加工の流れで処理
  • prismaでのデータ取得は各引数を分離
    Prisma.{model}WhereInput, Prisma.{model}Selectを使用
  • データ加工は別の関数に分離
    引数に受け取るデータの型はPrisma.{model}GetPayloadで取得できる

Discussion