GraphQL 実装 完全ガイド - 基礎から本番環境まで

はじめに

課題: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 && (
        ![{article.subject}]({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">
            ![{article.writer.username}]({article.writer.profileImage)
            <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 を基に作成されています。

タグ: GraphQL apollo-server apollo-client Prisma React

8月29日 04:58 投稿