EY-Office ブログ

ZenStackをもう少し学んでみた、ZModel便利です

先週のZenStackはドキュメントは最悪だが良いライブラリーかもしれないに書いたようにZenStackは素晴らしいライブラリーなので、もう少し学んでみました。

前回のアプリではZenStackの重要な機能であるZmodelは、ほとんど使っていませんでした。そこで今回はGet Started With Next.jsの例題に似たアプリを作ってみることにしました。

ZenStack

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のドキュメントには数々の問題がありますが、やはりシンプルで良いライブラリーだと確信しました。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ