はじめに
課題:REST APIで過剰なデータが転送される?複数のクライアント有不同的なデータ要件?フロントエンドがバックエンドの変更に依存する?
解決策:GraphQL を習得する — フロントエンドが必要なデータを正確に取得、一度のリクエストで全てのクライアントに対応。
GraphQL アーキテクチャ:
GraphQL と REST の比較:
| 機能 | GraphQL | REST |
|---|---|---|
| データ取得 | 単一リクエスト、精緻なフィールド | 複数リクエスト、過剩データ |
| バージョン管理 | バージョンレス、フィールドで進化 | バージョンパス /v1/, /v2/ |
| タイプシステム | 強い型付けSchema | 統一型なし |
| ドキュメント | 自己記述型 | Swagger/OpenAPIが必要 |
| キャッシュ | 手動管理 | HTTPキャッシュが自然対応 |
| リアルタイム購読 | ネイティブSubscription | WebSocket拡張が必要 |
一、GraphQL 基礎概念
1.1 GraphQL とは
GraphQL 三層アーキテクチャ:
1.2 Schema 定義言語(SDL)
# ===== GraphQL Schema 定義 =====
# ユーザタイプ定義
type Member {
id: ID!
username: String!
mailAddress: String!
birthday: Int
accountStatus: Boolean!
registrationDate: DateTime!
lastLoginDate: DateTime!
# リレーション
articles: [Article!]!
buddies: [Member!]!
followers: [Member!]!
following: [Member!]!
}
# 記事タイプ定義
type Article {
id: ID!
subject: String!
urlSlug: String!
body: String!
summary: String
thumbnailUrl: String
isPublic: Boolean!
publishedDate: DateTime
accessCount: Int!
estimatedReadMinutes: Int!
registrationDate: DateTime!
lastUpdateDate: DateTime!
# リレーション
writer: Member!
responses: [Response!]!
categories: [Category!]!
reactions: [Reaction!]!
}
# レスポンスタイプ定義
type Response {
id: ID!
message: String!
registrationDate: DateTime!
# リレーション
writer: Member!
article: Article!
replies: [Response!]!
parent: Response
}
# カテゴリタイプ定義
type Category {
id: ID!
label: String!
slug: String!
articleCount: Int!
articles: [Article!]!
}
# ライクタイプ定義
type Reaction {
id: ID!
registrationDate: DateTime!
user: Member!
article: Article!
}
# 列挙型
enum UserRole {
SUPER_ADMIN
MANAGER
CONTRIBUTOR
SUBSCRIBER
}
# インターフェース定義
interface Node {
id: ID!
}
interface TimeStamped {
registrationDate: DateTime!
lastUpdateDate: DateTime!
}
# スカラー型
scalar DateTime
scalar JSON
scalar Upload
# 入力型(Mutation用)
input CreateArticleInput {
subject: String!
body: String!
urlSlug: String
thumbnailUrl: String
categoryIds: [ID!]
}
input UpdateArticleInput {
subject: String
body: String
thumbnailUrl: String
}
input PagingInput {
page: Int! = 1
perPage: Int! = 10
}
input OrderingInput {
field: String!
direction: SortDirection! = ASC
}
enum SortDirection {
ASC
DESC
}
1.3 オペレーションタイプ
# ===== Query(クエリ) =====
type Query {
# 個別ユーザ取得
member(id: ID!): Member
# ログインユーザ取得
currentUser: Member
# 記事リスト取得
articles(
condition: ArticleFilterInput
paging: PagingInput
ordering: OrderingInput
): ArticleConnection!
# 個別記事取得
article(id: ID, urlSlug: String): Article
# カテゴリリスト取得
categories: [Category!]!
# レスポンス取得
responses(articleId: ID!): [Response!]!
}
# 接続タイプ(ページネーション用)
type ArticleConnection {
edges: [ArticleEdge!]!
pageInfo: PageInfo!
totalRecords: Int!
}
type ArticleEdge {
node: Article!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# ===== Mutation(変更) =====
type Mutation {
# ユーザ関連
createMember(input: CreateMemberInput!): Member!
updateMember(id: ID!, input: UpdateMemberInput!): Member!
removeMember(id: ID!): Boolean!
# 記事関連
createArticle(input: CreateArticleInput!): Article!
updateArticle(id: ID!, input: UpdateArticleInput!): Article!
removeArticle(id: ID!): Boolean!
publishArticle(id: ID!): Article!
unpublishArticle(id: ID!): Article!
# レスポンス関連
createResponse(articleId: ID!, input: CreateResponseInput!): Response!
removeResponse(id: ID!): Boolean!
# ライク関連
reactToArticle(articleId: ID!): Reaction!
removeReaction(articleId: ID!): Boolean!
}
# ===== Subscription(購読) =====
type Subscription {
# リアルタイム新レスポンス購読
responseAdded(articleId: ID!): Response!
# 記事更新購読
articleModified(id: ID!): Article!
# ユーザ状態購読
memberStatusChanged(memberId: ID!): MemberStatus!
}
二、Node.js + Apollo Server 実装
2.1 プロジェクト初期化
# ===== プロジェクト初期化 =====
mkdir graphql-api
cd graphql-api
npm init -y
# 依存パッケージインストール
npm install @apollo/server graphql graphql-tag
npm install @as-integrations/fastify # Fastify使用時
npm install express @apollo/server/express4 # Express使用時
npm install @graphql-tools/schema graphql-middleware # ツール
npm install @prisma/client prisma # データベースORM
npm install @graphql-auth-directive # 認証
npm install graphql-scalars # 拡張スカラー型
# 開発用依存
npm install -D typescript @types/node ts-node nodemon
// package.json
{
"name": "graphql-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "nodemon --exec ts-node-esm server.ts",
"build": "tsc",
"start": "node dist/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
}
}
2.2 TypeScript 設定
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
2.3 Schema 定義
// src/schema/index.ts
import { gql } from 'graphql-tag';
export const typeDefs = gql`
scalar DateTime
type Member {
id: ID!
username: String!
mailAddress: String!
profileImage: String
description: String
role: UserRole!
accountStatus: Boolean!
registrationDate: DateTime!
lastLoginDate: DateTime!
articles: [Article!]!
articleCount: Int!
}
type Article {
id: ID!
subject: String!
urlSlug: String!
body: String!
summary: String
thumbnailUrl: String
isPublic: Boolean!
publishedDate: DateTime
accessCount: Int!
estimatedReadMinutes: Int!
registrationDate: DateTime!
lastUpdateDate: DateTime!
writer: Member!
responses: [Response!]!
responseCount: Int!
categories: [Category!]!
hasReacted: Boolean!
reactionCount: Int!
}
type Response {
id: ID!
message: String!
registrationDate: DateTime!
writer: Member!
article: Article!
replies: [Response!]!
parent: Response
}
type Category {
id: ID!
label: String!
slug: String!
accentColor: String
articleCount: Int!
}
type Reaction {
id: ID!
registrationDate: DateTime!
user: Member!
article: Article!
}
type LoginResult {
accessToken: String!
member: Member!
}
enum UserRole {
SUPER_ADMIN
MANAGER
CONTRIBUTOR
SUBSCRIBER
}
type Query {
# ユーザ
currentUser: Member
member(id: ID!): Member
members(paging: PagingInput): MemberConnection!
# 記事
article(id: ID, urlSlug: String): Article
articles(
condition: ArticleFilterInput
paging: PagingInput
ordering: OrderingInput
): ArticleConnection!
popularArticles: [Article!]!
# カテゴリ
categories: [Category!]!
category(slug: String!): Category
# レスポンス
responses(articleId: ID!): [Response!]!
}
type Mutation {
# 認証
login(mailAddress: String!, password: String!): LoginResult!
register(input: RegisterInput!): LoginResult!
# ユーザ
updateProfile(input: UpdateProfileInput!): Member!
# 記事
createArticle(input: CreateArticleInput!): Article!
updateArticle(id: ID!, input: UpdateArticleInput!): Article!
removeArticle(id: ID!): Boolean!
publishArticle(id: ID!): Article!
unpublishArticle(id: ID!): Article!
# レスポンス
createResponse(articleId: ID!, message: String!, parentId: ID): Response!
removeResponse(id: ID!): Boolean!
# ライク
reactToArticle(articleId: ID!): Reaction!
removeReaction(articleId: ID!): Boolean!
}
type Subscription {
responseAdded(articleId: ID!): Response!
articleReacted(articleId: ID!): Reaction!
articleViewed(articleId: ID!): Article!
}
# 入力型
input RegisterInput {
username: String!
mailAddress: String!
password: String!
}
input UpdateProfileInput {
username: String
profileImage: String
description: String
}
input CreateArticleInput {
subject: String!
body: String!
urlSlug: String
thumbnailUrl: String
categoryIds: [ID!]
}
input UpdateArticleInput {
subject: String
body: String
thumbnailUrl: String
categoryIds: [ID!]
}
input ArticleFilterInput {
keyword: String
categorySlugs: [String!]
writerId: ID
isPublic: Boolean
startDate: DateTime
endDate: DateTime
}
input PagingInput {
page: Int = 1
perPage: Int = 10
}
input OrderingInput {
field: String = "registrationDate"
direction: SortDirection = DESC
}
enum SortDirection {
ASC
DESC
}
# 接続タイプ
type MemberConnection {
edges: [MemberEdge!]!
pageInfo: PageInfo!
totalRecords: Int!
}
type MemberEdge {
node: Member!
cursor: String!
}
type ArticleConnection {
edges: [ArticleEdge!]!
pageInfo: PageInfo!
totalRecords: Int!
}
type ArticleEdge {
node: Article!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
`;
2.4 Resolver 関数
// src/resolvers/index.ts
import { Context } from '../context';
import { AuthenticationError, ForbiddenError, UserInputError } from '../errors';
import {prisma} from '../lib/prisma';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
export const resolvers = {
// スカラー型解決
DateTime: {
__parseValue(value: string) {
return new Date(value);
},
__serialize(value: Date) {
return value.toISOString();
},
__parseLiteral(ast: any) {
if (ast.kind === 'StringValue') {
return new Date(ast.value);
}
return null;
},
},
// クエリ解決
Query: {
// ログインユーザ
currentUser: async (_: any, __: any, context: Context) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
return context.user;
},
// 個別ユーザ
member: async (_: any, { id }: { id: string }, context: Context) => {
return prisma.member.findUnique({ where: { id } });
},
// ユーザリスト
members: async (_: any, { paging }: { paging: any }, context: Context) => {
const { page = 1, perPage = 10 } = paging || {};
const skip = (page - 1) * perPage;
const [members, totalRecords] = await Promise.all([
prisma.member.findMany({
skip,
take: perPage,
orderBy: { registrationDate: 'desc' },
}),
prisma.member.count(),
]);
return {
edges: members.map((member) => ({
node: member,
cursor: Buffer.from(member.id).toString('base64')
})),
pageInfo: {
hasNextPage: skip + members.length < totalRecords,
hasPreviousPage: page > 1,
},
totalRecords,
};
},
// 記事
article: async (_: any, { id, urlSlug }: { id?: string; urlSlug?: string }, context: Context) => {
return prisma.article.findFirst({
where: id ? { id } : { urlSlug },
});
},
// 記事リスト
articles: async (_: any, { condition, paging, ordering }: any, context: Context) => {
const { page = 1, perPage = 10 } = paging || {};
const { field = 'registrationDate', direction = 'desc' } = ordering || {};
const skip = (page - 1) * perPage;
// クエリ条件構築
const where: any = {};
if (condition) {
if (condition.keyword) {
where.OR = [
{ subject: { contains: condition.keyword, mode: 'insensitive' } },
{ body: { contains: condition.keyword, mode: 'insensitive' } },
];
}
if (condition.categorySlugs) {
where.categories: {
some: { slug: { in: condition.categorySlugs } },
};
}
if (condition.writerId) {
where.writerId = condition.writerId;
}
if (condition.isPublic !== undefined) {
where.isPublic = condition.isPublic;
}
if (condition.startDate) {
where.registrationDate = { ...where.registrationDate, gte: new Date(condition.startDate) };
}
if (condition.endDate) {
where.registrationDate = { ...where.registrationDate, lte: new Date(condition.endDate) };
}
}
const [articles, totalRecords] = await Promise.all([
prisma.article.findMany({
where,
skip,
take: perPage,
orderBy: { [field]: direction },
include: {
writer: true,
categories: true,
_count: { select: { responses: true, reactions: true } },
},
}),
prisma.article.count({ where }),
]);
// ライク状態判定
const articlesWithReactionStatus = articles.map((article) => ({
...article,
hasReacted: context.user ? await checkHasReacted(article.id, context.user.id) : false,
}));
return {
edges: articlesWithReactionStatus.map((article) => ({
node: article,
cursor: Buffer.from(article.id).toString('base64'),
})),
pageInfo: {
hasNextPage: skip + articles.length < totalRecords,
hasPreviousPage: page > 1,
startCursor: articles[0] ? Buffer.from(articles[0].id).toString('base64') : null,
endCursor: articles[articles.length - 1] ? Buffer.from(articles[articles.length - 1].id).toString('base64') : null,
},
totalRecords,
};
},
// 人気記事
popularArticles: async (_: any, __: any, context: Context) => {
return prisma.article.findMany({
where: { isPublic: true },
orderBy: { accessCount: 'desc' },
take: 5,
include: { writer: true, categories: true },
});
},
// カテゴリリスト
categories: async (_: any, __: any, context: Context) => {
return prisma.category.findMany({
include: { _count: { select: { articles: true } } },
});
},
// レスポンスリスト
responses: async (_: any, { articleId }: { articleId: string }, context: Context) => {
return prisma.response.findMany({
where: { articleId, parentId: null },
include: {
writer: true,
replies: {
include: { writer: true, replies: { include: { writer: true } } },
},
},
orderBy: { registrationDate: 'desc' },
});
},
},
// Mutation解決
Mutation: {
// ログイン
login: async (_: any, { mailAddress, password }: { mailAddress: string; password: string }) => {
const member = await prisma.member.findUnique({ where: { mailAddress } });
if (!member || !await bcrypt.compare(password, member.passwordHash)) {
throw new AuthenticationError('メールアドレスまたはパスワードが正しくありません');
}
const accessToken = jwt.sign({ memberId: member.id }, process.env.JWT_SECRET!, {
expiresIn: '7d',
});
return { accessToken, member };
},
// 登録
register: async (_: any, { input }: { input: any }) => {
const existingMember = await prisma.member.findUnique({
where: { mailAddress: input.mailAddress },
});
if (existingMember) {
throw new UserInputError('このメールアドレスは既に登録されています');
}
const passwordHash = await bcrypt.hash(input.password, 10);
const member = await prisma.member.create({
data: {
username: input.username,
mailAddress: input.mailAddress,
passwordHash,
role: 'CONTRIBUTOR',
},
});
const accessToken = jwt.sign({ memberId: member.id }, process.env.JWT_SECRET!, {
expiresIn: '7d',
});
return { accessToken, member };
},
// 記事作成
createArticle: async (_: any, { input }: { input: any }, context: Context) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
const urlSlug = input.urlSlug || createSlug(input.subject);
const article = await prisma.article.create({
data: {
subject: input.subject,
urlSlug,
body: input.body,
thumbnailUrl: input.thumbnailUrl,
writerId: context.user.id,
isPublic: false,
},
include: { writer: true, categories: true },
});
// カテゴリ追加
if (input.categoryIds && input.categoryIds.length > 0) {
await prisma.article.update({
where: { id: article.id },
data: {
categories: { connect: input.categoryIds.map((id: string) => ({ id })) },
},
});
}
return article;
},
// 記事更新
updateArticle: async (_: any, { id, input }: { id: string; input: any }, context: Context) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
const article = await prisma.article.findUnique({ where: { id } });
if (!article) {
throw new UserInputError('記事が存在しません');
}
if (article.writerId !== context.user.id && context.user.role !== 'SUPER_ADMIN') {
throw new ForbiddenError('この記事を編集する権限がありません');
}
const updatedArticle = await prisma.article.update({
where: { id },
data: {
subject: input.subject,
body: input.body,
thumbnailUrl: input.thumbnailUrl,
},
include: { writer: true, categories: true },
});
// カテゴリ更新
if (input.categoryIds) {
await prisma.article.update({
where: { id },
data: {
categories: { set: input.categoryIds.map((id: string) => ({ id })) },
},
});
}
return updatedArticle;
},
// 記事削除
removeArticle: async (_: any, { id }: { id: string }, context: Context) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
const article = await prisma.article.findUnique({ where: { id } });
if (!article) {
throw new UserInputError('記事が存在しません');
}
if (article.writerId !== context.user.id && context.user.role !== 'SUPER_ADMIN') {
throw new ForbiddenError('この記事を削除する権限がありません');
}
await prisma.article.delete({ where: { id } });
return true;
},
// 記事公開
publishArticle: async (_: any, { id }: { id: string }, context: Context) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
return prisma.article.update({
where: { id },
data: { isPublic: true, publishedDate: new Date() },
include: { writer: true, categories: true },
});
},
// レスポンス作成
createResponse: async (_: any, { articleId, message, parentId }: any, context: Context) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
const response = await prisma.response.create({
data: {
message,
articleId,
writerId: context.user.id,
parentId,
},
include: { writer: true, article: true },
});
return response;
},
// ライク
reactToArticle: async (_: any, { articleId }: { articleId: string }, context: Context) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
const existing = await prisma.reaction.findFirst({
where: { articleId, userId: context.user.id },
});
if (existing) {
return existing;
}
return prisma.reaction.create({
data: { articleId, userId: context.user.id },
include: { user: true, article: true },
});
},
// ライク解除
removeReaction: async (_: any, { articleId }: { articleId: string }, context: Context) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
await prisma.reaction.deleteMany({
where: { articleId, userId: context.user.id },
});
return true;
},
},
// Subscription解決
Subscription: {
responseAdded: {
subscribe: async function* (_, { articleId }: any) {
for await (const response of responseIterator(articleId)) {
yield { responseAdded: response };
}
},
},
},
// タイプ解決
Member: {
articles: async (parent: any) => {
return prisma.article.findMany({
where: { writerId: parent.id, isPublic: true },
orderBy: { registrationDate: 'desc' },
});
},
articleCount: async (parent: any) => {
return prisma.article.count({ where: { writerId: parent.id, isPublic: true } });
},
},
Article: {
writer: async (parent: any) => {
return prisma.member.findUnique({ where: { id: parent.writerId } });
},
responses: async (parent: any) => {
return prisma.response.findMany({
where: { articleId: parent.id, parentId: null },
orderBy: { registrationDate: 'desc' },
});
},
responseCount: async (parent: any) => {
return prisma.response.count({ where: { articleId: parent.id } });
},
categories: async (parent: any) => {
return prisma.category.findMany({
where: { articles: { some: { id: parent.id } } },
});
},
reactionCount: async (parent: any) => {
return prisma.reaction.count({ where: { articleId: parent.id } });
},
estimatedReadMinutes: (parent: any) => {
const wordsPerMinute = 200;
const wordCount = parent.body.split(/\s+/).length;
return Math.ceil(wordCount / wordsPerMinute);
},
},
Category: {
articleCount: async (parent: any) => {
return prisma.article.count({
where: { isPublic: true, categories: { some: { id: parent.id } } },
});
},
},
};
// ヘルパー関数
async function checkHasReacted(articleId: string, userId: string): Promise<boolean> {
const reaction = await prisma.reaction.findFirst({
where: { articleId, userId },
});
return !!reaction;
}
function createSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
async function* responseIterator(articleId: string) {
// PubSub実装
}
2.5 Context と認証
// src/context.ts
import { PrismaClient } from '@prisma/client';
import { Member } from '@prisma/client';
import jwt from 'jsonwebtoken';
export interface Context {
prisma: PrismaClient;
user: Member | null;
}
export async function createContext(req: any): Promise<Context> {
const prisma = new PrismaClient();
// Authorization Headerからtoken取得
const authHeader = req.headers.authorization || '';
const token = authHeader.replace('Bearer ', '');
let user = null;
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { memberId: string };
user = await prisma.member.findUnique({ where: { id: decoded.memberId } });
} catch (error) {
// token無効時はゲストとして続行
}
}
return { prisma, user };
}
2.6 サーバエントリポイント
// src/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
const app = express();
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true, // 開発環境で有効
formatError: (error) => {
// 本番環境でstack trace除外
if (process.env.NODE_ENV === 'production') {
return {
message: error.message,
path: error.path,
};
}
return error;
},
});
await server.start();
app.use(
'/graphql',
cors<cors.CorsRequest>(),
express.json({ limit: '10mb' }),
expressMiddleware(server, {
context: async ({ req }) => createContext(req),
})
);
// ヘルスチェック
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`🚀 GraphQL Server ready at http://localhost:${PORT}/graphql`);
});
三、Prisma データベース統合
3.1 Prisma Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Member {
id String @id @default(cuid())
mailAddress String @unique
username String
passwordHash String
profileImage String?
description String?
role UserRole @default(CONTRIBUTOR)
accountStatus Boolean @default(true)
registrationDate DateTime @default(now())
lastLoginDate DateTime @updatedAt
articles Article[]
responses Response[]
reactions Reaction[]
}
model Article {
id String @id @default(cuid())
subject String
urlSlug String @unique
body String
summary String?
thumbnailUrl String?
isPublic Boolean @default(false)
publishedDate DateTime?
accessCount Int @default(0)
estimatedReadMinutes Int @default(0)
registrationDate DateTime @default(now())
lastUpdateDate DateTime @updatedAt
writerId String
writer Member @relation(fields: [writerId], references: [id])
categories Category[]
responses Response[]
reactions Reaction[]
@@index([writerId])
@@index([isPublic])
@@index([urlSlug])
}
model Category {
id String @id @default(cuid())
label String
slug String @unique
accentColor String?
createdAt DateTime @default(now())
articles Article[]
}
model Response {
id String @id @default(cuid())
message String
createdAt DateTime @default(now())
articleId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
writerId String
writer Member @relation(fields: [writerId], references: [id])
parentId String?
parent Response? @relation("ResponseReplies", fields: [parentId], references: [id])
replies Response[] @relation("ResponseReplies")
@@index([articleId])
@@index([writerId])
}
model Reaction {
id String @id @default(cuid())
createdAt DateTime @default(now())
articleId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
userId String
user Member @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([articleId, userId])
}
enum UserRole {
SUPER_ADMIN
MANAGER
CONTRIBUTOR
SUBSCRIBER
}
3.2 Prisma クライアント
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
declare global {
var __prisma: PrismaClient | undefined;
}
export const prisma =
global.__prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma;
}
四、フロントエンド統合(React + Apollo Client)
4.1 Apollo Client 設定
// src/lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
const httpLink = createHttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL || 'http://localhost:4000/graphql',
});
// Auth Link
const authLink = setContext((_, { headers }) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
// Error Link
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) => {
console.error(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
if (message === 'ログインしてください') {
// 未ログイン処理
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
});
}
if (networkError) {
console.error(`[Network error]: ${networkError}`);
}
});
// Subscription Link (WebSocket)
const wsLink = typeof window !== 'undefined'
? new GraphQLWsLink(
createClient({
url: process.env.NEXT_PUBLIC_GRAPHQL_WS_URL || 'ws://localhost:4000/graphql',
connectionParams: () => ({
Authorization: localStorage.getItem('accessToken') ? `Bearer ${localStorage.getItem('accessToken')}` : '',
}),
})
)
: null;
// Split Link
const splitLink = typeof window !== 'undefined' && wsLink
? split(({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
}, wsLink, authLink.concat(httpLink))
: authLink.concat(httpLink);
// Cache
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
articles: {
keyArgs: ['condition'],
merge(existing, incoming, { args }) {
if (!args?.paging?.page || args.paging.page === 1) {
return incoming;
}
return {
...incoming,
edges: [...(existing?.edges || []), ...incoming.edges],
};
},
},
},
},
Article: {
fields: {
hasReacted: {
merge(_, incoming) {
return incoming;
},
},
reactionCount: {
merge(_, incoming) {
return incoming;
},
},
},
},
},
});
export const apolloClient = new ApolloClient({
link: from([errorLink, splitLink]),
cache,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});
4.2 React Hooks ラップ
// src/hooks/useGraphQL.ts
import { useQuery, useMutation, useSubscription } from '@apollo/client';
import { gql } from '@apollo/client';
// ===== Query Hooks =====
// 記事リスト取得
export function useArticles(condition?: any, paging?: any, ordering?: any) {
return useQuery(GET_ARTICLES, {
variables: { condition, paging, ordering },
notifyOnNetworkStatusChange: true,
});
}
// 個別記事取得
export function useArticle(id?: string, urlSlug?: string) {
return useQuery(GET_ARTICLE, {
variables: { id, urlSlug },
skip: !id && !urlSlug,
});
}
// ログインユーザ取得
export function useCurrentUser() {
return useQuery(GET_CURRENT_USER);
}
// カテゴリリスト取得
export function useCategories() {
return useQuery(GET_CATEGORIES);
}
// ===== Mutation Hooks =====
// ログイン
export function useLogin() {
return useMutation(LOGIN, {
onCompleted: (data) => {
localStorage.setItem('accessToken', data.login.accessToken);
},
});
}
// 登録
export function useRegister() {
return useMutation(REGISTER, {
onCompleted: (data) => {
localStorage.setItem('accessToken', data.register.accessToken);
},
});
}
// 記事作成
export function useCreateArticle() {
return useMutation(CREATE_ARTICLE, {
refetchQueries: [{ query: GET_ARTICLES }],
});
}
// 記事更新
export function useUpdateArticle() {
return useMutation(UPDATE_ARTICLE, {
refetchQueries: [{ query: GET_ARTICLES }, { query: GET_ARTICLE }],
});
}
// ライク
export function useReactToArticle() {
return useMutation(REACT_TO_ARTICLE, {
optimisticResponse: {
reactToArticle: {
__typename: 'Reaction',
id: 'temp-id',
},
},
update: (cache, { data: { reactToArticle } }) => {
// キャッシュ更新
},
});
}
// ===== Subscription Hooks =====
// 新レスポンス購読
export function useResponseSubscription(articleId: string) {
return useSubscription(RESPONSE_SUBSCRIPTION, {
variables: { articleId },
});
}
// ===== GraphQL ドキュメント =====
const GET_ARTICLES = gql`
query GetArticles($condition: ArticleFilterInput, $paging: PagingInput, $ordering: OrderingInput) {
articles(condition: $condition, paging: $paging, ordering: $ordering) {
edges {
node {
id
subject
urlSlug
summary
thumbnailUrl
isPublic
accessCount
estimatedReadMinutes
registrationDate
writer {
id
username
profileImage
}
categories {
id
label
slug
}
responseCount
reactionCount
hasReacted
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalRecords
}
}
`;
const GET_ARTICLE = gql`
query GetArticle($id: ID, $urlSlug: String) {
article(id: $id, urlSlug: $urlSlug) {
id
subject
urlSlug
body
summary
thumbnailUrl
isPublic
publishedDate
accessCount
estimatedReadMinutes
registrationDate
writer {
id
username
profileImage
description
}
categories {
id
label
slug
accentColor
}
responses {
id
message
registrationDate
writer {
id
username
profileImage
}
replies {
id
message
registrationDate
writer {
id
username
profileImage
}
}
}
responseCount
reactionCount
hasReacted
}
}
`;
const GET_CURRENT_USER = gql`
query GetCurrentUser {
currentUser {
id
username
mailAddress
profileImage
description
role
}
}
`;
const GET_CATEGORIES = gql`
query GetCategories {
categories {
id
label
slug
accentColor
articleCount
}
}
`;
const LOGIN = gql`
mutation Login($mailAddress: String!, $password: String!) {
login(mailAddress: $mailAddress, password: $password) {
accessToken
member {
id
username
mailAddress
}
}
}
`;
const REGISTER = gql`
mutation Register($input: RegisterInput!) {
register(input: $input) {
accessToken
member {
id
username
mailAddress
}
}
}
`;
const CREATE_ARTICLE = gql`
mutation CreateArticle($input: CreateArticleInput!) {
createArticle(input: $input) {
id
subject
urlSlug
}
}
`;
const UPDATE_ARTICLE = gql`
mutation UpdateArticle($id: ID!, $input: UpdateArticleInput!) {
updateArticle(id: $id, input: $input) {
id
subject
urlSlug
body
}
}
`;
const REACT_TO_ARTICLE = gql`
mutation ReactToArticle($articleId: ID!) {
reactToArticle(articleId: $articleId) {
id
article {
id
reactionCount
}
}
}
`;
const RESPONSE_SUBSCRIPTION = gql`
subscription ResponseAdded($articleId: ID!) {
responseAdded(articleId: $articleId) {
id
message
registrationDate
writer {
id
username
profileImage
}
}
}
`;
4.3 React コンポーネント例
// src/components/ArticleList.tsx
import { useArticles } from '@/hooks/useGraphQL';
import { Link } from 'next/link';
import { useRouter } from 'next/navigation';
export function ArticleList() {
const router = useRouter();
const { data, loading, error, fetchMore } = useArticles(
{ isPublic: true },
{ page: 1, perPage: 10 },
{ field: 'registrationDate', direction: 'DESC' }
);
if (loading && !data) return <ArticleListSkeleton />;
if (error) return <ErrorMessage error={error} />;
const { articles } = data;
const { edges, pageInfo, totalRecords } = articles;
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">記事リスト</h1>
<span className="text-gray-500">全 {totalRecords} 件</span>
</div>
<div className="grid gap-6">
{edges.map(({ node: article }: any) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
{pageInfo.hasNextPage && (
<div className="text-center mt-8">
<button
onClick={() => {
fetchMore({
variables: {
paging: { page: Math.ceil(edges.length / 10) + 1, perPage: 10 },
},
});
}}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '読み込み中...' : 'もっと読み込む'}
</button>
</div>
)}
</div>
);
}
function ArticleCard({ article }: { article: any }) {
return (
<article className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition">
{article.thumbnailUrl && (

)}
<div className="p-6">
<div className="flex gap-2 mb-3">
{article.categories.map((category: any) => (
<Link
key={category.id}
href={`/categories/${category.slug}`}
className="px-2 py-1 text-xs bg-gray-100 rounded-full hover:bg-gray-200"
>
{category.label}
</Link>
))}
</div>
<Link href={`/articles/${article.urlSlug}`}>
<h2 className="text-xl font-bold mb-2 hover:text-blue-600">
{article.subject}
</h2>
</Link>
{article.summary && (
<p className="text-gray-600 mb-4 line-clamp-2">{article.summary}</p>
)}
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center gap-2">

<span>{article.writer.username}</span>
</div>
<div className="flex items-center gap-4">
<span>{new Date(article.registrationDate).toLocaleDateString()}</span>
<span>{article.estimatedReadMinutes} 分で読める</span>
<span>{article.reactionCount} ライク</span>
<span>{article.responseCount} コメント</span>
</div>
</div>
</div>
</article>
);
}
// src/components/CreateArticleForm.tsx
'use client';
import { useState } from 'react';
import { useCreateArticle, useCategories } from '@/hooks/useGraphQL';
import { useRouter } from 'next/navigation';
export function CreateArticleForm() {
const router = useRouter();
const [createArticle, { loading }] = useCreateArticle();
const { data: categoriesData } = useCategories();
const [formData, setFormData] = useState({
subject: '',
body: '',
thumbnailUrl: '',
categoryIds: [] as string[],
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const { data } = await createArticle({
variables: { input: formData },
});
router.push(`/articles/${data.createArticle.urlSlug}`);
} catch (error) {
console.error('記事作成失敗:', error);
}
};
const handleCategoryChange = (categoryId: string) => {
setFormData((prev) => ({
...prev,
categoryIds: prev.categoryIds.includes(categoryId)
? prev.categoryIds.filter((id) => id !== categoryId)
: [...prev.categoryIds, categoryId],
}));
};
return (
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto space-y-6">
<div>
<label htmlFor="subject" className="block mb-2 font-medium">
タイトル
</label>
<input
type="text"
id="subject"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="w-full px-4 py-2 border rounded-lg"
required
/>
</div>
<div>
<label htmlFor="body" className="block mb-2 font-medium">
本文
</label>
<textarea
id="body"
value={formData.body}
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
className="w-full px-4 py-2 border rounded-lg min-h-[300px]"
required
/>
</div>
<div>
<label htmlFor="thumbnailUrl" className="block mb-2 font-medium">
サムネイル画像 URL
</label>
<input
type="url"
id="thumbnailUrl"
value={formData.thumbnailUrl}
onChange={(e) => setFormData({ ...formData, thumbnailUrl: e.target.value })}
className="w-full px-4 py-2 border rounded-lg"
placeholder="https://..."
/>
</div>
<div>
<label className="block mb-2 font-medium">カテゴリ</label>
<div className="flex flex-wrap gap-2">
{categoriesData?.categories?.map((category: any) => (
<button
key={category.id}
type="button"
onClick={() => handleCategoryChange(category.id)}
className={`px-3 py-1 rounded-full text-sm ${
formData.categoryIds.includes(category.id)
? 'bg-blue-600 text-white'
: 'bg-gray-100 hover:bg-gray-200'
}`}
>
{category.label}
</button>
))}
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : '記事を作成'}
</button>
</form>
);
}
五、DataLoader(N+1 問題対策)
5.1 N+1 問題とその解決策
// src/loaders/index.ts
import DataLoader from 'dataloader';
import { prisma } from '../lib/prisma';
// メンバーデータローダー
export const createMemberLoader = () =>
new DataLoader<string, any>(async (ids) => {
const members = await prisma.member.findMany({
where: { id: { in: ids } },
});
const memberMap = new Map(members.map((member) => [member.id, member]));
return ids.map((id) => memberMap.get(id) || null);
});
// 記事データローダー
export const createArticleLoader = () =>
new DataLoader<string, any>(async (ids) => {
const articles = await prisma.article.findMany({
where: { id: { in: ids } },
include: { writer: true, categories: true },
});
const articleMap = new Map(articles.map((article) => [article.id, article]));
return ids.map((id) => articleMap.get(id) || null);
});
// レスポンス数ローダー
export const createResponseCountLoader = () =>
new DataLoader<string, number>(async (articleIds) => {
const counts = await prisma.response.groupBy({
by: ['articleId'],
_count: { articleId: true },
where: { articleId: { in: articleIds } },
});
const countMap = new Map(counts.map((c) => [c.articleId, c._count.articleId]));
return articleIds.map((id) => countMap.get(id) || 0);
});
// 反応数ローダー
export const createReactionCountLoader = () =>
new DataLoader<string, number>(async (articleIds) => {
const counts = await prisma.reaction.groupBy({
by: ['articleId'],
_count: { articleId: true },
where: { articleId: { in: articleIds } },
});
const countMap = new Map(counts.map((c) => [c.articleId, c._count.articleId]));
return articleIds.map((id) => countMap.get(id) || 0);
});
// Contextにローダーを注入
// src/context.ts
import { PrismaClient } from '@prisma/client';
import { createMemberLoader, createArticleLoader } from './loaders';
export interface Context {
prisma: PrismaClient;
user: Member | null;
loaders: {
member: ReturnType<typeof createMemberLoader>;
article: ReturnType<typeof createArticleLoader>;
};
}
export async function createContext(req: any): Promise<Context> {
const prisma = new PrismaClient();
// ... 認証ロジック ...
return {
prisma,
user,
loaders: {
member: createMemberLoader(),
article: createArticleLoader(),
},
};
}
5.2 Resolver でローダーを使用
// src/resolvers/article.ts
export const Article = {
writer: async (parent: any, _: any, { loaders }: Context) => {
// DataLoaderでバッチ読み込み
return loaders.member.load(parent.writerId);
},
responseCount: async (parent: any, _: any, { loaders }: Context) => {
return loaders.responseCount.load(parent.id);
},
reactionCount: async (parent: any, _: any, { loaders }: Context) => {
return loaders.reactionCount.load(parent.id);
},
};
六、ミドルウェアと権限管理
6.1 GraphQL ミドルウェア
// src/middleware/authorization.ts
import { AuthenticationError, ForbiddenError } from '../errors';
// ログイン確認
export function requireAuth(resolver: any) {
return (root: any, args: any, context: Context, info: any) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
return resolver(root, args, context, info);
};
}
// 管理者権限確認
export function requireSuperAdmin(resolver: any) {
return (root: any, args: any, context: Context, info: any) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
if (context.user.role !== 'SUPER_ADMIN') {
throw new ForbiddenError('管理者権限が必要です');
}
return resolver(root, args, context, info);
};
};
// 記事オーナー確認
export function requireArticleOwner(resolver: any) {
return async (root: any, args: any, context: Context, info: any) => {
if (!context.user) {
throw new AuthenticationError('ログインしてください');
}
const article = await context.prisma.article.findUnique({
where: { id: args.id },
});
if (!article) {
throw new UserInputError('記事が存在しません');
}
if (article.writerId !== context.user.id && context.user.role !== 'SUPER_ADMIN') {
throw new ForbiddenError('この記事を編集する権限がありません');
}
return resolver(root, args, context, info);
};
}
// フィールドレベルミドルウェア例
export const fieldMiddleware = {
Mutation: {
// 全てのMutationはログインが必要
'*': requireAuth,
// 例外
login: (resolver) => resolver,
register: (resolver) => resolver,
},
};
6.2 Schema ディレクティブ
# src/directives/auth.graphql
# 認証ディレクティブ
directive @auth on FIELD_DEFINITION | OBJECT
# 管理者ディレクティブ
directive @superAdmin on FIELD_DEFINITION | OBJECT
# レート制限ディレクティブ
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
# 非推奨ディレクティブ(組み込み)
directive @deprecated(reason: String) on FIELD_DEFINITION | ENUM_VALUE
# 使用例
type Query {
# 公開クエリ
articles: [Article!]!
# ログイン必要
currentUser: Member @auth
# 管理者必要
members: [Member!]! @superAdmin
}
type Mutation {
# ログイン必要
createArticle(input: CreateArticleInput!): Article! @auth
# レート制限
login(mailAddress: String!, password: String!): LoginResult! @rateLimit(max: 5, window: "5m")
}
七、パフォーマンス最適化
7.1 クエリ複雑度制限
// src/utils/queryComplexity.ts
import { createComplexityLimitRule } from 'graphql-query-complexity';
import { specifiedRules, validate } from 'graphql';
import { GraphQLError } from 'graphql';
const complexityRule = createComplexityLimitRule(1000, {
onCost: (cost) => {
console.log(`Query cost: ${cost}`);
},
formatErrorErrorMessage: (cost) => {
return `Query cost ${cost} exceeds maximum allowed cost of 1000`;
},
formatError: (error) => {
return new GraphQLError(
'Your query is too complex. Please simplify it.',
undefined,
undefined,
undefined,
undefined,
error
);
},
});
export { complexityRule };
7.2 レスポンスキャッシュ
// src/middleware/responseCache.ts
import { responseCachePlugin } from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
responseCachePlugin({
sessionId: (requestContext) => {
// ユーザIDベースのキャッシュ
return requestContext.context.user?.id || 'anonymous';
},
}),
],
});
// またはRedisキャッシュ
import RedisStore from 'apollo-server-plugin-response-cache-redis';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
responseCachePlugin({
store: new RedisStore({
redis: new Redis(process.env.REDIS_URL),
ttl: 60 * 60, // 1時間
}),
}),
],
});
7.3 Persisted Queries
// クライアント:APQ (Automatic Persisted Queries) 使用
import { ApolloClient, InMemoryCache, HttpLink, ApolloLink } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const persistedQueriesLink = createPersistedQueryLink({ sha256 });
const client = new ApolloClient({
link: ApolloLink.from([
persistedQueriesLink,
new HttpLink({ uri: '/graphql' }),
]),
cache: new InMemoryCache(),
});
八、事例:完全な GraphQL API
8.1 プロジェクト構成
graphql-api/
├── src/
│ ├── schema/
│ │ ├── index.ts # タイプ定義
│ │ ├── typeDefs/ # 分類タイプ定義
│ │ │ ├── member.ts
│ │ │ ├── article.ts
│ │ │ ├── response.ts
│ │ │ └── common.ts
│ │ └── directives/ # カスタムディレクティブ
│ │ └── auth.ts
│ ├── resolvers/
│ │ ├── index.ts # Resolverエントリ
│ │ ├── Query/ # Query解決
│ │ ├── Mutation/ # Mutation解決
│ │ ├── Subscription/ # Subscription解決
│ │ └── types/ # タイプ解決
│ ├── context.ts
│ ├── errors.ts
│ ├── loaders/ # DataLoader
│ ├── middleware/ # ミドルウェア
│ ├── lib/
│ │ ├── prisma.ts
│ │ ├── redis.ts
│ │ └── utils.ts
│ └── server.ts
├── prisma/
│ └── schema.prisma
├── tests/
└── package.json
8.2 完全なサーバコード
// src/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
import express from 'express';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext, Context } from './context';
import { complexityRule } from './utils/queryComplexity';
import { responseCachePlugin } from './apollo-server-plugin-response-cache';
async function main() {
const app = express();
const server = new ApolloServer<Context>({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
validationRules: [complexityRule],
plugins: [
ApolloServerPluginCacheControl({
defaultMaxAge: 60, // 60秒
calculateHttpHeaders: true,
}),
ApolloServerPluginLandingPageLocalDefault({
includeCookies: true,
}),
responseCachePlugin(),
],
});
await server.start();
app.use(
'/graphql',
cors<cors.CorsRequest>({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
}),
express.json({ limit: '10mb' }),
expressMiddleware(server, {
context: async ({ req }) => createContext(req),
formatError: (error) => {
// 本番環境エラー処理
if (process.env.NODE_ENV === 'production') {
return {
message: error.message,
path: error.path,
};
}
return error;
},
})
);
// ヘルスチェック
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`
🚀 GraphQL Server ready!
📍 Local: http://localhost:${PORT}/graphql
📍 Studio: http://studio.apollographql.com/sandbox
🌐 Environment: ${process.env.NODE_ENV || 'development'}
`);
});
}
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
九、テスト
9.1 Jest テスト設定
// tests/setup.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
beforeAll(async () => {
// テストデータクリーンアップと投入
await prisma.response.deleteMany();
await prisma.reaction.deleteMany();
await prisma.article.deleteMany();
await prisma.member.deleteMany();
// テストユーザ作成
await prisma.member.create({
data: {
id: 'test-user-1',
mailAddress: 'test@example.com',
username: 'Test User',
passwordHash: '$2a$10$...',
role: 'CONTRIBUTOR',
},
});
});
afterAll(async () => {
await prisma.$disconnect();
});
9.2 GraphQL テスト
// tests/graphql.test.ts
import { ApolloServer } from '@apollo/server';
import { typeDefs } from '../src/schema';
import { resolvers } from '../src/resolvers';
import { createContext } from '../src/context';
describe('GraphQL API', () => {
let server: ApolloServer;
let testContext: { prisma: PrismaClient; user: any };
beforeAll(async () => {
testContext = await createTestContext();
server = new ApolloServer({
typeDefs,
resolvers,
});
});
afterAll(async () => {
await server.stop();
});
describe('Query', () => {
it('should return articles', async () => {
const query = `
query GetArticles {
articles {
edges {
node {
id
subject
writer {
username
}
}
}
}
}
`;
const response = await server.executeOperation(
{ query },
{ contextValue: testContext }
);
expect(response.body.kind).toBe('single');
if (response.body.kind === 'single') {
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.articles.edges).toBeDefined();
}
});
});
describe('Mutation', () => {
it('should create an article', async () => {
const mutation = `
mutation CreateArticle($input: CreateArticleInput!) {
createArticle(input: $input) {
id
subject
urlSlug
}
}
`;
const response = await server.executeOperation(
{
query: mutation,
variables: {
input: {
subject: 'Test Article',
body: 'Test content',
},
},
},
{ contextValue: testContext }
);
expect(response.body.kind).toBe('single');
if (response.body.kind === 'single') {
expect(response.body.singleResult.data?.createArticle.subject).toBe('Test Article');
}
});
});
});
十、おわりに
10.1 GraphQL コア知識ポイント
10.2 GraphQL と REST の比較
| 観点 | GraphQL | REST |
|---|---|---|
| データ取得 | 単一リクエスト、精緻なフィールド | 複数リクエストまたは過剩データ |
| タイプシステム | 強い型付けSchema | 統一型なし |
| バージョン管理 | フィールドで進化 | URLバージョン /v1/ |
| ドキュメント | 自己ドキュメント化 | Swagger必要 |
| キャッシュ | 手動管理 | HTTPキャッシュ |
| リアルタイム | Subscription | WebSocket |
| 学習曲線 | やや険しい | 緩やか |
| 適応シナリオ | マルチクライアントAPI、柔軟クエリ | シンプルなCRUD |
10.3 ベストプラクティス
| プラクティス | 説明 |
|---|---|
| Schema First | Schemaを先に設計し、実装 |
| 命名規則 | 統一された命名スタイル |
| ページネーション | Connectionパターン使用 |
| エラーハンドリング | 統一エラー形式 |
| 認証 | JWT + ミドルウェア |
| キャッシング | DataLoader + Redis |
| レート制限 | クエリ複雑度制限 |
10.4 学習パス
| 段階 | コンテンツ | リソース |
|---|---|---|
| 入門 | Schema、Query、Mutation | Apolloドキュメント |
| 上級 | Resolver、Context、DataLoader | GraphQL仕様 |
| 実践 | Apollo Server + Prisma | GitHubサンプル |
| 本番 | パフォーマンス最適化、セキュリティ | Apolloベストプラクティス |
10.5 おすすめリソース
| 種類 | リソース | 説明 |
|---|---|---|
| 公式ドキュメント | graphql.org | GraphQL仕様 |
| Apollo | apollographql.com | GraphQLプラットフォーム |
| Prisma | prisma.io | データベースORM |
| Yoga | the-guild.dev/graphql/yoga | 軽量サーバ |
| GraphQL Code Generator | the-guild.dev/graphql/codegen | コード生成 |
本稿は Apollo Server 4 + Prisma + React Apollo Client を基に作成されています。