先週のZenStackはドキュメントは最悪だが良いライブラリーかもしれないに書いたようにZenStackは素晴らしいライブラリーなので、もう少し学んでみました。
前回のアプリではZenStackの重要な機能であるZmodelは、ほとんど使っていませんでした。そこで今回はGet Started With Next.jsの例題に似たアプリを作ってみることにしました。
Get Started With Next.js
Get Started With Next.jsで作るアプリはブログサイトでしたが、ここではログインして使う掲示板(BBS)にしてみました。
作成手順は先週とほぼ同じです。違いはuse Tailwind CSSをYesにしました。
フォルダー構造
今回のアプリのフォルダー構造は以下のようになっています、ファイルには以下のようなマークを付けました。
- ◎ ほぼ新規作成
- ○ Get Started With Next.jsのコードを利用
- △ 修正
- □ ZenSatckが自動生成
├── prisma
│ └── ...
├── public
│ └── ...
├── schema.zmodel ← ◎
├── src
│ ├── lib
│ │ └── hooks
│ │ ├── index.ts ← □
│ │ ├── post.ts ← □
│ │ └── user.ts ← □
│ ├── pages
│ │ ├── _app.tsx ← △
│ │ ├── _document.tsx ← ○
│ │ ├── api
│ │ │ ├── auth
│ │ │ │ └── [...nextauth].ts ← ◎
│ │ │ └── model
│ │ │ └── [...path].ts ← ◎
│ │ ├── index.tsx ← ○
│ │ ├── signin.tsx ← ○
│ │ └── signup.tsx ← ○
│ ├── server
│ │ ├── authOptions.ts ← ◎
│ │ └── db.ts ← ◎
│ └── styles
│ └── globals.css ← ○
└── ...
schema.zmodel
ZenStackの中核Zmodelの設定ファイルで、Prisma ORMの設定ファイルの拡張です。 Prismaの機能に形式チェック(Validation)、権限管理などが追加されています。
このファイルを書き換えた場合は npx zenstack generate
を実行する必要があります。さらにRDBの定義が変わった場合は npx prisma db push
を実行する必要があります。
datasource db {
provider = 'sqlite'
url = 'file:./dev.db'
}
generator client {
provider = "prisma-client-js"
}
plugin hooks {
provider = '@zenstackhq/swr'
output = "./src/lib/hooks"
}
model User { // ← ①
id Int @id @default(autoincrement()) // ← ②
email String @unique @email // ← ③
name String
password String @password @omit @length(4, 16) // ← ④
posts Post[] // ← ⑤
createdAt DateTime @default(now())
@@allow('create', true) // ← ⑥
@@allow('read', auth() != null) // ← ⑦
@@allow('all', auth() == this) // ← ⑧
}
model Post { // ← ⑨
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
text String @length(1, 16)
user User @relation(fields: [userId], references: [id]) // ← ⑩
userId Int
@@allow('read', auth() != null) // ← ⑪
@@allow('all', auth() == user) // ← ⑫
}
- ① ユーザー・モデル(テーブル)、主にログイン認証に使います
- ② idカラムの定義、@id, @defaultはPrismaの持つ属性です
- Get Started With Next.jsではidは文字型でしたが、ここでは整数にしました
- ③ emailカラムの定義、 @uniqueはPrisma、@emailはZmodelの属性でemail形式かチェック(Validation)されます
- ④ passwordカラムの定義
- @passworはZmodelの属性でパスワード形式かチェック
- @omitはデータ呼び出し時には、この値が取り出されない事を指定しています(素晴らしい!)
- @lengthは文字数チェックの設定
- ⑤ Prismaの機能で、userが複数のpostを持つことを指定しています
- ⑥ 権限管理、作成はログインしてなくても可能
- ⑦ 権限管理、読み出しは、ログイン済みであれば他ユーザーの情報も読み出せます
- ⑧ 権限管理、ログイン済みであれば自分のデータは全操作(変更、削除・・・)ができます
- ⑨ 投稿・モデル(テーブル)、投稿内容
- ⑩ postは
post.userId
の値を使い、user.id
のレコードと関連を持ちます - ⑪ 権限管理、読み出しは、ログイン済みであれば他ユーザーの情報も読み出せます
- ⑫ 権限管理、ログイン済みであれば自分のデータは全操作(変更、削除・・・)ができます
src/server/db.ts
Get Started With Next.jsの先頭の方に、You can find the final build result here というリンクがあり、リンク先のGitHubにGet Started With Next.js で説明するアプリの全コードがあります。ただしGet Started With Next.jsの説明より高機能なものになっています。😅
データベースとの接続部分のコードはGitHubにありました。
- ① PrismaClientのインスタンスを保持しています
- ② GitHubにはありませんでしたが、PrismaのSQLログをコンソールに表示するようにしています
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma = global.prisma || new PrismaClient({ // ← ①
log: ['query', 'info', 'warn', 'error'] // ← ②
});
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
}
src/server/authOptions.ts
このアプリでは認証部分には NextAuth.js を使っています。
このコードはGet Started With Next.jsでは auth.ts
になっていましたが、GitHubでは本体はsrc/pages/api/auth/[...nextauth].ts
に書かれていて、一部関数がsrc/server/common/get-server-auth-session.ts
に書かれていました。
いろいろと考えた結果、私はNextAuth.jsの設定コードのみ、このsrc/server/authOptions.ts
に書き、src/pages/api/auth/[...nextauth].ts
にはリクエストハンドラーのみ置きました。
NextAuth.jsに付いては詳しくないので、コードの説明は省きますが。TypeScriptのエラーが発生するので、以下の型定義を追加しました。
- ① 認証関数authorizeの戻り値等で使われる型
- ② セッションに格納されるデータの定義
- ③ NextAuth.jsの設定
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import type { PrismaClient } from '@prisma/client';
import { compare } from 'bcryptjs';
import NextAuth, { type DefaultSession, type NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prisma } from './db';
declare module 'next-auth' {
interface User { // ← ①
id: number;
name: string;
}
interface Session { // ← ②
user: {
id: number;
name: string;
}
}
}
export const authOptions: NextAuthOptions = { // ← ③
session: {
strategy: 'jwt',
},
callbacks: {
session({ session, token }) {
if (session.user) {
session.user.id = Number(token.sub!);
}
return session;
},
},
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
credentials: {
email: { type: 'email' },
password: { type: 'password' },
},
authorize: authorize(prisma),
}),
],
};
function authorize(prisma: PrismaClient) {
return async (credentials: Record<'email' | 'password', string> | undefined) => {
if (!credentials) throw new Error('Missing credentials');
if (!credentials.email) throw new Error('"email" is required in credentials');
if (!credentials.password) throw new Error('"password" is required in credentials');
const maybeUser = await prisma.user.findFirst({
where: { email: credentials.email },
select: { id: true, email: true, password: true, name: true },
});
if (!maybeUser || !maybeUser.password) return null;
const isValid = await compare(credentials.password, maybeUser.password);
if (!isValid) return null;
return { id: maybeUser.id, name: maybeUser.name };
};
}
src/pages/api/auth/[…nextauth].ts
NextAuth.jsが認証に使うページです。ここはNextAuthのリクエストハンドラーをエクスポートしているだけです。
import NextAuth from 'next-auth';
import { authOptions } from '@/server/authOptions';
export default NextAuth(authOptions);
src/pages/api/model/[…path].ts
先週のブログのコードに加え、認証(Session)情報を取得しZenStackに渡しています。
- ① NextAuth.jsのSession情報を取得
- ② ZenStackの拡張機能を含むPrismaのContextに、Session情報を渡しています
import { NextRequestHandler } from '@zenstackhq/server/next';
import { enhance } from '@zenstackhq/runtime';
import type { NextApiRequest, NextApiResponse } from 'next';
import { getServerSession } from 'next-auth';
import { prisma } from '@/server/db';
import { authOptions } from '@/server/authOptions';
async function getPrisma(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); // ← ①
return enhance(prisma, { user: session?.user }); // ← ②
}
export default NextRequestHandler({ getPrisma });
src/pages/index.tsx
トップページでGet Started With Next.jsのページに少し手を加え、ログイン済みの場合は掲示板の表示を行っています。
ログインしてないときは、Sigin, Sinupページのリンクが表示されます。
import { type NextPage } from 'next';
import { signOut, useSession } from 'next-auth/react';
import Link from 'next/link';
import Router from 'next/router';
import { useFindManyPost, useMutatePost } from '../lib/hooks';
import { User } from 'next-auth';
const Welcome = ({ user }: { user: User }) => { // ← ①
async function onSignout() {
await signOut({ redirect: false });
await Router.push('/signin');
}
return (
<div className="flex gap-4">
<h3 className="text-lg">Welcome back, {user.name}</h3>
<button className="text-gray-300 underline" onClick={() => void onSignout()}>
Signout
</button>
</div>
);
};
const SigninSignup = () => { // ← ②
return (
<div className="flex gap-4 text-2xl">
<Link href="/signin" className="rounded-lg border px-4 py-2">
Signin
</Link>
<Link href="/signup" className="rounded-lg border px-4 py-2">
Signup
</Link>
</div>
);
};
const Posts = ({ user }: { user: User }) => { // ← ③
const { createPost } = useMutatePost();
const { data: posts } = useFindManyPost({ // ← ④
include: { user: true },
orderBy: { createdAt: 'desc' },
});
async function onCreatePost() {
const text = prompt('Enter post text');
if (text) {
await createPost({ data: { text, userId: user.id } }); // ← ⑤
}
}
return (
<div className="container flex flex-col text-white">
<button className="rounded border border-white p-2 text-lg" onClick={() => void onCreatePost()}>
+ Create Post
</button>
<ul className="container mt-8 flex flex-col gap-2">
{posts?.map((post) => (
<li key={post.id} className="flex items-end gap-4">
<span className="text-sm"> {post.user.name}</span>
<span className="text-lg ml-2"> {post.text}</span>
</li>
))}
</ul>
</div>
);
};
const Home: NextPage = () => { // ← ⑥
const { data: session, status } = useSession(); // ← ⑦
if (status === 'loading') return <p>Loading ...</p>;
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16 text-white">
<h1 className="text-5xl font-extrabold">My BSS</h1>
{session?.user ? ( // ← ⑧
<div className="flex flex-col">
<Welcome user={session.user} />
<section className="mt-10">
<Posts user={session.user} />
</section>
</div>
) : (
<SigninSignup />
)}
</div>
</main>
);
};
export default Home;
- ① ページ上部のログイン者名の表示とSignoutボタンのコンポーネント
- ② ログインしてないとき表示される、Sigin, Sinupページへのリンクのコンポーネント
- ③ 投稿ボタンと投稿内容の表示のコンポーネント
- ④ 投稿記事の取得、先週説明したように、useFindManyPostで全Postデータを取得しています
- ⑤ 投稿の作成、createPostでPostデータを作成
- ⑥ メインのコンポーネント
- ⑦ Session情報の取得、これもSWRベースです
- ⑧ 表示部分
- Session情報が取得でたらログイン中なので、投稿一覧を表示。
- ログインしてなければ、Sigin, Sinupリンクを表示
src/pages/signup.tsx
ユーザー登録ページでGet Started With Next.jsに名前(name)登録欄を追加しました。
- ① signup = createUserでユーザーを登録
- ② 登録に成功した場合、ここでログイン(signIn)しています
- ③ 今回追加した名前(name)の登録用コード
import type { NextPage } from 'next';
import { signIn } from 'next-auth/react';
import Router from 'next/router';
import { useState, type FormEvent } from 'react';
import { useMutateUser } from '@/lib/hooks';
const Signup: NextPage = () => {
const [email, setEmail] = useState('');
const [name, setName] = useState(''); // ← ③
const [password, setPassword] = useState('');
const { createUser: signup } = useMutateUser();
async function onSignup(e: FormEvent) {
e.preventDefault();
try {
await signup({ data: { email, name, password } }); // ← ①
} catch (err: any) {
console.error(err);
if (err.info?.prisma && err.info?.code === 'P2002') {
alert('User alread exists');
} else {
alert('An unknown error occurred');
}
return;
}
// signin to create a session
await signIn('credentials', { redirect: false, email, password }); // ← ②
await Router.push('/');
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<h1 className="text-5xl font-extrabold text-white">Sign up</h1>
<form className="mt-16 flex flex-col gap-8 text-2xl" onSubmit={(e) => void onSignup(e)}>
<div>
<label htmlFor="email" className="inline-block w-32 text-white">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
className="ml-4 w-72 rounded border p-2"
/>
</div>
<div> // ← ③
<label htmlFor="name" className="inline-block w-32 text-white">
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
className="ml-4 w-72 rounded border p-2"
/>
</div>
<div>
<label htmlFor="password" className="inline-block w-32 text-white ">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
className="ml-4 w-72 rounded border p-2"
/>
</div>
<input
type="submit"
value="Create account"
className="cursor-pointer rounded border border-gray-500 py-4 text-white"
/>
</form>
</div>
);
};
export default Signup;
src/pages/signin.tsx
ログインページでGet Started With Next.jsのままです。
- ① でログイン(signIn)
import type { NextPage } from 'next';
import { signIn } from 'next-auth/react';
import Router from 'next/router';
import { useState, type FormEvent } from 'react';
const Signin: NextPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
async function onSignin(e: FormEvent) {
e.preventDefault();
const result = await signIn('credentials', { // ← ①
redirect: false,
email,
password,
});
if (result?.ok) {
await Router.push('/');
} else {
alert('Signin failed');
}
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<h1 className="text-5xl font-extrabold text-white">Login</h1>
<form className="mt-16 flex flex-col gap-8 text-2xl" onSubmit={(e) => void onSignin(e)}>
<div>
<label htmlFor="email" className="inline-block w-32 text-white">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
className="ml-4 w-72 rounded border p-2"
/>
</div>
<div>
<label htmlFor="password" className="inline-block w-32 text-white">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
className="ml-4 w-72 rounded border p-2"
/>
</div>
<input
type="submit"
value="Sign me in"
className="cursor-pointer rounded border border-gray-500 py-4 text-white"
/>
</form>
</div>
);
};
export default Signin;
まとめ
今回のアプリのようにZenStack + Next.jsを使うと、以下を書くだけで簡単にフロントエンド+バックエンド両方が出来ました。
- schema.zmodel
- Validationや権限管理を含む、データベースの定義
- バックエンドのロジック
- このアプリではログイン認証・セッション管理
- フロントエンド
- React画面
ZenStackのドキュメントには数々の問題がありますが、やはりシンプルで良いライブラリーだと確信しました。