Next.js & Nest.js & Prisma を利用してGraphQLを使ったアプリケーションを作成する

作成したアプリケーション

GraphQLの学習用のサンプルアプリケーションです。

フロントエンドは Next.js & Apollo Client 、サーバーサイドは Nest.js & Prisma を利用して作成しています。

主な機能

  • ログイン/ログアウト
  • 会員登録
  • 投稿一覧
  • 投稿詳細
  • マイページ(一覧・登録・更新・削除)

利用している技術

  • Next.js (React16)
  • Redux Tool Kit
  • Typescript
  • Apollo Client
  • Nest.js
  • Prisma
  • GraphQL

Apollo Client を利用した認証機能の実装

Client側 では、Http Header の authorization に token を設定してサーバー側に渡すようにしています。

utilities/api.ts

import {
  ApolloClient,
  HttpLink,
  ApolloLink,
  InMemoryCache,
  concat,
} from '@apollo/client'
import Env from '../common/env/'

const httpLink = new HttpLink({ uri: Env.externalEndpointUrl })

const authMiddleware = new ApolloLink((operation, forward) => {
  // add the authorization to the headers
  operation.setContext(({ headers = {} }) => ({
    headers: {
      ...headers,
      authorization: 'Bearer ' + localStorage.getItem('token') || null,
    },
  }))
  return forward(operation)
})

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: concat(authMiddleware, httpLink),
})

export default client

Server側 では、Http Header の authorization から token を取得して JWT で検証しています。有効なトークンの場合のみ、GraphQLの処理が実行されるようにしています。

auth.service.ts

import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma.service';
import { AuthenticationError } from 'apollo-server-core';
import { ApolloServer, UserInputError } from 'apollo-server-express';
import { User } from './user/models/user.model';
import * as bcrypt from 'bcrypt';
import * as jwt from 'jsonwebtoken';

type JWT_TOKEN = {
  id: number
  email: string
}

const initial = {
  secret: 'my_secret',
  expiresIn: '24h'
}

@Injectable()
export class AuthService {
  constructor(
      private prisma: PrismaService
  ) {}

  // パスワードをハッシュ化する
  public generatePasswordHash = async (password: string) => {
      const saltRounds = 10;
      return await bcrypt.hash(password, saltRounds);
  }

  // パスワードをチェックする
  public validatePassword = async (user: User, password: string) => {
      return await bcrypt.compare(password, user.password);
  }

  // ユーザトークンを生成する
  public createToken = async (user: User) => {
      const { id, email }: JWT_TOKEN = user;
      return await jwt.sign({ id, email }, initial.secret, { expiresIn: initial.expiresIn });
  }

  // JWTトークンを公開鍵で検証する
  public verifyToken = async (token: string) => {
      return await jwt.verify(token, 'my_secret', async (err: any, decoded: JWT_TOKEN) => {
          if (err) {
              throw new AuthenticationError('Invalid password. ' + err);
          } else {
              // OK
              console.log(`OK: decoded.id=[${decoded.id}], email=[${decoded.email}]`);

              const user = await this.prisma.user.findUnique({where: {email: decoded.email}});
              if (!user) throw new UserInputError('No user found with this login credentials.');

              return user
          }
      })
  }
}

auth/guards/auth.guard.ts

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { AuthService } from 'src/auth.service';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthenticationError } from 'apollo-server-core';

@Injectable()
export class GqlAuthGuard implements CanActivate {
  constructor(
    private auth: AuthService
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
      const req = this.getRequest(context);

      // headerからtokenを取得
      const authHeader = req.headers.authorization as string;
      if (!authHeader) {
          throw new AuthenticationError('Authorization header not found.');
      }
      const [type, token] = authHeader.split(' ');
      if (type !== 'Bearer') {
          throw new AuthenticationError(`Authentication type \\'Bearer\\' required. Found \\'${type}\\'`);
      }

      // JWTトークンを公開鍵で検証する
      await this.auth.verifyToken(token);
      
      return true
  }
    
  private getRequest = (context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }

}

post/post.resolver.ts

@Mutation(() => Post)
@UseGuards(GqlAuthGuard)
async createPost(
      @Args('title') title: string,
      @Args('description') description: string,
      @Args('photo') photo: string,
      @Args('authorId') authorId: number,
) {
  return this.prisma.post.create({ data: { title, description, photo, authorId } });
}

開発環境の構築

ソースコード

MIT ライセンスにてコードを公開していますのでご利用下さいませ。

https://github.com/isystk/nextjs-nestjs-graphql

ディレクトリ構造

.
├── docker/
│   ├── mysql/
│   ├── nestjs/
│   │   └── app/ (Nest.js のソースコード)
│   │       ├── prisma/
│   │       ├── src/
│   │       └── test/
│   └── docker-compose.yml
├── src/ (Next.js のソースコード)
│   ├── @types/
│   ├── auth/
│   ├── common/
│   ├── components/
│   ├── pages/
│   ├── store/
│   ├── styles/
│   └── utilities/
└── test/

操作用シェルスクリプトの使い方

Usage:
  dc.sh [command] [<options>]

Options:
  stats|st                 Dockerコンテナの状態を表示します。
  init                     Dockerコンテナ・イメージ・生成ファイルの状態を初期化します。
  start                    すべてのDaemonを起動します。
  stop                     すべてのDaemonを停止します。
  mysql login              MySQLデータベースにログインします。
  mysql export <PAHT>      MySQLデータベースのdumpファイルをエクスポートします。
  mysql import <PAHT>      MySQLデータベースにdumpファイルをインポートします。
  mysql restart            MySQLデータベースを再起動します。
  server login             Nest.jsのサーバーにログインします。
  server start             Nest.jsを起動します。
  prisma studio            Prisma Studio を起動します。
  prisma migrate           Prisma の Migrate を実行します。
  --version, -v     バージョンを表示します。
  --help, -h        ヘルプを表示します。

起動方法

# 下準備
$ ./dc.sh init

# Dockerを起動する
$ ./dc.sh start

# データベースとPHPが立ち上がるまで少し待ちます。(初回は5分程度)

# MySQLにログインしてみる
$ ./dc.sh mysql login

# DBのマイグレーション
$ ./dc.sh prisma migrate

# サーバーの起動
$ ./dc.sh server start

# Dockerを停止する場合
$ ./dc.sh stop

GraphQL

http://localhost:9000/graphql

# 以下のように必要なフィールドのみを指定してデータを取得できます。

query { 
  getPosts {
    id
    title
    description
    photo
    createdAt
    updatedAt
    authorId
  }
}

Prisma

http://localhost:5555

$ ./dc.sh server prisma

コメントを残す

入力エリアすべてが必須項目です。メールアドレスが公開されることはありません。

内容をご確認の上、送信してください。