PrismaとPothosでコード生成を使いながら効率よくGraphQLサーバーを作ってみる
はじめに
この記事ではGraphQLサーバーをPothosを使って実装していきます。
ところで皆さんはPothosをご存知でしょうか?
どんなものかというと、いわゆるコードファーストな開発を手助けするツールです。同じようなものとしてはNexusなんかが有名ですね。Prismaの公式サイトでもNexusを使った開発を紹介してますよね。
ただ、Nexusは最近開発がほぼ止まってしまっていて不穏な雰囲気が漂っています。
そこで、なにか代替できるものはないかと探してみたところ、Pothosが良さそうだったので使ってみました。
この記事では、Prismaとの統合、Pothos Pluginsを使ってRelay準拠・認証認可の実装を紹介していきます。
ソースコード全体は以下のGitHubリポジトリに上げてます。
開発環境構築
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を使います。
例として作るものは、チームで使うブログシステムをイメージしてます。ユーザーのログイン機能、ロール(管理者/メンバー)、記事の作成、記事のドラフト/公開の設定機能などを備えてます。
// 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します。
+ 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機能を使って入れるデータのコードを書いていきます。
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
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 をスキーマビルダに渡します。
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()
を使うことで手動でエンコード/デコードできます。
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サーバーを作ります。
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を開くことが出来ます。
ログイン機能を実装する
パスワードのハッシュ化にbcrypt、認証トークンはJWTを使います。
pnpm add bcrypt jsonwebtoken
pnpm add -D @types/bcrypt @types/jsonwebtoken
ログインに成功したらトークンをCookieに保存しようと思います。
GraphQL YogaのCookiesプラグインを使います。
pnpm add @whatwg-node/server-plugin-cookies
const yoga = createYoga({
schema,
+ plugins: [useCookies()],
});
Contextを定義
Contextにログイン済みのユーザー情報を保存します。Contextのインターフェースを定義してSchema Builderで渡します。
import { type YogaInitialContext } from "graphql-yoga";
import { type User } from "../prisma/client";
export interface Context extends YogaInitialContext {
user?: User;
}
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
export const builder = new SchemaBuilder<{
PrismaTypes: PrismaTypes;
Context: Context;
}>({
- plugins: [PrismaPlugin, RelayPlugin],
+ plugins: [PrismaPlugin, RelayPlugin, SimpleObjectsPlugin],
relayOptions: {},
prisma: {
client: prisma,
},
});
ログインMutationの戻り値のObjectの定義とログインMutationの処理を書いていきます。
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からユーザーを取得します。
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
の設定を追記する。
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,
},
});
フィールドに対してスコープを設定する。
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に対してスコープを設定する。
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を追加してみます。
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を指定します。
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大美徳の一つ「怠惰であれ」にも通ずる話ですよね。
Discussion