🪴

PrismaとPothosでコード生成を使いながら効率よくGraphQLサーバーを作ってみる

2023/11/12に公開

はじめに

この記事ではGraphQLサーバーをPothosを使って実装していきます。

ところで皆さんはPothosをご存知でしょうか?

どんなものかというと、いわゆるコードファーストな開発を手助けするツールです。同じようなものとしてはNexusなんかが有名ですね。Prismaの公式サイトでもNexusを使った開発を紹介してますよね。
ただ、Nexusは最近開発がほぼ止まってしまっていて不穏な雰囲気が漂っています。

そこで、なにか代替できるものはないかと探してみたところ、Pothosが良さそうだったので使ってみました。

この記事では、Prismaとの統合Pothos Pluginsを使ってRelay準拠・認証認可の実装を紹介していきます。

ソースコード全体は以下のGitHubリポジトリに上げてます。

https://github.com/akhrszk/prisma-pothos-example

開発環境構築

pnpm init

# TypeScriptをインストール
pnpm add -D typescript ts-node @types/node
pnpm tsc --init

# eslintの設定
npm init @eslint/config
✔ How would you like to use ESLint? · style
✔ What type of modules does your project use? · commonjs
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · No / Yes
✔ Where does your code run? · browser
✔ How would you like to define a style for your project? · guide
✔ Which style guide do you want to follow? · standard-with-typescript
✔ What format do you want your config file to be in? · JavaScript

# prettier導入
pnpm add -D prettier eslint-config-prettier
pnpm add -D npm-run-all

Prismaを入れる

pnpm add -D prisma
pnpm prisma init

適当にスキーマファイルを書きます。今回はデータベースはPostgreSQLを使います。

例として作るものは、チームで使うブログシステムをイメージしてます。ユーザーのログイン機能ロール(管理者/メンバー)記事の作成記事のドラフト/公開の設定機能などを備えてます。

prisma/prisma.schema
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
  // Generate into custom location because this repo has multiple prisma schemas
  output   = "./client"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum Role {
  MEMBER
  ADMIN
}

enum PostStatus {
  DRAFT
  PUBLIC
}

model User {
  id    Int    @id @default(autoincrement())
  email String @unique @db.VarChar(255)
  name  String @db.VarChar(255)
  role  Role   @default(MEMBER)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  password Password?
  posts    Post[]
}

model Password {
  userId Int  @id
  user   User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)

  hashed String

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id      Int        @id @default(autoincrement())
  title   String     @db.VarChar(255)
  content String
  status  PostStatus @default(DRAFT)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  authorId Int
  author   User @relation(fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Cascade)
}

Prismaのスキーマファイルが書けたらPrisma Clientを生成します。

pnpm prisma generate

Pothosを入れる

@pothos/plugin-prismaはPrismaと連携するためにpluginです。さらにRelay準拠にするために@pothos/plugin-relayも入れます。

pnpm add @pothos/core @pothos/plugin-prisma @pothos/plugin-relay

prisma.schemaにPothos用の設定を書き加えて、generateします。

prisma/prisma.schema
+ generator pothos {
+   provider     = "prisma-pothos-types"
+   // Match client output location from above
+   clientOutput = "./client"
+   output       = "./generated.d.ts"
+ }
pnpm prisma generate

Pothosで使うための型が生成されます。

Seedデータを入れる

ダミーデータの生成にFakerを利用します。

pnpm add -D @faker-js/faker

PrismaのSeed機能を使って入れるデータのコードを書いていきます。

prisma/seed.ts
import { type PostStatus, PrismaClient } from "./client";
import { faker } from "@faker-js/faker";
import { hashPassword } from "../src/utils";

const prisma = new PrismaClient();

async function main() {
  const users = Array.from({ length: 10 })
    .map((_, i) => ({
      id: i + 1,
      email: faker.internet.email(),
      name: faker.person.fullName(),
    }));
  const posts = Array.from({ length: 30 })
    .map((_, i) => ({
      id: i + 1,
      title: faker.lorem.sentence(),
      content: faker.lorem.paragraph(),
      status: Math.floor(Math.random() * 10) % 4 === 0 ? "DRAFT" : "PUBLIC",
      authorId: Math.floor(Math.random() * 10) + 1,
    }));

  const hashedPassword = await hashPassword("password");

  await Promise.all(users.map(user =>
    prisma.user.upsert({
      where: { id: user.id },
      update: {},
      create: {
        id: user.id,
        email: user.email,
        name: user.name,
        password: {
          create: {
            hashed: hashedPassword,
          }
        }
      },
    })
  ));

  await Promise.all(posts.map(post => prisma.post.upsert({
      where: { id: post.id },
      update: {},
      create: {
        id: post.id,
        title: post.title,
        content: post.content,
        status: post.status as PostStatus,
        authorId: post.authorId,
      },
    })
  ));
}

main()
  .then(async () => {
    await prisma.$disconnect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });

Prismaのコマンドを使ってseedデータを入れます。

pnpm prisma db seed

実行出来たら、Prisma Studioでデータが入ってること確認しましょう。

pnpm prisma studio
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555

PrismaとPothosの統合

ここまで出来たらいよいよPothosでGraphQLサーバーを実装していきます。

Prisma Client

src/db.ts
import { Prisma, PrismaClient } from "../prisma/client";

export const prisma = new PrismaClient({
  log:
    process.env.NODE_ENV === "production"
      ? ["warn", "error"]
      : ["query", "info", "warn", "error"],
});

Prisma ClientをContextに置くのはVSCodeの型チェックを重くするため非推奨だそうなので、グローバル変数で扱います。

https://pothos-graphql.dev/docs/plugins/prisma#set-up-the-builder

It is strongly recommended NOT to put your prisma client into Context. This will result in slower type-checking and a laggy developer experience in VSCode. See this issue for more details.

Pothosのスキーマビルダ

今回利用する PrismaPlugin と RelayPlugin をスキーマビルダに渡します。

src/builder.ts
import SchemaBuilder from "@pothos/core";
import PrismaPlugin from "@pothos/plugin-prisma";
import RelayPlugin from "@pothos/plugin-relay";
import type PrismaTypes from "../prisma/generated";
import { prisma } from "./db";

export const builder = new SchemaBuilder<{
  PrismaTypes: PrismaTypes;
}>({
  plugins: [PrismaPlugin, RelayPlugin],
  relayOptions: {},
  prisma: {
    client: prisma,
    dmmf: Prisma.dmmf,
  },
});

builder.queryType();
builder.mutationType();

Pothosでリゾルバを定義

prismaNode()prismaConnection()relatedConnection() を使うことでRelay準拠になってくれます。

Relay準拠なのでフロントエンドとやり取りするIDは一意なグローバルIDにエンコードされます。encodeGlobalID/decodeGlobalID()を使うことで手動でエンコード/デコードできます。

src/schema.ts
import { decodeGlobalID } from "@pothos/plugin-relay";
import { builder } from "./builder";
import { prisma } from "./db";

builder.prismaNode("User", {
  id: { field: "id" },
  findUnique: (id) => {
    const { id: rawID } = decodeGlobalID(id);
    return { id: Number.parseInt(rawID, 10) };
  },
  fields: (t) => ({
    name: t.exposeString("name"),
    posts: t.relatedConnection("posts", {
      cursor: "id",
      totalCount: true,
    }),
  }),
});

builder.prismaNode("Post", {
  id: { field: "id" },
  findUnique: (id) => {
    const { id: rawID } = decodeGlobalID(id);
    return { id: Number.parseInt(rawID, 10) };
  },
  fields: (t) => ({
    title: t.exposeString("title"),
    content: t.exposeString("content"),
    author: t.relation("author"),
  }),
});

builder.queryFields((t) => ({
  post: t.prismaField({
    type: "Post",
    nullable: true,
    args: {
      id: t.arg.id({ required: true }),
    },
    resolve: async (query, _, args) => {
      const { id } = decodeGlobalID(args.id as string);
      return await prisma.post.findUnique({
        ...query,
        where: { id: parseInt(id, 10) },
      });
    },
  }),
  posts: t.prismaConnection({
    type: "Post",
    cursor: "id",
    resolve: async (query) => await prisma.post.findMany({ ...query }),
  }),
}));

// スキーマビルダでGraphQLスキーマを生成する。
export const schema = builder.toSchema();

これだけでRelay準拠のConnectionを返してくれるようになります。とても便利ですよね。

GraphQLサーバーを立ち上げる

何でも良いのですが今回は GraphQL Yoga を使ってみます。

pnpm add graphql-yoga

スキーマビルダから生成したSchemaを渡してGraphQLサーバーを作ります。

src/main.ts
import { createServer } from 'node:http'
import { createYoga } from "graphql-yoga"
import { schema } from "./schema"

const yoga = createYoga({
  schema,
})

const server = createServer(yoga)

const port = process.env.PORT || "3000";

server.listen(port, () => {
  console.info(
    `Server is running on http://localhost:${port}${yoga.graphqlEndpoint}`,
  );
});

書けたらGraphQLサーバーを立ち上げてみます。

pnpm ts-node src/main.ts
Server is running on http://localhost:3000/graphql

ブラウザでURLにアクセスするとGraphiQLを開くことが出来ます。

GraphiQL ScreenShot

ログイン機能を実装する

パスワードのハッシュ化にbcrypt、認証トークンはJWTを使います。

pnpm add bcrypt jsonwebtoken
pnpm add -D @types/bcrypt @types/jsonwebtoken

ログインに成功したらトークンをCookieに保存しようと思います。

GraphQL YogaのCookiesプラグインを使います。

pnpm add @whatwg-node/server-plugin-cookies
src/main.ts
  const yoga = createYoga({
    schema,
+   plugins: [useCookies()],
  });

Contextを定義

Contextにログイン済みのユーザー情報を保存します。Contextのインターフェースを定義してSchema Builderで渡します。

src/context.ts
import { type YogaInitialContext } from "graphql-yoga";
import { type User } from "../prisma/client";

export interface Context extends YogaInitialContext {
  user?: User;
}
src/builder.ts
  export const builder = new SchemaBuilder<{
    PrismaTypes: PrismaTypes;
+   Context: Context;
  }>({
    plugins: [PrismaPlugin, RelayPlugin],
    relayOptions: {},
    prisma: {
      client: prisma,
    },
});

ログインMutationを実装

Simple Objects Pluginを使います。

pnpm add @pothos/plugin-simple-objects
src/builder.ts
  export const builder = new SchemaBuilder<{
    PrismaTypes: PrismaTypes;
    Context: Context;
  }>({
-   plugins: [PrismaPlugin, RelayPlugin],
+   plugins: [PrismaPlugin, RelayPlugin, SimpleObjectsPlugin],
    relayOptions: {},
    prisma: {
      client: prisma,
    },
  });

ログインMutationの戻り値のObjectの定義とログインMutationの処理を書いていきます。

src/schema.ts
const LoginType = builder.simpleObject("Login", {
  fields: (t) => ({
    token: t.string({ nullable: false }),
  }),
});

builder.mutationFields((t) => ({
  login: t.field({
    type: LoginType,
    args: {
      email: t.arg.string({ required: true }),
      password: t.arg.string({ required: true }),
    },
    resolve: async (_, args, ctx) => {
      const userWithPassword = await prisma.user.findUnique({
        where: { email: args.email },
        include: { password: true },
      });
      if (!userWithPassword || !userWithPassword.password) {
        throw new Error("Failed login");
      }
      const isVerifiedPassword = await verifyPassword({
        rawPassword: args.password,
        hashedPassword: userWithPassword.password.hashed,
      });
      if (!isVerifiedPassword) {
        throw new Error("Failed login");
      }
      const token = jwtSign(userWithPassword.id);
      await ctx.request.cookieStore?.set(CookieKeys.authToken, token);
      return { token };
    },
  }),
}));

ユーザー情報をContextに渡す処理を書いていきます。Cookieで渡されるトークンをデコードしてDBからユーザーを取得します。

src/main.ts
  const yoga = createYoga({
    schema,
+   context: async (ctx) => {
+     const authToken =
+       ctx.request.headers.get("Authorization")?.split(" ")?.[1] ||
+       (await ctx.request.cookieStore?.get(CookieKeys.authToken))?.value;
+     if (!authToken) {
+       return { ...ctx };
+     }
+     const auth = jwtVerify(authToken);
+     const user = await prisma.user.findUnique({
+       where: { id: parseID(auth.sub!) },
+     });
+     return { ...ctx, user };
+   },
    plugins: [useCookies()],
  });

認可処理を行う

PothosのAuth Pluginを使います。

pnpm add @pothos/plugin-scope-auth

スキーマビルダに AuthScopes の設定を追記する。

src/builder.ts
  export const builder = new SchemaBuilder<{
+   AuthScopes: {
+     member: boolean;
+     admin: boolean;
+   };
    PrismaTypes: PrismaTypes;
    Context: Context;
  }>({
-   plugins: [PrismaPlugin, RelayPlugin, SimpleObjectsPlugin],
+   plugins: [ScopeAuthPlugin, PrismaPlugin, RelayPlugin, SimpleObjectsPlugin],
+   authScopes: async (ctx) => ({
+     admin: ctx.user?.role === "ADMIN",
+     member: ctx.user?.role === "MEMBER",
+   }),
    relayOptions: {},
    prisma: {
      client: prisma,
    },
  });

フィールドに対してスコープを設定する。

src/schema.ts
  const User = builder.prismaNode("User", {
    id: { field: "id" },
    findUnique: (id) => ({ id: parseID(id) }),
    fields: (t) => ({
      name: t.exposeString("name"),
-     email: t.exposeString("email"),
+     email: t.exposeString("email", {
+       authScopes: { admin: true, member: true },
+     }),
      role: t.exposeString("role"),
      posts: t.relation("posts"),
    }),
  });

QueryやMutationに対してスコープを設定する。

src/schema.ts
  builder.mutationField("updateUserRole", (t) =>
    t.prismaField({
      type: User,
      args: {
        input: t.arg({ type: updateUserRoleInput, required: true }),
      },
+     authScopes: { admin: true },
      resolve: (query, _, { input }) =>
        prisma.user.update({
          ...query,
          where: { id: parseID(input.userId) },
          data: { role: input.role },
        }),
    }),
  );

カスタムスカラーを追加する

例として graphql-scalars のDateTime Resolverを追加してみます。

src/builder.ts
  export const builder = new SchemaBuilder<{
+   Scalars: {
+     DateTime: {
+       Input: Date;
+       Output: Date;
+     };
+   };
    Connection: {
      totalCount: number | (() => number | Promise<number>),
    },
    AuthScopes: {
      loggedIn: boolean;
      member: boolean;
      admin: boolean;
    };
    PrismaTypes: PrismaTypes;
    Context: Context;
  }>({
    plugins: [ScopeAuthPlugin, PrismaPlugin, RelayPlugin, SimpleObjectsPlugin],
    authScopes: async (ctx) => ({
      loggedIn: !!ctx.user,
      admin: ctx.user?.role === "ADMIN",
      member: ctx.user?.role === "MEMBER",
    }),
    relayOptions: {},
    prisma: {
      client: prisma,
      dmmf: Prisma.dmmf,
      filterConnectionTotalCount: true,
    },
  });

+ builder.addScalarType("Date", DateResolver, {});

updateAt と createdAt の型にDateTimeを指定します。

src/schema.ts
  builder.prismaNode("Post", {
    id: { field: "id" },
    findUnique: (id) => ({ id: parseID(id) }),
    fields: (t) => ({
      title: t.exposeString("title"),
      content: t.exposeString("content"),
      author: t.relation("author"),
+     createdAt: t.expose("createdAt", { type: "DateTime" }),
+     updatedAt: t.expose("updatedAt", { type: "DateTime" }),
    }),
  });

まとめ

この記事では紹介出来ませんでしたが、Query complexityのPluginやInputのバリデーションのPluginなど他にも数多くのPluginが用意されているのでそちらも便利そうで気になりますね。
👉 Pothos GraphQL Plugins

どうでしたでしょうか。自動生成をゴリゴリで使って楽が出来そうな雰囲気を味わえたのではないでしょうか。
コードを機械的に自動生成することで人の変なミスが混入しないので、バグが起こりにくく安全なアプリケーションになることも利点ですよね。

プログラマの3大美徳の一つ「怠惰であれ」にも通ずる話ですよね。

GitHubで編集を提案

Discussion