1 連絡・概要
1.1 連絡
- 年内最後の講義になります。次回は、1月8日 (木) となります。
- スペシャルコンテンツとして OB講演 (=現役のフリーランスエンジニアとして活躍している本校OB) があります。
- Unity 1-Week GAME JAM (再掲)
- 次回の開催 12月23日(月) 0時 〜 12月29日(日) 20時 … お題「????」
1.2 ここまでの授業の流れ
以下の内容は、学修単位科目の「授業時間外学習」として取組みをしておいてください。最後に冬休みの宿題🥶 (=課題2 の準備に相当) があります。
前回講義では「ブログアプリ」のバックエンド開発として、「カテゴリ」の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 "@/generated/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 },
);
}
};httpYac を使って /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では直接的に与えられる形式に変更していることです。そのため、記事一覧や個別記事表示を処理するフロントエンド側でも、この変更にあわせてコードの変更が必要になります。
これ以外にも、自作バックエンドにあわせて各種変更が必要になります (例えば、fetch の引数に
"X-MICROCMS-API-KEY": apiKey
が不要になるなど)。それらは、適宜、変更修正してください。
ところで、このレスポンスを利用して以下のような画面 (=投稿記事の一覧表示の画面 /
トップページ) を構成しようとすると 「カテゴリ」に関する情報が不足していること に気づくでしょうか。また
coverImageURL と updatedAt
は冗長な情報になることに気づくでしょうか。
RDB には情報が「正規化」されて格納されているため、投稿記事テーブル
(Post) だけを参照しても 記事に紐づく「カテゴリ」の情報 を得ることは
で・き・ま・せ・ん。
このようなとき、RDB では (中間テーブルを介した使った) テーブルの結合操作
というものが必要となってきます。SQL文では JOIN
というキーワードでテーブルの結合処理をします。
(プロンプト例)
リレーショナルデータベースにおいてテーブルの結合 (JOIN) は、どのようなときに必要となりますか。投稿記事とカテゴリが「多対多」の関係になっているブログデータを扱う場合を例に考えてみてください。
しかし、このブログアプリ開発では、ORM である Prisma を利用しているため、(JOIN
を使った SQL文 を記述することなく) 比較的簡単に
投稿記事に紐づくカテゴリ情報の取得が可能 となります🙌。具体的には
route.ts の 第07行目 findMany
メソッドの第1引数に「selectオプション」を追加することによって可能になります。
実際に、次のように findMany
の第1引数を変更してみてください。そして、投稿記事の情報 (coverImageURL と
updatedAt を除いたもの) に加えて、それに紐づけられたカテゴリの「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
に対して明示的に型を指定したいときはこちらのようにしてください。ただし、これは中級以上の内容になってくるので、無理に利用する必要はありません。
httpYac を使って、/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図の対応関係が (なんとなく)
分かるでしょうか🤔
ER図のなかで 赤色で prisma と示したカラム、具体的には、投稿記事テーブルの
categories カラム、中間テーブルの category と post
カラム、カテゴリテーブルの 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
);
id や title などのカラムは存在しますが、categories
というカラムは 存・在・し・な・い・こ・と が確認できます。このように 実際の RDB
のテーブルに存在しないカラムの値 は、select オプション (あるいは
include オプション) を指定して取得する必要があります。
なお、schema.prisma のフィールドのうち、RDB にカラム (列) が作成されないのは
categories PostCategory[] や posts PostCategory[]
のように「配列型」になっているものと、@relation(...)
の属性がついたものとなります。つまり、schema.prisma
の 25、34、44、45行目 についてはカラムが作成されません。
2.2.2 補足: selectプロパティに関する解説 2
prisma.post.findMany を使って、「Categoryテーブル」のなかの id と
name の情報同時に取得するためには、以下の ➊ → ➋ → ➌
のように段階的にアプローチする必要があります。
この の ➊ → ➋ → ➌ のアプローチが、さきほどの 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の引数に設定可能なselect、inclue、_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"
]
}投稿記事の追加では、次のような点を考慮する必要があるため、前回講義のカテゴリの追加よりも複雑な処理となります。
- リクエストボディのバリエーション (データ検証) の処理
- categoryIdsの要素に一致するIDを持ったカテゴリが存在しない場合の処理
- 中間テーブルにレコードを追加する処理
まず、「1番目」のバリエーションですが、これはカテゴリの追加でも本来は必要だった処理になります。具体的には、リクエストボディについて「必要なプロパティを持っているか」「プロパティが期待する型であるか」「文字数制限
(例えば2文字以上32文字以内) が守られているか」「URLとして適切な形式になっているか」といったチェックをする処理になります。この処理は
紙面の都合で省略します (余裕がある学生はバリエーションを実装し、問題があれば
400 Bad Request をレスポンスするようにしてください)。
次に「2番目」ですが、これは「実在するカテゴリだけを投稿記事に紐づける」または「実在しないカテゴリがあれば、エラーとしてリクエストを拒否する」 という対応が考えられますが、ここではリクエストを拒否する方針で設計します。
「3番目」は必須の処理となります。
以上を踏まえて投稿記事の追加するAPIは、以下のように実装することができます。
import { prisma } from "@/lib/prisma";
import { NextResponse, NextRequest } from "next/server";
import type { Post } from "@/generated/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)
に、投稿記事とカテゴリを紐づけるためのレコードを挿入しています。
各処理の概要が理解できたら、httpYac と Prisma Studio
を利用してエンドポイント /api/admin/posts
の動作確認を行なってください。この際、あえて不適切なボディ
(例えば、存在しないカテゴリIDを含むデータなど) を与え、そのレスポンスや VSCodeのターミナルに出力されるエラーメッセージ
がどのようになるかを確認してください。
### 記事の投稿
POST http://localhost:3000/api/admin/posts
Content-Type: application/json
{
"title": "投稿X",
"content": "投稿Xの本文。<br/>投稿Xの本文。投稿Xの本文。",
"coverImageURL": "https://....",
"categoryIds": [
"d3e10601-3cfa-48f3-9ede-886647a1063b",
"5a8d8365-e435-4ed4-a05e-34237c1b2ff0"
]
}- categoryIds のUUID (第09行目と第10行目) は、各自の DB にあわせて変更してください。
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 の 第21行目 から 第23行目 のように
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 をレスポンスするようにしてください。
- URLパスのパラメータプレースホルダ
[id]の処理については、前回講義のこちらを参考にしてください。 - 単一のレコードの取得には
findUniqueメソッドを使用してください。- 条件に一致するレコードが見つからなかったとき、
findUniqueの戻り値はnullになります (厳密にはawaitした Promise の結果としてnullが返されます)。
- 条件に一致するレコードが見つからなかったとき、
どうしても実装できないときはこちらを参考にしてください。
2.5 宿題⛄: 投稿記事を削除するウェブAPIの実装
/api/admin/posts/[id]
というエンドポイントに、「投稿記事」を削除するウェブAPI (DELETE
Method に対応) を実装してください。実装にあたっては、前回講義のカテゴリ削除のAPIを参考にしてください。
- エンドポイントが
/api/posts/[id]ではなく/api/admin/posts/[id]であることに注意してください。
中間テーブルのレコードの削除について
前回講義において 中間テーブル (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
のレコードを削除しても、Post や Category
のレコードが削除されるようなことはありません。
DELETE /api/admin/posts/[id] で、id
に一致する投稿記事が「削除できたとき」は、ステータスコード「200
OK」で以下のような JSON をレスポンスするようにしてください。
また、id に一致する投稿記事が存在しないなどの理由で「削除できなかったとき」は、ステータスコード「500 Internal Server Error」で以下の JSON をレスポンスするようにしてください。
どうしても実装できないときはこちらを参考にしてください。
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データをレスポンスするようにしてください。
実装に際しては次のことに注意してください。
- 投稿記事とカテゴリを紐付けする情報の更新は、まず 中間テーブルから現在の紐づけ情報を削除 してから、中間テーブルに新しい紐付け情報を挿入するという 2段階 で行なってください。
- リクエストボディの
"categoryIds"に不正な値が含まれているときに「タイトル、本文、カバーイメージURLは更新されたが、カテゴリ設定については失敗した」といった 中途半端な状態 (データの整合性が保たれない状態) が生じないように実装 してください。prisma.post.updateの実行よりも先に"categoryIds"の検証をするようにしてください。- もしくは トランザクション を利用して処理してください (トランザクションの利用は必須ではありません)。
参考
httpYac のコード例です。@post_id、categoryIds は、各自の DB
にあわせて適切に設定してください。
### 投稿記事の更新
@post_id = d1e9ff60-2f17-419a-bacd-ee54cd741484
PUT http://localhost:3000/api/admin/posts/{{post_id}}
Content-Type: application/json
{
"title": "投稿Z",
"content": "投稿Zの本文。<br/>投稿Zの本文。投稿Zの本文。",
"coverImageURL": "https://....",
"categoryIds": [
"51f8302e-6296-4f1b-bc1c-c65fedc1850b",
"cc8cd654-7f15-4bea-8ab5-38e94a2ed8ea"
]
}(プロンプト例)
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 に
カテゴリを新規作成 (追加) するためのフォーム
を配置したページを実装していきます。
この画面では、実装済みの API を以下のように利用します。
- 既存のカテゴリ (一覧) の取得に GET /api/categories を使用
- カテゴリの新規作成 (追加) に POST /api/admin/categories を使用
3.1 準備
ここでは、以下のウェブAPIが実装されていることを前提とします (いずれのAPIも前回授業のなかで実装済みです)。
| エンドポイント | HTTP Method | 処理 |
|---|---|---|
| /api/categories | GET | カテゴリの一覧を取得する |
| /api/admin/categories | POST | カテゴリを追加する |
念のために /api/categories
のレスポンスが、次のような形式のJSONになっていることを httpYac
から確認しておいてください。もし、形式が違っていれば、前回の講義資料を参照して修正しておいてください。
[
{
"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/admin/categories)
は、次のようなリクエストボディを受付けることを確認しておいてください。問題があれば、前回の講義資料を参照して修正してください。
httpTac を使用したPOSTリクエストの確認方法は次のとおりです (再掲)。なお、既に同じ名前のカテゴリが DB に存在する場合は失敗するので注意 してください (失敗するように設計しているので注意してください)。
### カテゴリ一覧の取得
GET http://localhost:3000/api/categories
### カテゴリの作成
POST http://localhost:3000/api/admin/categories
Content-Type: application/json
{
"name": "カテゴリ5"
}以下、ステップバイステップで段階的にカテゴリの追加フォームを実装していきます。
3.2 カテゴリの追加フォームの実装 (第1段階)
プロジェクトフォルダに src/app/admin/categories/new/page.tsx を作成してこちらのコードを貼付け、開発モードでアプリを起動し、/admin/categories/new
にアクセスして動作確認してください。
- ページ (=Reactコンポーネント) の実装となるので、ファイル名を
route.tsではなくpage.tsxとすることに注意してください。 /admin/categories/newにアクセスすると、内部的にGET /api/admin/categoriesがコールされ、カテゴリの一覧が画面に表示されることを確認してください。- 各カテゴリをクリックすると
/admin/categories/[id]に遷移することを確認してください (遷移先のページは未実装なので 404 となります)。
- 各カテゴリをクリックすると
- テキストボックスに「名前」を入力してボタンを押下すると、開発ツール
(
F12で起動) のコンソールにPOST /api/admin/categories => {"name":"カテゴリ5"}のようなログが出力されること確認してください。- ただし、現状の実装では 実際の「POST処理」は実行されていないこと に注意してください。
- 新しいカテゴリの名前についての バリデーション (値の検証)
が実装されていないことを確認してください。
- テキストボックスが空欄でも「カテゴリを作成」のボタンが押下できてしまいます。
- 既存のカテゴリと同じ名前でも「カテゴリを作成」のボタンが押下できてしまいます。
以上のように、このコードには「POST処理」と「バリデーション処理」が実装されていません。そのことを踏まえて、まずはじっくりとコードの読解に努めてください。特にしっかりと理解して欲しい部分は以下のようになります。
- 第28行目から第70行目の「カテゴリ一覧の取得する処理」
- 第07回講義(microCMSからのデータフェッチ) の復習となります。
- 第77行目から第89行目の「ボタンを押下したときの処理」
- 第123行目から第154行目の「
<form>要素を使った入力フォームの構成」
(プロンプト例)
Next.js (React) を使用してブログアプリ開発をしています。そこで、
<input type="text">や<button>などの要素を使って、記事の投稿フォームを構成しています。これらの要素を<form>の子要素にすることの意味について教えてください。<button>にonClick属性を設定すれば送信処理を実装できるのに、わざわざ<form>を使うことに理由があるのでしょうか
ある程度の理解ができたら、次のセクションに進んでください。
3.3 カテゴリの追加フォームの実装 (第2段階)
バリデーション (値の検証) を実装したコードをこちらに示します。開発モードで実行して動作を確認してください。文字数が規定範囲にない場合、既存のカテゴリと同じ名前を設定した場合 にバリデーションエラーとなります。
バリデーションをパスしない状態では ボタンが押下できないこと を確認してください (ボタンが薄色表示になり、マウスカーソルをあわせるとマークになる)。 テキストボックスやボタンのバリデーションについては第05回講義(Todoアプリ開発) のほぼ復習となります。
ある程度の理解ができたら、次のセクションに進んでください。
3.4 カテゴリの追加フォームの実装 (第3段階)
/api/admin/categories に対する POST処理
を追加実装したコードをこちらに示します。開発モードで実行して動作を確認してください。
また、Prisma Studio を使って、実際に DB の カテゴリテーブルにレコードが追加されていること を確認してください。
第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
に実装していきます。この画面では、既存のカテゴリの「名前の変更」と「削除」の操作ができるようにフォームを構築していきます。
不正な id が指定されたときの画面の例です。
ここでは、以下のウェブAPIが実装されていることを前提とします。
| エンドポイント | HTTP Method | 処理 |
|---|---|---|
| /api/categories | GET | カテゴリの一覧を取得する |
| /api/admin/categories/[id] | PUT | カテゴリの名前を変更する |
| /api/admin/categories/[id] | DELETE | カテゴリを削除する |
実装例をこちらに示します。まずは、コード例を参照せずに自分で実装にチャレンジしてみてください。そのプロセスのなかで 「どうやって実装すれば良いのか分からない」 ということが明確になるので (頭のなかで整理されるので)、それから実装例を見たほうが圧倒的に知識定着率が高くなります。
5 投稿記事の新規作成フォームの実装
/admin/posts/new に、次のような 投稿記事の新規作成フォーム
を実装していきます。基本的には、カテゴリの追加フォームの実装の拡張版になります。
- 画像をアップロードする機能については、次回以降の講義で解説します。現段階では「画像のURL」を与えるように実装してください。なお、ここで指定したURLの画像を、投稿記事の詳細画面
(
/posts/[id]) で表示するためには 「next.config.ts」を適切に設定 する必要があります。詳しくは第07回講義の解説を参照してください。
実装例をこちらに示します。まずは、コード例を参照せずに自分で実装にチャレンジしてみてください。
6 冬休みの宿題
これまでと比較して格段に「高難易度の課題」です。その分だけ、実装できたときには大きく成長していることは間違いないので頑張って取り組んでください。
まず、次のAPIを全て実装してください。★印のAPIは ここまでの講義で実装済み です。実質3件が新規実装が必要なAPI になります。なお、レスポンスのボディについては、こちらのように自由に仕様変更しても問題ありません。ただし データベースのスキーマ (schema.prisma ) については変更しないでください。
| APIエンドポイント | 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のAPI と Next.jsで自作したAPI ではレスポンスのボディのJSON形式が少し違っているので注意してください)。
| Page URL | 処理 | 管理者 権限 |
|
|---|---|---|---|
| / | 投稿記事の一覧表示 | ☆ | |
| /posts/[id] | 投稿記事の詳細表示 | ☆ | |
| /about | 制作者プロフィール このサイトについて |
★ | |
| /admin/posts | 投稿記事の一覧表示 編集ページへのリンク、削除機能 (オプション) |
必要 | |
| /admin/posts/new | 投稿記事の新規作成 | 必要 | ★ |
| /admin/posts/[id] | 投稿記事の編集 (削除を含む) | 必要 | |
| /admin/categories | カテゴリの一覧表示 編集ページへのリンク、削除機能 (オプション) |
必要 | |
| /admin/categories/new | カテゴリの新規作成 | 必要 | ★ |
| /admin/categories/[id] | カテゴリの編集 (削除を含む) | 必要 | ★ |
以上について、次回授業日 1月8日 (木) を目標に実装にチャレンジしてください (生成AIをフル活用してください)。
デザインや機能によっては、これまでの授業で習っていない (紹介されていない) ライブラリやテクニックが必要になります。そこは、生成AIのサポートを受けて乗り越えてください。
1月14日 (水) を期限に 課題2 として、上記の各ページの「スクリーンショット」と「各コードへのリンク」を提出してもらうことを予定しています (冬休みの宿題⛄が完了していれば30分程度で完了する内容です)。
課題2の提出方法の詳細は、次回の授業 1月8日 (木) のなかで案内します。
どうしても実装できないときは…
こちらのリポジトリ (feature/sqliteブランチ)と、以下のデザイン例 (スクリーンショット) を参考にしてください。
さらなるスキル向上を目指すなら…
- 同じ型定義が複数箇所で必要になったときは
_typesフォルダのなかに型定義専用のファイルを作成し、そこから型をインポートして使うようにしてみましょう。これにより、型の一貫性が保たれ、変更管理も容易になります (つまり「保守性」と「再利用性」が向上します)。 - 複数のページで共通して使用するUIコンポーネント (例えばボタンやカテゴリタグなど) は
_componentsフォルダに切り出して、再利用可能なモジュールとして管理するようにしてみましょう。 - 現在、記事の投稿フォームは
onChangeとuseStateで値の管理やバリデーション (文字数のチェックなど) をしています。これは基本形として大事ですが、多く現場では react-hook-form と zod というライブラリが使用されています。定番のライブラリとなるので、ぜひ試してみてください。 fetch関数によるデータ取得 (HTTP GET) がマスターできたら、次はuseSWRという Hook の利用にチャレンジしてみてください。「キャッシュ管理」や「自動再取得」などの便利な機能 (UXを向上につながる機能) が揃っており、実務でもよく利用されているライブラリになります。
生成AIからコードレビューを受けてみましょう
生成AIを利用してコードレビューを受けてみましょう。要求される実行結果が得られるプログラムが書けても「果たしてベストな書き方になっているのか」「実際の現場で通用するレベルのコードになっているのか」という疑問や不安が残っていると思います。そのような場合、次のようなプロンプトを利用して、コードレビューを受けてみてください。
私は就職およびインターンシップの応募に使うポートフォリオとして、Next.js 15 で「ブログアプリ」を開発している学生です。以下に示す私のプログラム (=XXXXするページの実装) について、現場のエンジニアの視点で「コードレビュー」と「評価」をしてください。特に、以下のことを重点的にレビューしてください。
1. モダンな Next.js の書き方やベストプラクティスに則しているか
2. パフォーマンスや保守性・可読性の観点で改善点はないか
3. セキュリティ上の懸念点はないか
4. 命名や構造が適切で理解しやすいか