api

GraphQL実装ガイド - Apollo ServerとTypeScriptで型安全なAPI構築

2025.12.02

GraphQLの基本概念

GraphQLは、Facebookが開発したAPIのためのクエリ言語です。クライアントが必要なデータを正確に指定でき、オーバーフェッチ・アンダーフェッチを防ぎます。

REST API vs GraphQL

REST API(3リクエスト必要):

sequenceDiagram
    participant Client
    participant Server
    Client->>Server: GET /users/1
    Server-->>Client: user data
    Client->>Server: GET /users/1/posts
    Server-->>Client: posts data
    Client->>Server: GET /users/1/followers
    Server-->>Client: followers data

GraphQL(1リクエストで完結):

sequenceDiagram
    participant Client
    participant Server
    Client->>Server: POST /graphql<br/>query { user(id: 1) { name, posts, followers } }
    Server-->>Client: 全データを一度に返却

セットアップ

# 依存関係のインストール
npm install @apollo/server graphql graphql-tag
npm install -D typescript @types/node

# コード生成ツール
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

プロジェクト構成

src/
├── graphql/
│   ├── schema/
│   │   ├── user.graphql
│   │   ├── post.graphql
│   │   └── index.ts
│   ├── resolvers/
│   │   ├── user.resolver.ts
│   │   ├── post.resolver.ts
│   │   └── index.ts
│   ├── context.ts
│   └── server.ts
├── dataloaders/
│   ├── userLoader.ts
│   └── postLoader.ts
├── services/
│   ├── userService.ts
│   └── postService.ts
└── index.ts

スキーマ定義

# src/graphql/schema/user.graphql
type User {
  id: ID!
  email: String!
  name: String!
  avatar: String
  posts: [Post!]!
  followers: [User!]!
  following: [User!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

input CreateUserInput {
  email: String!
  name: String!
  password: String!
}

input UpdateUserInput {
  name: String
  avatar: String
}

type Query {
  me: User
  user(id: ID!): User
  users(limit: Int = 10, offset: Int = 0): [User!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
  followUser(userId: ID!): User!
  unfollowUser(userId: ID!): User!
}

# src/graphql/schema/post.graphql
type Post {
  id: ID!
  title: String!
  content: String!
  published: Boolean!
  author: User!
  comments: [Comment!]!
  likes: Int!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Comment {
  id: ID!
  content: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

input CreatePostInput {
  title: String!
  content: String!
  published: Boolean = false
}

input UpdatePostInput {
  title: String
  content: String
  published: Boolean
}

extend type Query {
  post(id: ID!): Post
  posts(
    limit: Int = 10
    offset: Int = 0
    authorId: ID
    published: Boolean
  ): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

extend type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  likePost(postId: ID!): Post!
  addComment(postId: ID!, content: String!): Comment!
}

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

コード生成設定

# codegen.yml
schema: "src/graphql/schema/**/*.graphql"
generates:
  src/graphql/__generated__/types.ts:
    plugins:
      - typescript
      - typescript-resolvers
    config:
      contextType: ../context#GraphQLContext
      mappers:
        User: ../../models/User#UserModel
        Post: ../../models/Post#PostModel
      scalars:
        DateTime: Date
# コード生成実行
npx graphql-codegen

リゾルバー実装

// src/graphql/resolvers/user.resolver.ts
import { Resolvers } from '../__generated__/types';
import { GraphQLContext } from '../context';
import { UserService } from '../../services/userService';

export const userResolvers: Resolvers<GraphQLContext> = {
  Query: {
    me: async (_, __, context) => {
      if (!context.userId) return null;
      return context.loaders.user.load(context.userId);
    },

    user: async (_, { id }, context) => {
      return context.loaders.user.load(id);
    },

    users: async (_, { limit, offset }, context) => {
      return context.services.user.findMany({ limit, offset });
    },
  },

  Mutation: {
    createUser: async (_, { input }, context) => {
      return context.services.user.create(input);
    },

    updateUser: async (_, { id, input }, context) => {
      context.assertAuthenticated();
      context.assertOwnership(id);
      return context.services.user.update(id, input);
    },

    deleteUser: async (_, { id }, context) => {
      context.assertAuthenticated();
      context.assertOwnership(id);
      await context.services.user.delete(id);
      return true;
    },

    followUser: async (_, { userId }, context) => {
      context.assertAuthenticated();
      return context.services.user.follow(context.userId!, userId);
    },

    unfollowUser: async (_, { userId }, context) => {
      context.assertAuthenticated();
      return context.services.user.unfollow(context.userId!, userId);
    },
  },

  User: {
    posts: async (user, _, context) => {
      return context.loaders.postsByAuthor.load(user.id);
    },

    followers: async (user, _, context) => {
      return context.loaders.followers.load(user.id);
    },

    following: async (user, _, context) => {
      return context.loaders.following.load(user.id);
    },
  },
};
// src/graphql/resolvers/post.resolver.ts
import { Resolvers } from '../__generated__/types';
import { GraphQLContext } from '../context';

export const postResolvers: Resolvers<GraphQLContext> = {
  Query: {
    post: async (_, { id }, context) => {
      return context.loaders.post.load(id);
    },

    posts: async (_, args, context) => {
      const { limit, offset, authorId, published } = args;

      const [posts, totalCount] = await Promise.all([
        context.services.post.findMany({
          limit: limit + 1, // 次ページ存在確認用
          offset,
          authorId,
          published,
        }),
        context.services.post.count({ authorId, published }),
      ]);

      const hasNextPage = posts.length > limit;
      const edges = posts.slice(0, limit).map((post) => ({
        node: post,
        cursor: Buffer.from(`post:${post.id}`).toString('base64'),
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: offset > 0,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
        totalCount,
      };
    },
  },

  Mutation: {
    createPost: async (_, { input }, context) => {
      context.assertAuthenticated();
      const post = await context.services.post.create({
        ...input,
        authorId: context.userId!,
      });

      // サブスクリプション通知
      context.pubsub.publish('POST_CREATED', { postCreated: post });

      return post;
    },

    updatePost: async (_, { id, input }, context) => {
      context.assertAuthenticated();
      const post = await context.loaders.post.load(id);
      context.assertOwnership(post?.authorId);
      return context.services.post.update(id, input);
    },

    likePost: async (_, { postId }, context) => {
      context.assertAuthenticated();
      return context.services.post.like(postId, context.userId!);
    },

    addComment: async (_, { postId, content }, context) => {
      context.assertAuthenticated();
      const comment = await context.services.post.addComment({
        postId,
        content,
        authorId: context.userId!,
      });

      context.pubsub.publish(`COMMENT_ADDED_${postId}`, {
        commentAdded: comment,
      });

      return comment;
    },
  },

  Subscription: {
    postCreated: {
      subscribe: (_, __, context) => {
        return context.pubsub.asyncIterator(['POST_CREATED']);
      },
    },

    commentAdded: {
      subscribe: (_, { postId }, context) => {
        return context.pubsub.asyncIterator([`COMMENT_ADDED_${postId}`]);
      },
    },
  },

  Post: {
    author: async (post, _, context) => {
      return context.loaders.user.load(post.authorId);
    },

    comments: async (post, _, context) => {
      return context.loaders.commentsByPost.load(post.id);
    },
  },
};

DataLoaderでN+1問題を解決

// src/dataloaders/userLoader.ts
import DataLoader from 'dataloader';
import { db } from '../db';
import { UserModel } from '../models/User';

export const createUserLoader = () =>
  new DataLoader<string, UserModel | null>(async (ids) => {
    const users = await db.user.findMany({
      where: { id: { in: ids as string[] } },
    });

    const userMap = new Map(users.map((user) => [user.id, user]));
    return ids.map((id) => userMap.get(id) ?? null);
  });

export const createFollowersLoader = () =>
  new DataLoader<string, UserModel[]>(async (userIds) => {
    const follows = await db.follow.findMany({
      where: { followingId: { in: userIds as string[] } },
      include: { follower: true },
    });

    const followerMap = new Map<string, UserModel[]>();
    for (const follow of follows) {
      const followers = followerMap.get(follow.followingId) ?? [];
      followers.push(follow.follower);
      followerMap.set(follow.followingId, followers);
    }

    return userIds.map((id) => followerMap.get(id) ?? []);
  });

// src/dataloaders/postLoader.ts
export const createPostLoader = () =>
  new DataLoader<string, PostModel | null>(async (ids) => {
    const posts = await db.post.findMany({
      where: { id: { in: ids as string[] } },
    });

    const postMap = new Map(posts.map((post) => [post.id, post]));
    return ids.map((id) => postMap.get(id) ?? null);
  });

export const createPostsByAuthorLoader = () =>
  new DataLoader<string, PostModel[]>(async (authorIds) => {
    const posts = await db.post.findMany({
      where: { authorId: { in: authorIds as string[] } },
      orderBy: { createdAt: 'desc' },
    });

    const postMap = new Map<string, PostModel[]>();
    for (const post of posts) {
      const authorPosts = postMap.get(post.authorId) ?? [];
      authorPosts.push(post);
      postMap.set(post.authorId, authorPosts);
    }

    return authorIds.map((id) => postMap.get(id) ?? []);
  });

Context設定

// src/graphql/context.ts
import { PubSub } from 'graphql-subscriptions';
import { createUserLoader, createFollowersLoader } from '../dataloaders/userLoader';
import { createPostLoader, createPostsByAuthorLoader } from '../dataloaders/postLoader';
import { UserService } from '../services/userService';
import { PostService } from '../services/postService';
import { AuthenticationError, ForbiddenError } from './errors';

export interface GraphQLContext {
  userId: string | null;
  loaders: {
    user: ReturnType<typeof createUserLoader>;
    post: ReturnType<typeof createPostLoader>;
    postsByAuthor: ReturnType<typeof createPostsByAuthorLoader>;
    followers: ReturnType<typeof createFollowersLoader>;
    following: ReturnType<typeof createFollowingLoader>;
    commentsByPost: ReturnType<typeof createCommentsByPostLoader>;
  };
  services: {
    user: UserService;
    post: PostService;
  };
  pubsub: PubSub;
  assertAuthenticated: () => void;
  assertOwnership: (ownerId: string | null | undefined) => void;
}

const pubsub = new PubSub();

export const createContext = async ({ req }): Promise<GraphQLContext> => {
  // 認証トークンからユーザーID取得
  const token = req.headers.authorization?.replace('Bearer ', '');
  const userId = token ? await verifyToken(token) : null;

  return {
    userId,
    loaders: {
      user: createUserLoader(),
      post: createPostLoader(),
      postsByAuthor: createPostsByAuthorLoader(),
      followers: createFollowersLoader(),
      following: createFollowingLoader(),
      commentsByPost: createCommentsByPostLoader(),
    },
    services: {
      user: new UserService(),
      post: new PostService(),
    },
    pubsub,
    assertAuthenticated() {
      if (!this.userId) {
        throw new AuthenticationError('Authentication required');
      }
    },
    assertOwnership(ownerId) {
      if (ownerId !== this.userId) {
        throw new ForbiddenError('Not authorized');
      }
    },
  };
};

サーバー設定

// src/graphql/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext, GraphQLContext } from './context';

async function startServer() {
  const app = express();
  const httpServer = http.createServer(app);

  // スキーマ作成
  const schema = makeExecutableSchema({ typeDefs, resolvers });

  // WebSocket設定(サブスクリプション用)
  const wsServer = new WebSocketServer({
    server: httpServer,
    path: '/graphql',
  });

  const serverCleanup = useServer(
    {
      schema,
      context: async (ctx) => createContext({ req: ctx.extra.request }),
    },
    wsServer
  );

  // Apollo Server設定
  const server = new ApolloServer<GraphQLContext>({
    schema,
    plugins: [
      ApolloServerPluginDrainHttpServer({ httpServer }),
      {
        async serverWillStart() {
          return {
            async drainServer() {
              await serverCleanup.dispose();
            },
          };
        },
      },
    ],
  });

  await server.start();

  app.use(
    '/graphql',
    cors<cors.CorsRequest>(),
    express.json(),
    expressMiddleware(server, {
      context: createContext,
    })
  );

  const PORT = process.env.PORT || 4000;
  httpServer.listen(PORT, () => {
    console.log(`Server ready at http://localhost:${PORT}/graphql`);
    console.log(`Subscriptions ready at ws://localhost:${PORT}/graphql`);
  });
}

startServer();

エラーハンドリング

// src/graphql/errors.ts
import { GraphQLError } from 'graphql';

export class AuthenticationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
}

export class ForbiddenError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: 'FORBIDDEN' },
    });
  }
}

export class ValidationError extends GraphQLError {
  constructor(message: string, field?: string) {
    super(message, {
      extensions: {
        code: 'BAD_USER_INPUT',
        field,
      },
    });
  }
}

export class NotFoundError extends GraphQLError {
  constructor(resource: string, id: string) {
    super(`${resource} not found: ${id}`, {
      extensions: { code: 'NOT_FOUND', resource, id },
    });
  }
}

クライアント側の利用例

// クライアント側コード例
import { gql, useQuery, useMutation, useSubscription } from '@apollo/client';

const GET_POSTS = gql`
  query GetPosts($limit: Int, $offset: Int) {
    posts(limit: $limit, offset: $offset) {
      edges {
        node {
          id
          title
          content
          author {
            id
            name
            avatar
          }
          createdAt
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount
    }
  }
`;

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      title
      content
    }
  }
`;

const POST_CREATED = gql`
  subscription OnPostCreated {
    postCreated {
      id
      title
      author {
        name
      }
    }
  }
`;

// React Hooksで利用
function PostList() {
  const { data, loading, fetchMore } = useQuery(GET_POSTS, {
    variables: { limit: 10, offset: 0 },
  });

  const [createPost] = useMutation(CREATE_POST, {
    refetchQueries: ['GetPosts'],
  });

  useSubscription(POST_CREATED, {
    onData: ({ data }) => {
      console.log('New post:', data.data.postCreated);
    },
  });

  // ...
}

参考リンク

← 一覧に戻る