2024-3I プログラミング3 第09回 講義資料

2024年12月19日(木)1・2時限

1 連絡・概要

1.1 連絡

1.2 協力依頼

1.3 ここまでの授業の流れ

前回講義では「ブログアプリ」のバックエンド開発として、「カテゴリ」のCRUDに関連するウェブAPIの設計と実装を行ないました。

エンドポイント HTTP
Method
処理 管理者
権限
リクエスト
ボディ
/api/categories GET カテゴリの一覧を取得する
/api/admin/categories POST カテゴリを追加する 必要 必要
/api/admin/categories/[id] PUT カテゴリの名前を変更する 必要 必要
/api/admin/categories/[id] DELETE カテゴリを削除する 必要

各APIの実装では、O/R Mapper である PrismaClient を通して リレーショナルデータベース (RDB) に対するCRUD操作 について学びました。なお、現在は RDB として「SQLite」を使用していますが、次々回以降に Supabase の「PostgreSQL」に切り替える予定です。

今回講義 (+冬休みの宿題を含む) では「投稿記事」に関連するウェブAPIの実装と、各APIと連携するフロントエンド開発 (=編集用のフォームを含む画面の実装) について解説していきます。

2 投稿記事に関連するウェブAPIの実装

投稿記事」に関連するCRUD操作のインターフェイスとして、次のようなウェブAPIを実装していきます。

エンドポイント HTTP
Method
処理 管理者
権限
リクエスト
ボディ
/api/posts GET 投稿記事の一覧を取得する
/api/posts/[id] GET 投稿記事 (単体) を取得する
/api/admin/posts POST 投稿記事を追加する 必要 必要
/api/admin/posts/[id] PUT 投稿記事の内容を変更 (編集) する 必要 必要
/api/admin/posts/[id] DELETE 投稿記事を削除する 必要

投稿記事については、id を指定して 投稿記事 (単体) を取得 するAPI (GET Method 対応) も実装していきます。

2.1 準備

解説に示す実行結果と、皆さんの手元での実行結果を一致させるために、以下のコマンドを実行して DBのレコードの初期化 を実行しておいてください。なお、適切に実行結果の読み替えができる場合は初期化の必要はありません。

npx prisma db seed

注意: prisma/seed.ts の内容がこちらのようになっている前提です。

2.2 投稿記事の一覧を取得するウェブAPIの実装

はじめに、GET Method に対応した 投稿記事の「一覧」を取得するためのエンドポイント (/api/posts) を実装していきます。基本的には前回講義で実装したカテゴリの一覧の取得と同じ構造となります。

src/app/api/posts/route.ts を作成して、次のプログラムを貼付けてください。また、プログラムを読み解いてください。

import prisma from "@/lib/prisma";
import { NextResponse, NextRequest } from "next/server";
import { Post } from "@prisma/client";

export const GET = async (req: NextRequest) => {
  try {
    const posts: Post[] = await prisma.post.findMany({
      orderBy: {
        createdAt: "desc",
      },
    });
    return NextResponse.json(posts);
  } catch (error) {
    console.error(error);
    return NextResponse.json(
      { error: "投稿記事の一覧の取得に失敗しました" },
      { status: 500 }
    );
  }
};

Thunder Clinet を使って /api/posts にGETリクエストを送って、次のような 「id」「title」「content」「coverImageURL」「createdAt」「updatedAt」を属性に持ったオブジェクトの配列 (JSON形式) がレスポンスとして得られることを確認してください。

[
  {
    "id": "d6993236-ac1c-40cf-a4af-7ff405b7e96f",
    "title": "投稿4",
    "content": "投稿4の本文。<br/>投稿4の本文。投稿4の本文。",
    "coverImageURL": "https://...",
    "createdAt": "2024-12-05T05:58:44.095Z",
    "updatedAt": "2024-12-05T05:58:44.095Z"
  },
  //  省略 
  {
    "id": "18cd63c0-d364-4c29-9e92-fa68b8d65cfc",
    "title": "投稿1",
    "content": "投稿1の本文。<br/>投稿1の本文。投稿1の本文。",
    "coverImageURL": "https://...",
    "createdAt": "2024-12-05T05:58:44.073Z",
    "updatedAt": "2024-12-05T05:58:44.073Z"
  }
]

注意

microCMS の /api/posts のレスポンスは、次のような構造の JSON でした。一方で、今回の講義で自作したAPI /api/posts のレスポンスの構造は上記に示す通りです。

{
  "contents": [
    {
      "id": "3x24sup0aq26",
      "createdAt": "2024-11-14T09:34:17.968Z",
      "updatedAt": "2024-11-14T09:43:00.987Z",
      "publishedAt": "2024-11-14T09:34:17.968Z",
      "revisedAt": "2024-11-14T09:43:00.987Z",
      "title": "投稿1",
      "content": "<p>春はあけぼの...",
      "coverImage": {
        "url": "https://images.microcms-assets.io/...",
        "height": 768,
        "width": 1365
      }
    },
    {
      "id": "...",
      "createdAt": "...",
      "updatedAt": "...",
      // 省略
    },
        {
      "id": "...",
      "createdAt": "...",
      "updatedAt": "...",
      // 省略
    },
  ],
  "totalCount": 1,
  "offset": 0,
  "limit": 10
}

ここで、注意して欲しいことは「投稿記事の配列」が、microCMS のときは contents というプロパティで与えられていたのに対して、ここで実装したAPIでは直接的に与えられる形式に変更していることです。そのため、記事一覧や個別記事表示を処理するフロントエンド側でも、この変更にあわせてコードの変更が必要になります。

const data = await response.json();
setPosts(data.contents as Post[]); 
const data = await response.json();
setPosts(data as Post[]);

これ以外にも、自作バックエンドにあわせて各種変更が必要になります (例えば、fetch の引数に "X-MICROCMS-API-KEY": apiKey が不要になるなど)。それらは、適宜、変更修正してください。

ところで、このレスポンスを利用して以下のような画面 (=投稿記事の一覧表示の画面 / トップページ) を構成しようとすると 「カテゴリ」に関する情報が不足していること に気づくでしょうか。また coverImageURLupdatedAt は冗長な情報になることに気づくでしょうか。

img

RDB には情報が「正規化」されて格納されているため、投稿記事テーブル (Post) だけを参照しても 記事に紐づく「カテゴリ」の情報 を得ることは で・き・ま・せ・ん。このようなとき、RDB では (中間テーブルを介した使った) テーブルの結合操作 というものが必要となってきます。SQL文では JOIN というキーワードでテーブルの結合処理をします。

(プロンプト例)

リレーショナルデータベースにおいてテーブルの結合 (JOIN) は、どのようなときに必要となりますか。投稿記事とカテゴリが「多対多」の関係になっているブログデータを扱う場合を例に考えてみてください。

しかし、このブログアプリ開発では、ORM である Prisma を利用しているため、(JOIN を使った SQL文 を記述することなく) 比較的簡単に 投稿記事に紐づくカテゴリ情報の取得が可能 となります🙌。具体的には route.ts第07行目 findMany メソッドの第1引数に「selectオプション」を追加することによって可能になります。

実際に、次のように findMany の第1引数を変更してみてください。そして、投稿記事の情報 (coverImageURLupdatedAt を除いたもの) に加えて、それに紐づけられたカテゴリの「id」と「name」 がレスポンスとして返ってくることを確認してください。

const posts = await prisma.post.findMany({ // ◀ 推論を利用して posts の型を決定
  select: {
    id: true,
    title: true,
    content: true,
    createdAt: true,
    categories: {
      select: {
        category: {
          select: {
            id: true,
            name: true,
          },
        },
      },
    },
  },
  orderBy: {
    createdAt: "desc",
  },
});

なお、上記のように第1引数を変更したときは、findManyメソッドの「戻り値」に カテゴリの情報が含まれるようになる ため、第07行目const posts: Post[] = ...const posts = ... のように変更してください。つまり 型推論を利用して posts の型を決定する ようにしてください。posts: Post[] のままでは「型」の不一致によるエラーが発生するので注意してください。

応用: selectを指定したときの「型」を明示するためには…

findMany の引数に select を含むオブジェクトを指定するとき、型推論を使わずに posts に対して明示的に型を指定したいときはこちらのようにしてください。ただし、これは中級以上の内容になってくるので、無理に利用する必要はありません。

ThunderClinet を使って、/api/posts のレスポンスのボディが次のようになっていること (=投稿記事に紐づくカテゴリの「id」と「name」が取得できること) を確認してください。

[
  {
    "id": "d6993236-ac1c-40cf-a4af-7ff405b7e96f",
    "title": "投稿4",
    "content": "投稿4の本文。<br/>投稿4の本文。投稿4の本文。",
    "createdAt": "2024-12-05T05:58:44.095Z",
    "categories": []
  },
  //  省略 
  {
    "id": "18cd63c0-d364-4c29-9e92-fa68b8d65cfc",
    "title": "投稿1",
    "content": "投稿1の本文。<br/>投稿1の本文。投稿1の本文。",
    "createdAt": "2024-12-05T05:58:44.073Z",
    "categories": [
      {
        "category": {
          "id": "f5ee7b9c-ee8e-466e-aafc-6aa112005d5a",
          "name": "カテゴリ1"
        }
      },
      {
        "category": {
          "id": "d3e10601-3cfa-48f3-9ede-886647a1063b",
          "name": "カテゴリ2"
        }
      }
    ]
  }
]

2.2.1 補足: selectプロパティに関する解説 1

PrismaClient の findManyメソッドfindUniqueメソッド (=レコード単体の取得に使用) の引数に設定可能な select プロパティは、SQL文の SELECT に相当するもので、戻り値に得たい カラム (属性) を選択 (指定) するために使用します。

findMany/findUnique の第1引数として与えるオブジェクトに select プロパティを含めなければ そのテーブルが持っている全てのカラム (列) が戻り値となります。例えば prisma.post.findMany メソッドにおいて、select を指定しなければ「id」「title」「content」「coverImageURL」「createdAt」「updatedAt」の 6個のプロパティを持ったオブジェクトの配列 が「戻り値」となります。

一方で、メソッドの引数に select: { id: true, title: true, content: true } を与えれば、「id」「title」「content」の 3つだけのプロパティを持ったオブジェクト (単体/配列) が「戻り値」となります。

ここで 「そのテーブルが持つすべてのカラム (列)とは何か?」 ということについて正しく理解しておく必要があります。前回講義 では prisma/schema.prisma というファイルで、各テーブルのスキーマ (=構造・型・制約) を次のように定義しました。

// 投稿記事テーブル
model Post {
  id             String @id @default(uuid())
  title          String
  content        String
  coverImageURL  String
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
  categories     PostCategory[]
}

// カテゴリテーブル
model Category {
  id         String @id @default(uuid())
  name       String @unique
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
  posts      PostCategory[]
}

// 投稿記事とカテゴリを紐づける中間テーブル
model PostCategory {
  id          String @id @default(uuid())
  postId      String
  categoryId  String
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  category    Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
  post        Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
}

これらの定義を ER図 (風) に表現すると以下のようになります (ER図: Entity Relationship Diagram については「データベース工学」で詳しく学びます。ここでは「RDBの設計図」と考えてください)。上記の schema.prisma の記述と、以下のER図の対応関係が (なんとなく) 分かるでしょうか🤔

img

ER図のなかで 赤色で prisma と示したカラム、具体的には、投稿記事テーブルの categories カラム、中間テーブルの categorypost カラム、カテゴリテーブルの posts のカラムは 扱いに注意が必要 です。

これらのカラムは、Prisma が O/R Mapper として内部的に使用する参照情報 (=ユーザにオブジェクト指向的なデータアクセスを提供するための設定情報) であり、実際の RDB のテーブルには存在しないカラム となっています。

実際に SQLite のコマンドラインツールである sqlite3.exe を使って prisma/dev.db に接続して「投稿記事テーブル」のスキーマを確認すると (.schema Post コマンドを実行すると)、次のような結果になります。

sqlite> .schema Post
CREATE TABLE IF NOT EXISTS "Post" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "title" TEXT NOT NULL,
    "content" TEXT NOT NULL,
    "coverImageURL" TEXT NOT NULL,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" DATETIME NOT NULL
);

idtitle などのカラムは存在しますが、categories というカラムは 存・在・し・な・い・こ・と が確認できます。このように 実際の RDB のテーブルに存在しないカラムの値 は、select オプション (あるいは include オプション) を指定して取得する必要があります。

なお、schema.prisma のフィールドのうち、RDB にカラム (列) が作成されないのは categories PostCategory[]posts PostCategory[] のように「配列型」になっているものと、@relation(...) の属性がついたものとなります。つまり、schema.prisma9、18、28、29行目 についてはカラムが作成されません。

2.2.2 補足: selectプロパティに関する解説 2

prisma.post.findMany を使って、「Categoryテーブル」のなかの idname の情報同時に取得するためには、以下の ➊ → ➋ → ➌ のように段階的にアプローチする必要があります。

img

この の ➊ → ➋ → ➌ のアプローチが、さきほどの select (第13行目から第22行目) に対応しています。

const posts = await prisma.post.findMany({
  select: {
    id: true,
    title: true,
    content: true,
    createdAt: true,
    categories: { // ◀ ➊
      select: {
        category: { // ◀ ➋
          select: {
            id: true,    // ◀ ➌
            name: true,  // ◀ ➌
          },
        },
      },
    },
  },
  orderBy: {
    createdAt: "desc",
  },
});

select オプションの指定は理解が難しいと思うので、生成AIなどのサポートを受けてください。

(プロンプト例)

prisma の findMany の引数に設定可能な selectinclue_count について丁寧に解説してください。投稿記事とカテゴリが「多対多」の関係になっているブログデータを扱う場合を例に解説してください。

上記以外に findMany メソッドでは、どのようなオプションが利用できますか。

2.3 投稿記事を新規作成するウェブAPIの実装

投稿記事を新規作成 (POST メソッドで記事を新規投稿) するためのウェブAPIを実装していきます。

エンドポイントは /api/admin/posts として、以下のようなリクエストボディを受け付ける想定で設計します。投稿記事にカテゴリを1個も設定しないことも許可し、その場合は "categoryIds": [] のように 空配列 を与えるものとします。

{
  "title": "投稿X",
  "content": "投稿Xの本文。<br/>投稿Xの本文。投稿Xの本文。",
  "coverImageURL": "https://....",
  "categoryIds": [
    "d3e10601-3cfa-48f3-9ede-886647a1063b",
    "5a8d8365-e435-4ed4-a05e-34237c1b2ff0"
  ]
}

投稿記事の追加では、次のような点を考慮する必要があるため、前回講義のカテゴリの追加よりも複雑な処理となります。

  1. リクエストボディのバリエーション (データ検証) の処理
  2. categoryIdsの要素に一致するIDを持ったカテゴリが存在しない場合の処理
  3. 中間テーブルにレコードを追加する処理

まず、「1番目」のバリエーションですが、これはカテゴリの追加でも本来は必要だった処理になります。具体的には、リクエストボディについて「必要なプロパティを持っているか「プロパティが期待する型であるか」文字数制限 (例えば2文字以上32文字以内) が守られているか「URLとして適切な形式になっているか」といったチェックをする処理になります。この処理は 紙面の都合で省略します (余裕がある学生はバリエーションを実装し、問題があれば 400 Bad Request をレスポンスするようにしてください)。

次に「2番目」ですが、これは「実在するカテゴリだけを投稿記事に紐づける」または「実在しないカテゴリがあれば、エラーとしてリクエストを拒否する」 という対応が考えられますが、ここではリクエストを拒否する方針で設計します。

3番目」は必須の処理となります。

以上を踏まえて投稿記事の追加するAPIは、以下のように実装することができます。

import prisma from "@/lib/prisma";
import { NextResponse, NextRequest } from "next/server";
import { Post } from "@prisma/client";

type RequestBody = {
  title: string;
  content: string;
  coverImageURL: string;
  categoryIds: string[];
};

export const POST = async (req: NextRequest) => {
  try {
    const requestBody: RequestBody = await req.json();

    // 分割代入
    const { title, content, coverImageURL, categoryIds } = requestBody;

    // categoryIds で指定されるカテゴリがDB上に存在するか確認
    const categories = await prisma.category.findMany({
      where: {
        id: {
          in: categoryIds,
        },
      },
    });
    if (categories.length !== categoryIds.length) {
      return NextResponse.json(
        { error: "指定されたカテゴリのいくつかが存在しません" },
        { status: 400 } // 400: Bad Request
      );
    }

    // 投稿記事テーブルにレコードを追加
    const post: Post = await prisma.post.create({
      data: {
        title, // title: title の省略形であることに注意。以下も同様
        content,
        coverImageURL,
      },
    });

    // 中間テーブルにレコードを追加
    for (const categoryId of categoryIds) {
      await prisma.postCategory.create({
        data: {
          postId: post.id,
          categoryId: categoryId,
        },
      });
    }

    return NextResponse.json(post);
  } catch (error) {
    console.error(error);
    return NextResponse.json(
      { error: "投稿記事の作成に失敗しました" },
      { status: 500 }
    );
  }
};

第05行目 から 第10行目 では、POSTリクエストの「ボディ」として想定される JSONデータ に適合する「型」を定義しています (他のAPIでも同じ型を使う場合は、別ファイルに型を定義してインポートするほうが望ましいです)。また、第14行目 では、リクエストのボディ (=JSON形式の文字列) を、JavaScriptオブジェクトに変換して requestBody に代入しています。

第20行目 から 第32行目 では「リクエストボディの categoryIds に含まれるIDのカテゴリが DB に存在するか」を確認し、存在しないカテゴリがひとつでもあれば、処理を中断してステータスコード「400」をレスポンスするようにしています。ここでは prisma.category.findMany の引数を where: {id: {in: categoryIds } } とすることで「categoryIds 配列の各要素に対応するカテゴリIDを一括検索する処理」を実行しています。これにより、forループなどの繰り返し処理を使用することなく 一度の操作で複数のレコードを効率的に取得することができます。そして、取得したレコード数と categoryIds の要素数を比較し、一致しない場合は 指定されたカテゴリIDの一部がDBに存在しない と判断してステータスコード「400」を返しています。

参考情報

次の PrismaClientの処理 (TypeScriptプログラム) は、

prisma.category.findMany( { where: { id: { in: ["c-001","c-002","c-003"] }}} );

次のような SQL と同等になります。

SELECT * FROM category WHERE id IN ('c-001', 'c-002', 'c-003');

第44行目 から 第51行目 では、中間テーブル (postCategory) に、投稿記事とカテゴリを紐づけるためのレコードを挿入しています。

各処理の概要が理解できたら、TunderClientPrisma Studio を利用してエンドポイント /api/admin/posts の動作確認を行なってください。この際、あえて不適切なボディ (例えば、存在しないカテゴリIDを含むデータなど) を与え、そのレスポンスや VSCodeのターミナルに出力されるエラーメッセージ がどのようになるかを確認してください。

img

2.3.1 演習 (10分)

次のような「誤った書式のJSON文字列」をボディに与えてリクエストを送ったとき、VSCodeのターミナルにどのようなエラーメッセージが出力されるか確認してください。

{
  title: "投稿X",
  content: "投稿Xの本文。<br/>投稿Xの本文。投稿Xの本文。",
  coverImageURL: "https://....",
  categoryIds: [
    "d3e10601-3cfa-48f3-9ede-886647a1063b",
    "5a8d8365-e435-4ed4-a05e-34237c1b2ff0"
  ]
}
{
  "title": "投稿X",
  "content": "投稿Xの本文。<br/>投稿Xの本文。投稿Xの本文。",
  "coverImageURL": "https://....",
  "categoryIds": [
    "d3e10601-3cfa-48f3-9ede-886647a1063b",
    "5a8d8365-e435-4ed4-a05e-34237c1b2ff0",
  ]
}

EX: より高度でエレガントな実装✨

投稿記事とカテゴリを紐付ける「中間テーブルへのレコードの挿入」については、前回講義のDBの初期データの作成で示した seed.ts第23行目 から 第25行目 のように Prismaのリレーションを使用した一括作成機能 を利用することで、実行効率の良い実装ができます。

実装例はこちらを参照してください。

また、このように実装すると「カテゴリが存在することを事前確認せずに処理すること」が可能になります。投稿記事を作成する際に、存在しないカテゴリIDが与えられると、データベースの外部キー制約によってエラーが発生し、それを適切にハンドリングすることでステータスコード「400」を返すことができるようになります。

2.4 宿題⛄: 投稿記事 (単体) を取得するウェブAPIの実装

/api/posts/[id] というエンドポイントに、単一の投稿記事を取得するウェブAPI (GET Methodに対応) を実装してください。

URLパスで与える id に一致する投稿記事が存在するときは、ステータスコード「200 OK」で以下のような JSON をレスポンスするようにしてください。

{
  "id": "58169a35-8f4c-4f62-b50b-876f254ef3a3",
  "title": "投稿1",
  "content": "投稿1の本文。<br/>投稿1の本文。投稿1の本文。",
  "coverImageURL": "https://...",
  "createdAt": "2024-12-08T12:00:42.720Z",
  "updatedAt": "2024-12-08T12:00:42.720Z",
  "categories": [
    {
      "category": {
        "id": "ad28ee69-359f-4d66-aaea-1680c97347b8",
        "name": "カテゴリ1"
      }
    },
    {
      "category": {
        "id": "f6a99736-ad29-493b-8323-c3b10c6390c2",
        "name": "カテゴリ2"
      }
    }
  ]
}

また、id に一致する投稿記事が存在しなかったときは、ステータスコード「404 Not Found」で以下のような JSON をレスポンスするようにしてください。

{
  "error": "id='0120-00-9696'の投稿記事は見つかりませんでした"
}

どうしても実装できないときはこちらを参考にしてください。

2.5 宿題⛄: 投稿記事を削除するウェブAPIの実装

/api/admin/posts/[id] というエンドポイントに、「投稿記事」を削除するウェブAPI (DELETE Method に対応) を実装してください。実装にあたっては、前回講義のカテゴリ削除のAPIを参考にしてください。

中間テーブルのレコードの削除について

前回講義において 中間テーブル (PostCategory) は schema.prisma のなかで次のように定義しました。

// 投稿記事とカテゴリを紐づける中間テーブル
model PostCategory {
  id          String @id @default(uuid())
  postId      String
  categoryId  String
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  category    Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
  post        Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
}

この設定のうち、投稿記事の削除に関連して重要になってくるのが 第45行目onDelete: Cascade というオプションです。このオプションは もし参照先の「Postレコード」が削除された場合、それに関連する「PostCategoryレコード」も自動的に削除する というものになります。

たとえば、各テーブルに次のようなレコードがあるとき…

■ Post テーブル

id title content created_at
p-001 JavaScriptだと… TypeScriptに移行… 2024-03-19
p-002 リストの[-1]って… Pythonのリストで… 2024-03-24

■ Category テーブル

id name
c-001 プログラミング
c-002 TypeScript
c-003 JavaScript
c-004 Python

■ PostCategory テーブル (中間テーブル)

postId categoryId
p-001 c-001
p-001 c-002
p-001 c-003
p-002 c-001
p-002 c-004

Post テーブルから id = p-002 のレコードを削除すると、それに連動して PostCategory テーブルのなかの関連レコードも次のように自動削除されるというオプションになります。

■ PostCategory テーブル (Postテーブルから p-002 のレコードを削除した後 )

postId categoryId
p-001 c-001
p-001 c-002
p-001 c-003

中間テーブルのレコードには、以上のような onDelete: Cascade が設定されているため、「投稿記事の削除」に際して 中間テーブルのレコードに対する明示的な削除処理 は不要です。

なお、この連動削除は「単方向」のものなので、PostCategory のレコードを削除しても、PostCategory のレコードが削除されるようなことはありません

DELETE /api/admin/posts/[id] で、id に一致する投稿記事が「削除できたとき」は、ステータスコード「200 OK」で以下のような JSON をレスポンスするようにしてください。

{
  "msg": "「投稿X」を削除しました。"
}

また、id に一致する投稿記事が存在しないなどの理由で「削除できなかったとき」は、ステータスコード「500 Internal Server Error」で以下の JSON をレスポンスするようにしてください。

{
  "error": "投稿記事の削除に失敗しました"
}

どうしても実装できないときはこちらを参考にしてください。

2.6 宿題⛄: 投稿記事の内容を変更 (更新) するウェブAPIの実装

/api/admin/posts/[id] というエンドポイントに、投稿記事を変更 (更新) するウェブAPI (PUT Method に対応) を実装してください。このエンドポイントは 先ほどの「投稿記事の削除」と同じエンドポイント となります。DELETE 関数を記述したファイルのなかに PUT 関数を実装してください。

PUTリクエストのボディは、POSTメソッドと同じ形式のJSONデータを想定してください。

{
  "title": "投稿Z",
  "content": "投稿Zの本文。<br/>投稿Zの本文。投稿Zの本文。",
  "coverImageURL": "https://....",
  "categoryIds": [
    "d3e10601-3cfa-48f3-9ede-886647a1063b",
    "5a8d8365-e435-4ed4-a05e-34237c1b2ff0"
  ]
}

データベースに id に一致する投稿記事が存在し「内容を変更できたとき」は、ステータスコード「200 OK」で以下のようなJSONデータ (更新された内容) をレスポンスするようにしてください。カテゴリの情報は不要です。

{
  "id": "0d2f8c7e-08c4-4aa5-8ed7-a46d9a1ef3be",
  "title": "投稿Z",
  "content": "投稿Zの本文。<br/>投稿Zの本文。投稿Zの本文。",
  "coverImageURL": "https://....",
  "createdAt": "2024-12-10T08:51:42.829Z",
  "updatedAt": "2024-12-11T05:33:45.884Z"
}

内容を変更できなかったとき」は、ステータスコード「500 Internal Server Error」で以下のJSONデータをレスポンスするようにしてください。

{
  "error": "投稿記事の変更に失敗しました"
}

実装に際しては次のことに注意してください。

(プロンプト例)

RDBにおけるトランザクションの概念について教えてください。

Prisma でトランザクションを使う方法を解説してください。投稿記事とカテゴリが「多対多」の関係になっているブログデータで更新 (UPDATE) を実行するケースを題材に解説してください。

どうしても実装できないときはこちらを参考にしてください。

2.7 情報の整理: APIリストとサイトマップ

宿題を含めてここまでの実装を終えると、次のような 9件のウェブAPI が利用可能な状態になっているはずです。

エンドポイント HTTP
Method
処理 管理者
権限
リクエスト
ボディ
/api/categories GET カテゴリの一覧を取得する 第08回講義
/api/admin/categories POST カテゴリを追加する 必要 必要 第08回講義
/api/admin/categories/[id] PUT カテゴリの名前を変更する 必要 必要 第08回講義
/api/admin/categories/[id] DELETE カテゴリを削除する 必要 第08回講義
/api/posts GET 投稿記事の一覧を取得する 第09回講義
/api/posts/[id] GET 投稿記事 (単体) を取得する 宿題⛄ (課題2)
/api/admin/posts POST 投稿記事を追加する 必要 必要 第09回講義
/api/admin/posts/[id] PUT 投稿記事の内容を変更 (編集) する 必要 必要 宿題⛄ (課題2)
/api/admin/posts/[id] DELETE 投稿記事を削除する 必要 宿題⛄ (課題2)

2.8 ふたたびフロンドエンド開発に…

ウェブAPIを利用する形で、次のようなフロントエンド開発 (Reactによるウェブ画面の設計・構築) を進めていきます。なお、宿題⛄ の部分がまだ完了していなくても着手することが可能です。

URL 処理 管理者権限
/ 投稿記事の一覧表示 (実装済み・但し要修正)
/about 制作者プロフィール
このサイトについて (実装済み)
/posts/[id] 投稿記事の詳細表示 (実装済み・但し要修正)
/admin/posts 投稿記事の一覧表示
編集ページへのリンク、削除機能 (オプション)
必要
/admin/posts/new 投稿記事の新規作成 必要
/admin/posts/[id] 投稿記事の編集 (削除を含む) 必要
/admin/categories カテゴリの一覧表示
編集ページへのリンク、削除機能 (オプション)
必要
/admin/categories/new カテゴリの新規作成 必要
/admin/categories/[id] カテゴリの編集 (削除を含む) 必要

3 カテゴリの新規作成フォームの実装

フロントエンド開発に戻ります。/admin/categories/newカテゴリを新規作成 (追加) するためのフォーム を配置したページを実装していきます。

img

この画面では、実装済みの API を以下のように利用します。

  1. 既存のカテゴリ (一覧) の取得GET /api/categories を使用
  2. カテゴリの新規作成 (追加)POST /api/admin/categories を使用

3.1 準備

ここでは、以下のウェブAPIが実装されていることを前提とします (いずれのAPIも前回授業のなかで実装済みです)。

エンドポイント HTTP Method 処理
/api/categories GET カテゴリの一覧を取得する
/api/admin/categories POST カテゴリを追加する

念のために /api/categories のレスポンスが、次のような形式のJSONになっていることを ThunderClinet から確認しておいてください。もし、形式が違っていれば、前回の講義資料を参照して修正しておいてください。

[
  {
    "id": "db450dc7-a73b-4311-8b70-3af652efd144",
    "name": "カテゴリ4",
    "createdAt": "2024-12-08T12:00:42.713Z",
    "updatedAt": "2024-12-08T12:00:42.713Z"
  },
  // ...略...
  {
    "id": "ad28ee69-359f-4d66-aaea-1680c97347b8",
    "name": "カテゴリ1",
    "createdAt": "2024-12-08T12:00:42.695Z",
    "updatedAt": "2024-12-08T12:00:42.695Z"
  }
]

また、カテゴリを新規作成 (追加) するための API (/api/categories) は、次のようなリクエストボディを受付けることを確認しておいてください。問題があれば、前回の講義資料を参照して修正してください。

{
  "name": "カテゴリ5"
}

ThunderClinet を使用したPOSTリクエストの確認方法は次のとおりです (再掲)。なお、既に同じ名前のカテゴリが DB に存在する場合は失敗するので注意してください (失敗するように設計しているので注意してください)。

img

以下、ステップバイステップで段階的にカテゴリの追加フォームを実装していきます。

3.2 カテゴリの追加フォームの実装 (第1段階)

プロジェクトフォルダに src/app/admin/categories/new/page.tsx を作成してこちらのコードを貼付け、開発モードでアプリを起動し、/admin/categories/new にアクセスして動作確認してください。

img
  1. ページ (=Reactコンポーネント) の実装となるので、ファイル名を route.ts ではなく page.tsx とすることに注意してください。
  2. /admin/categories/new にアクセスすると、内部的に GET /api/admin/categories がコールされ、カテゴリの一覧が画面に表示されることを確認してください。
    • 各カテゴリをクリックすると /admin/categories/[id] に遷移することを確認してください (遷移先のページは未実装なので 404 となります)。
  3. テキストボックスに「名前」を入力してボタンを押下すると、開発ツール (F12で起動) のコンソールに POST /api/admin/categories => {"name":"カテゴリ5"} のようなログが出力されること確認してください。
    • ただし、現状の実装では 実際の「POST処理」は実行されていないこと に注意してください。
  4. 新しいカテゴリの名前についての バリデーション (値の検証) が実装されていないことを確認してください。
    • テキストボックスが空欄でも「カテゴリを作成」のボタンが押下できてしまいます。
    • 既存のカテゴリと同じ名前でも「カテゴリを作成」のボタンが押下できてしまいます。

以上のように、このコードには「POST処理」と「バリデーション処理」が実装されていません。そのことを踏まえて、まずはじっくりとコードの読解に努めてください。特にしっかりと理解して欲しい部分は以下のようになります。

(プロンプト例)

Next.js (React) を使用してブログアプリ開発をしています。そこで、<input type="text"><button> などの要素を使って、記事の投稿フォームを構成しています。これらの要素を <form> の子要素にすることの意味について教えてください。<button>onClick 属性を設定すれば送信処理を実装できるのに、わざわざ <form> を使うことに理由があるのでしょうか

ある程度の理解ができたら、次のセクションに進んでください。

3.3 カテゴリの追加フォームの実装 (第2段階)

バリデーション (値の検証) を実装したコードをこちらに示します。開発モードで実行して動作を確認してください。文字数が規定範囲にない場合既存のカテゴリと同じ名前を設定した場合 にバリデーションエラーとなります。

img

バリデーションをパスしない状態では ボタンが押下できないこと を確認してください (ボタンが薄色表示になり、マウスカーソルをあわせるとマークになる)。 テキストボックスやボタンのバリデーションについては第05回講義(Todoアプリ開発) のほぼ復習となります。

ある程度の理解ができたら、次のセクションに進んでください。

3.4 カテゴリの追加フォームの実装 (第3段階)

/api/admin/categories に対する POST処理 を追加実装したコードをこちらに示します。開発モードで実行して動作を確認してください。

img

また、Prisma Studio を使って、実際に DB の カテゴリテーブルにレコードが追加されていること を確認してください。

img

第90行目から第133行目が主な追加実装となります。特に次の処理については、じっくりと読み解い理解に努めてください。fetch 関数の第2引数が、GET の場合とは、どのように違うかに注意してください。

// ▼▼ 追加 ウェブAPI (/api/admin/categories) にPOSTリクエストを送信する処理
try {
  const requestUrl = "/api/admin/categories";
  const res = await fetch(requestUrl, {
    method: "POST",
    cache: "no-store",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name: newCategoryName }),
  });

  if (!res.ok) {
    throw new Error(`${res.status}: ${res.statusText}`); // -> catch節に移動
  }

  setNewCategoryName("");
  await fetchCategories(); // カテゴリの一覧を再取得
} catch (error) {
  const errorMsg =
    error instanceof Error
      ? `カテゴリのPOSTリクエストに失敗しました\n${error.message}`
      : `予期せぬエラーが発生しました\n${error}`;
  console.error(errorMsg);
  window.alert(errorMsg);
} finally {
  setIsSubmitting(false);
}

4 カテゴリの変更・削除フォームの実装

/admin/categories/[id] でアクセス可能な カテゴリの編集・削除フォームsrc/app/admin/categories/[id]/page.tsx に実装していきます。この画面では、既存のカテゴリの「名前の変更」と「削除」の操作ができるようにフォームを構築していきます。

img

不正な id が指定されたときの画面の例です。

img

ここでは、以下のウェブAPIが実装されていることを前提とします。

エンドポイント HTTP Method 処理
/api/categories GET カテゴリの一覧を取得する
/api/admin/categories/[id] PUT カテゴリの名前を変更する
/api/admin/categories/[id] DELETE カテゴリを削除する

実装例をこちらに示します。まずは、コード例を参照せずに自分で実装にチャレンジしてみてください。そのプロセスのなかで 「どうやって実装すれば良いのか分からない」 ということが明確になるので (頭のなかで整理されるので)、それから実装例を見たほうが圧倒的に知識定着率が高くなります。

5 投稿記事の新規作成フォームの実装

/admin/posts/new に、次のような 投稿記事の新規作成フォーム を実装していきます。基本的には、カテゴリの追加フォームの実装の拡張版になります。

img

実装例をこちらに示します。まずは、コード例を参照せずに自分で実装にチャレンジしてみてください。

6 冬休みの宿題

これまでと比較して格段に「高難易度の課題」です。その分だけ、実装できたときには大きく成長していることは間違いないので頑張って取り組んでください。

まず、次のAPIを全て実装してください。印のAPIは ここまでの講義で実装済み です。実質3件が新規実装が必要なAPI になります。なお、レスポンスのボディについては、こちらのように自由に仕様変更しても問題ありません。ただし データベースのスキーマ (schema.prisma ) については変更しないでください。

エンドポイント HTTP
Method
処理 リクエスト
ボディ
/api/categories GET カテゴリの一覧を取得する
/api/admin/categories POST カテゴリを追加する 必要
/api/admin/categories/[id] PUT カテゴリの名前を変更する 必要
/api/admin/categories/[id] DELETE カテゴリを削除する
/api/posts GET 投稿記事の一覧を取得する
/api/posts/[id] GET 投稿記事 (単体) を取得する
/api/admin/posts POST 投稿記事を追加する 必要
/api/admin/posts/[id] PUT 投稿記事の内容を変更 (編集) する 必要
/api/admin/posts/[id] DELETE 投稿記事を削除する

つづいて上記のAPIを利用する形で、次のウェブページ (Reactコンポーネント) を全て実装してください。ページのデザインやレイアウトに制約・制限はありません (自由にデザインしてください)。

印は既に実装済みのページ、印は microCMS API からの切り替えが必要のページ になります (microCMSのAPINext.jsで自作したAPI ではレスポンスのボディのJSON形式が少し違っているので注意してください)。

URL 処理 管理者権限
/ 投稿記事の一覧表示
/posts/[id] 投稿記事の詳細表示
/about 制作者プロフィール
このサイトについて
/admin/posts 投稿記事の一覧表示
編集ページへのリンク、削除機能 (オプション)
必要
/admin/posts/new 投稿記事の新規作成 必要
/admin/posts/[id] 投稿記事の編集 (削除を含む) 必要
/admin/categories カテゴリの一覧表示
編集ページへのリンク、削除機能 (オプション)
必要
/admin/categories/new カテゴリの新規作成 必要
/admin/categories/[id] カテゴリの編集 (削除を含む) 必要

以上について、次回授業日 1月9日 (木) を目標に実装にチャレンジしてください (生成AIをフル活用してください)。

デザインや機能によっては、これまでの授業で習っていない (紹介されていない) ライブラリやテクニックが必要になります。そこは、生成AIのサポートを受けて乗り越えてください。

1月15日 (水) を期限に 課題2 として、上記の各ページの「スクリーンショット」と「各コードへのリンク」を提出してもらうことを予定しています (冬休みの宿題⛄が完了していれば30分程度で完了する内容です)。

課題2の提出方法の詳細は、次回の授業 1月9日 (木) のなかで案内します。

どうしても実装できないときは…

こちらのリポジトリ (feature/sqliteブランチ)と、以下のデザイン例 (スクリーンショット) を参考にしてください。

さらなるスキル向上を目指すなら…

生成AIからコードレビューを受けてみましょう

生成AIを利用してコードレビューを受けてみましょう。要求される実行結果が得られるプログラムが書けても「果たしてベストな書き方になっているのか」「実際の現場で通用するレベルのコードになっているのか」という疑問や不安が残っていると思います。そのような場合、次のようなプロンプトを利用して、コードレビューを受けてみてください。

私は就職およびインターンシップの応募に使うポートフォリオとして、Next.js 14 で「ブログアプリ」を開発している学生です。以下に示す私のプログラム (=XXXXするページの実装) について、現場のエンジニアの視点で「コードレビュー」と「評価」をしてください。特に、以下のことを重点的にレビューしてください。
1. モダンな Next.js の書き方やベストプラクティスに則しているか
2. パフォーマンスや保守性・可読性の観点で改善点はないか
3. セキュリティ上の懸念点はないか
4. 命名や構造が適切で理解しやすいか