経緯
今年の4月からフロントエンドエンジニアとして就職することになったのですが、その業務の中で、アルバイト課題向けに作成されていた既存のWebAPIを改善し、新たなバージョンを開発するお仕事を行う機会がありました。
WebAPI開発といえば「Express(Node.js)」や「Flask(Python)」辺りが有名どころなのかなという印象だったのですが、今回の開発で選定されたのは「NestJS」と呼ばれるフレームワークでした。
そういうわけでAPI開発の業務を進めるうちに、
「趣味開発でAPIが作れるようになれたら強そうだよな〜!」
的なことを考え始め、実際に趣味の一環として作ってみることにしました。
今回の記事では、趣味開発で初めてWebAPIを作り、実際にデプロイまで持って行った際に自分が学んだことをまとめてみようかな、という感じです。
アプリの構想
今回は日記帳アプリの開発を想定したAPIを構築します。
要件
- ユーザーのログイン・新規登録機能(Passportによる認証)
- ユーザーごとの日記帳・タグを作成できる
- 日記帳には複数のタグを付けることができる
- タグを使った日記帳の絞り込み機能
また、今回のAPIはこのリポジトリで作成してます。
技術選定・紹介
NestJS
NestJSは、Node.jsの環境で動作するサーバサイドアプリケーション開発用のフレームワークです。
特徴としては以下のような点があります。
- 内部的にはExpressでの実装となっている
- NestCLIと呼ばれるインターフェイスツールを用意しており、環境構築が簡単
- Node.jsでのアーキテクチャー的な問題を解決するために開発された
後ほど紹介しますが、NestCLIを利用することで容易に環境構築を行うことができ、スムーズに開発を進められるのがめちゃくちゃ良きって感じでした。
Prisma
Prismaは、「Next-generation Node.js and TypeScript ORM」を謳っている、ORM(Object Relational Mapping)ライブラリです。
以下のような特徴があります。
- ユーザーが定義したスキーマを使って、sqlファイルを生成する
- MySQLやPostgreSQL・MongoDBなど、多くのデータベースに対応している
- Prisma Clientによってスキーマからクエリが生成され、型安全でありながら手軽にデータの取得・更新といった操作を行える
PrismaはNestJSと一緒に使われるイメージがあったため今回技術選定しましたが、データ操作が非常に簡単かつ型安全で堅牢に開発を行うことができ、手軽さと安心感をうまく両立されてる印象でした。
Docker
Dockerはコンテナ仮想化を用いて、コンテナでアプリケーションを実行したり、作成したコンテナを配布したりすることができるプラットフォームです。
今回の開発ではデータベースにMySQLを使うため、MySQL用のイメージを使ってDBをDockerのコンテナ内に作成する形にしました。
デプロイ時にも必要となるため、選定しました。
GraphQL
GraphQLはAPI用のクエリ言語です。
RESTと比べ、以下のような特徴があります。
- エンドポイントが一つでOK
- クライアント側で取得したいデータの構造を定義でき、それに合わせてサーバー側からデータが返される
- GraphQL Playgroundを用いてクエリのテストを行える
- Get・Postといったメソッドが存在しない
業務の方ではREST APIを構築することになっていましたが、今回はGraphQLを使ってみたかったので選定しました。
Passport
[Passport公式](https://www.passportjs.org/)
Passportは認証のためのNodeJSミドルウェアです。
認証についての知見がなかったので、勉強のために選定してみました。
環境構築
プロジェクト作成
まずはNestJSプロジェクトを作っていきます。
NestCLIが必要になりますので、npmを使ってグローバルインストールします。
npm i -g @nestjs/cli
nest new project-name
これだけで、ディレクトリ直下にNestJSプロジェクトが作成されます。
(デフォルトで生成されるのはTypeScriptのプロジェクトなので、JavaScript版を利用したい場合はコチラ)
git clone https://github.com/nestjs/typescript-starter.git project-name
cd project-name
npm install
npm run start
MySQLの準備
今回の開発ではDockerを使って、MySQLのサーバーを立てるためのコンテナを作成しておきます。
MySQLに登録するユーザー情報などは.envファイルに記述しておきます。
version: '3.7'
services:
db:
image: mysql:8.0.28
container_name: nest-practice-db
environment:
MYSQL_ROOT_HOST: '%'
MYSQL_ROOT_PASSWORD: '${MYSQL_ROOT_PASSWORD}'
MYSQL_USER: '${MYSQL_USER}'
MYSQL_PASSWORD: '${MYSQL_PASSWORD}'
MYSQL_DATABASE: '${MYSQL_DATABASE}'
TZ: Asia/Tokyo
ports:
- '${MYSQL_PORT}:3306'
volumes:
# ホスト:コンテナ
# 初期データを投入するSQLが格納されているdir
- ./docker/db/sql:/docker-entrypoint-initdb.d
# DBのデータの実体
- ./docker/db/mysql:/var/lib/mysql
# 設定ファイル
- ./docker/db/my.cnf:/etc/mysql/my.cnf
- ./mysql-files:/var/lib/mysql-files
# データベース関連
MYSQL_PORT=3306
MYSQL_ROOT_PASSWORD=root-password
MYSQL_USER=user
MYSQL_PASSWORD=password
MYSQL_DATABASE=nest-practice
DATABASE_URL=mysql://user:password@localhost:3306/nest-practice
今後envファイルを参照することがちまちま出てきますので、そのために「dotenv」を追加しておきます。
npm install dotenv
また、MySQLの起動時にいくつか必要なファイルがありますので、docker/dbディレクトリ内に、下記のように定義しておきます。
mysql=( mysql --protocol=socket -uroot -p"${MYSQL_ROOT_PASSWORD}" )
"${mysql[@]}" <<-EOSQL
GRANT ALL PRIVILEGES ON *.* TO '${MYSQL_USER}'@'%' WITH GRANT OPTION;
EOSQL
[mysqld]
default-authentication-plugin=mysql_native_password
character-set-server=utf8mb4
[mysql]
default-character-set=utf8mb4
[client]
default-character-set=utf8mb4
Dockerの起動
今後DB関連の処理を実行する場合、Dockerの実行が必要不可欠となります。
今回の場合だと、以下のコマンドでDockerのDBを実行できます。
chmod 775 ./docker/db/sql/init.sh && docker-compose --env-file ./.env -f ./docker-compose.yml up -d
Prismaのセットアップ
続いて、Prismaを利用するためのセットアップを行います。
npm install --save-dev prisma
npx prisma init
この時点で、prismaというフォルダが作成されます。
prismaフォルダの中にschema.prismaがあり、それを編集していくことで、DBのモデルを作成していきます。
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
モデルの作成
早速モデルを作成していきます。今回の要件では、以下のようなモデルを考えます。
Userモデル
- id (int 生成時に自動的にインクリメントされる)
- name (string)
- email (string 重複なし)
- password (string 暗号化を行う)
Tagモデル
- id (int 生成時に自動的にインクリメントされる)
- name (string)
- user_id (int タグを登録したユーザーのid)
- diaries (Tagに紐付けされている複数のDiary)
Diaryモデル
- id (int 生成時に自動的にインクリメントされる)
- title
- detail
- created_at (Date 作成時点の日付)
- user_id (int Diaryを作成したユーザーのid)
- Tags (Diaryに紐付けされている複数のTag)
以上を考慮すると、schema.prismaでは以下のように記述を行えます。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String
Tag Tag[]
Diary Diary[]
}
model Tag {
id Int @id @default(autoincrement())
name String
user User @relation(fields: [user_id], references: [id])
user_id Int
diaries Diary[]
}
model Diary {
id Int @id @default(autoincrement())
title String
detail String
user User @relation(fields: [user_id], references: [id])
user_id Int
created_at DateTime @default(now())
tags Tag[]
}
このようにした状態で、マイグレーションを実施してみます。
prismaでのマイグレーションは、以下のコマンドで実行します。
dotenv .env prisma migarte dev
マイグレーションを実施すると、prismaフォルダの直下にmigrationsフォルダが作成されます。その中に、各マイグレーション時の情報をsqlファイルで保存しておく、という形です。
(任意)Seedの作成
PrismaにもSeedの機能があります。Seedとは、動作確認のために初期データを投入する機能です。
以下のようにpackage.jsonを編集することで、PrismaのSeedの設定を行います。
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
続いて、seed用のファイル(seed.ts)をprismaフォルダ直下に作成します。
import { PrismaClient, User, Tag, Diary } from '@prisma/client';
import { CreateDiaryInput } from 'src/auth/dto/create-diary.input';
const prisma = new PrismaClient();
// モデル投入用のデータ定義
const user_data: User[] = [
{
id: 1,
name: 'maru1',
email: 'test1@test.com',
password: 'test1',
},
{
id: 2,
name: 'maru2',
email: 'test2@test.com',
password: 'test2',
},
{
id: 3,
name: 'maru3',
email: 'test3@test.com',
password: 'test3',
},
];
const tag_data: Tag[] = [
{ id: 1, name: '勉強', user_id: 1 },
{ id: 2, name: '仕事', user_id: 2 },
{ id: 3, name: '趣味', user_id: 3 },
];
const diary_data: Diary[] = [
{
id: 1,
title: '今日の振り返り',
detail: '今日はprismaの勉強',
user_id: 1,
created_at: new Date(),
},
{
id: 2,
title: 'お仕事めんどくさい',
detail: '残業なんてさせんじゃないよーーー',
user_id: 2,
created_at: new Date(),
},
{
id: 3,
title: '曲作った',
detail: 'トランス系統の曲作ったよ',
user_id: 3,
created_at: new Date(),
},
];
const seedingUser = async () => {
const users = [];
for (const user of user_data) {
const create_users = prisma.user.create({
data: user,
});
users.push(create_users);
}
return await prisma.$transaction(users);
};
const seedingTag = async () => {
const tags = [];
for (const tag of tag_data) {
const create_tags = prisma.tag.create({
data: tag,
});
tags.push(create_tags);
}
return await prisma.$transaction(tags);
};
const seedingDiary = async () => {
const diaries = [];
for (const diary of diary_data) {
const create_diaries = prisma.diary.create({
data: {
...diary,
tags: {
connect: [{ id: 1 }],
},
},
});
diaries.push(create_diaries);
}
return await prisma.$transaction(diaries);
};
const main = async () => {
console.log(`Start seeding ...`);
console.log('Seeding User...');
await seedingUser();
console.log('Seeding User finished.');
console.log('Seeding Tag...');
await seedingTag();
console.log('Seeding Tag finished.');
console.log('Seeding Diary...');
await seedingDiary();
console.log('Seeding Diary finished.');
console.log(`Seeding finished.`);
};
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
seedingを行うためのファイルも、prismaを使ったデータ操作と同じように処理を記述します。
この状態で以下のコマンドを実行すると、seedingが行われます。
dotenv -e ./env prisma db seed
seedingを行なった状態でもう一度seedingを行う場合、ユニーク制約などでエラーが発生することがあるため、その場合は以下のコマンドを実行し、リセットを行います。
dotenv -e ./env prisma migrate reset
もしSeedの実装をした場合、ビルド後の生成フォルダにSeedを含める必要はないかと思いますので、コチラはビルド対象から外しておきましょう。
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test","dist", "**/*spec.ts","prisma/seed.ts"]
}
GraphQLの導入
ここからは、NestJSでGraphQLを扱うための準備をしていきます。
まずは必要なパッケージを追加します。
npm install @nestjs/graphql @nestjs/apollo graphql apollo-server-express
続いて、app.module.tsを下記のように修正します。
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaService } from './prisma.service';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
context: ({ req }) => ({ req }),
driver: ApolloDriver, //apolloのDriverを指定
autoSchemaFile: 'src/schema.gql', //スキーマの生成場所
}),
],
controllers: [AppController],
providers: [AppService, PrismaService],
})
export class AppModule {}
Driverの指定についてですが、NestJS v10からの仕様上、Apollo以外のパッケージを利用することを考慮して、Driverを明示することを義務付けられています。
GraphQLには、「コードファースト」と「スキーマファースト」と呼ばれる二つの開発手法が存在します。
- コードファースト
- TypeScriptコード中にGraphQLライブラリが認識できるようなスニペットを埋め込み、それを利用してスキーマを生成する手法
- スキーマファースト
- 最初にGraphQlスキーマを作成し、それを絶対としてコードを記述し開発を行う手法
今回の開発では「コードファースト」方式を採用し、開発を行います。
GraphQLモデルの作成
ここでは一例として、Userモデルを定義してみます。
import { Field, ID, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class User {
@Field(() => ID)
id: number;
@Field()
name: string;
@Field()
email: string;
@Field()
password: string;
}
ここで定義したモデルは、後ほど作成するResolverで使用します。
Module・Resolverの作成
NestCLIを利用することで、ModuleやResolverも簡単に生成することができます。
nest g module users
nest g resolver users
一例として、作成したResolver内にUser一覧を取得するQueryを作成してみます。
import { UseGuards } from '@nestjs/common';
import { Query, Resolver } from '@nestjs/graphql';
import { PrismaService } from '../prisma.service';
import { User } from './models/user.model';
@Resolver(() => User)
export class UserResolver {
constructor(
private prisma: PrismaService,
) {}
@Query(() => [User])
async users() {
return this.prisma.user.findMany();
}
}
Resolverを定義しただけでは利用することができませんので、user.module.tsとapp.module.tsを修正し、実際に利用できるようにしてみます。
import { Module } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
import { UserResolver } from './users.resolver';
@Module({
providers: [PrismaService, UserResolver],//追加
})
export class UserModule {}
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaService } from './prisma.service';
import { ConfigModule } from '@nestjs/config';
import { UserModule } from './users/users.module';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
context: ({ req }) => ({ req }),
driver: ApolloDriver,
autoSchemaFile: 'src/schema.gql',
}),
ConfigModule.forRoot({
isGlobal: true,
}),
UserModule, //追加
],
controllers: [AppController],
providers: [AppService, PrismaService],
})
export class AppModule {}
イメージとしては、定義したResolverを各Moduleでまとめ、それをさらにAppのModuleで一つにまとめる感じです。
(本来なら、QueryやMutationをResolver内に定義し、詳細な定義はServiceに定義した方が良さそうですが、今回は割愛してます)
ここでの修正ですが、NestCLIを利用してファイルを生成した場合、Nest側が勝手に修正を加えてくれます。(これめっちゃ便利...)
実行確認
ここまですれば、userの一覧を実際に取得することが可能になってるはずです。
以下のコマンドを使って、実際に実行してみます。
実行の際には、DBの起動が必要となるため、あらかじめdockerを立ち上げておきましょう。
dotenv -e .env nest start --watch
起動後、localhost:3000/graphqlにアクセスした際、以下のような画面が表示されればOKです。(ポート番号はmain.tsに定義されてるので、場合によって違うかもしれません。)
クライアントからデータを取得する際も、このエンドポイントのみを用います。
個人的に超便利だな〜って感じたのが、ブラウザで実行されるGraphQL Playgroundを使うことで、簡単にクエリの実行テストが行える点でした。
例えば、先ほど定義したUser一覧のクエリを実行する場合、以下のような画面となります。
認証の実装
NestJS+GraphQLでの処理の追加も行えるようになったので、ここからは認証の実装を行なっていきます。
まずは認証の実装に必要なパッケージを追加します。
$ npm i --save @nestjs/passport passport passport-local @nestjs/jwt passport-jwt
$ npm i --save-dev @types/passport-local @types/passport-jwt
$ npm i --save bcrypt
$ npm i --save @nestjs/config
各パッケージには以下の役割があります。
- passport
- NodeJSでよく使われる認証ライブラリであるPassportのパッケージ
- passport-local
- メールアドレスとパスワードでログインする機能の提供
- passport-jwt
- jwtを検証するための機能を提供
- bcrypt
- パスワードの暗号化に使用
- @nestjs/config
- envファイルから秘密鍵を持ってくるために利用(他にもありそう)
認証にはいくつかの戦略があり、今回はその中から、JWT認証とlocal認証を採用しています。
今回の認証は、以下のような手順で行います。
- メールアドレスとパスワードをユーザーが入力する
- 入力されたデータからクエリを作成し、サーバー側に送信する
- 送信されたデータが正しく認証されれば、対応したトークンを生成
- クライアント側に生成したトークンを送信
- 以後、クライアント側はheaderにトークンを乗せ、サーバーと通信する
それでは、認証機能を実装していきます。
nest g service auth
nest g resolver auth
nest g module auth
local認証のStrategyファイル作成
authフォルダ直下に、以下のようにlocal.strategy.tsを作成します。
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth/auth.service';
import { User } from '../users/models/user.model';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({ usernameField: 'email' });
}
async validate(email: string, password: string): Promise<User> {
const user = this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
後ほどServiceファイルに実装するvalidateUserメソッドから、userが返ってきた場合は認証成功となります。
今回はDBのモデルでemailをuniqueに設定しているので、usernameFieldの箇所を"email"としています。
JWT認証のStrategyファイル作成
同じように、jwt.strategy.tsを作成します。
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { JwtPayload } from './auth.service';
// JwtについているPayload情報の型
interface JWTPayload {
email: string;
id: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly configService: ConfigService) {
super({
// Authorization bearerからトークンを読み込む関数を返す
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// 有効期間を無視するかどうか
ignoreExpiration: false,
// envファイルから秘密鍵を渡す
secretOrKey: configService.get<string>('JWT_SECRET_KEY'),
});
}
// ここでPayloadを使ったバリデーション処理を実行できる
// Payloadは、AuthService.login()で定義した値
async validate(payload: JWTPayload): Promise<JwtPayload> {
return { email: payload.email, id: payload.id };
}
}
Guard作成
続いて、local認証とjwt認証で利用するGuardをそれぞれ作成します。
- gql-auth.guard.ts : local認証用(ログインで利用)
- jwt.guard.ts : jwt認証用(ログイン以外で利用)
本来はPassportから提供されるGuardを利用するのですが、そちらはREST API対応となっているので、今回はGraphQLで利用できるように、コチラの記事を参考にしながらアレンジを行います。
import { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
export class GqlAuthGuard extends AuthGuard('local') {
constructor() {
super();
}
getRequest(context: ExecutionContext): any {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext();
request.body = ctx.getArgs().loginUserInput;
return request;
}
}
import {
Injectable,
ExecutionContext,
createParamDecorator,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}
getRequest(context: ExecutionContext): any {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
//後ほどユーザーの認証情報を取得するためのデコレーターを定義しておきます
export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.user;
},
);
DTO作成
続いて、認証のメソッドが受け取るリクエストの型を定義します。
- register-user.input.ts : 新規登録リクエストの型
- login-user.input.ts : ログインリクエストの型
これはDTO(Data Transfer Object)と呼ばれており、リクエストに複数のデータが送信される場合は、あらかじめ定義しておくと良いでしょう。
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class RegisterUserInput {
@Field()
name: string;
@Field()
email: string;
@Field()
password: string;
}
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class LoginUserInput {
@Field()
email: string;
@Field()
password: string;
}
Resolver・Service作成
必要ファイルが揃ったので、以下のように認証の処理を作成していきます。
import bcrypt = require('bcrypt');
import { HttpException, HttpStatus, UseGuards } from '@nestjs/common';
import { Resolver, Mutation, Args, Context } from '@nestjs/graphql';
import { AuthService } from '../auth/auth.service';
import { LoginResponse } from '../auth/dto/login-response';
import { LoginUserInput } from '../auth/dto/login-user.input';
import { GqlAuthGuard } from '../auth/guards/gql-auth.guard';
import { PrismaService } from '../prisma.service';
import { RegisterUserInput } from './dto/register-user.input';
@Resolver()
export class AuthResolver {
constructor(
private readonly authService: AuthService,
private prisma: PrismaService,
) {}
@Mutation(() => LoginResponse)
@UseGuards(GqlAuthGuard)
async login(
@Args('loginUserInput') loginUserInput: LoginUserInput,
@Context() context,
) {
return this.authService.login(context.user);
}
@Mutation(() => LoginResponse)
async register(
@Args('registerUserInput') registerUserInput: RegisterUserInput,
) {
const registered_user = await this.prisma.user.findUnique({
where: { email: registerUserInput.email },
});
if (registered_user) {
throw new HttpException(
'既に使用されているメールアドレスです。',
HttpStatus.CONFLICT,
);
}
const user = await this.prisma.user.create({
data: {
...registerUserInput,
password: await bcrypt.hash(
registerUserInput.password,
await bcrypt.genSalt(10),
),
},
});
return this.authService.login(user);
}
}
import bcrypt = require('bcrypt');
import { Injectable } from '@nestjs/common';
import { User } from '../users/models/user.model';
import { JwtService } from '@nestjs/jwt';
import { UserResolver } from '../users/users.resolver';
import { PrismaService } from '../prisma.service';
type PasswordOmitUser = Omit<User, 'password'>;
export interface JwtPayload {
email: string;
id: number;
}
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private userResolver: UserResolver,
private prisma: PrismaService,
) {}
//ユーザー認証
async validateUser(email: string, password: string): Promise<User | null> {
const user = await this.prisma.user.findUnique({ where: { email: email } });
if (user && bcrypt.compareSync(password, user.password)) {
const { password, ...result } = user; // パスワード情報を外部に出さないようにする
return result;
}
return null;
}
//jwt tokenを返す
async login(user: PasswordOmitUser) {
const payload = { email: user.email, id: user.id };
return {
access_token: this.jwtService.sign(payload),
};
}
}
(この辺りの実装について、まだよくわかってない箇所もありますので、また調べておきたいところです)
ここまでのファイル作成に合わせて、Moduleファイルの修正を行います。
auth.module.tsでは、作成したStrategyファイルをまとめたり、認証方式の詳細設定を行います。
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { LocalStrategy } from './local.strategy';
import { PrismaService } from '../prisma.service';
import { AuthResolver } from './auth.resolver';
@Module({
imports: [
PassportModule,
// JWTを使うための設定
JwtModule.registerAsync({
useFactory: async (configService: ConfigService) => {
return {
// envファイルから秘密鍵を渡す
secret: configService.get<string>('JWT_SECRET_KEY'),
signOptions: {
// 有効期間を設定
// 指定する値は以下を参照
// https://github.com/vercel/ms
expiresIn: '1200000s',
},
};
},
inject: [ConfigService],
}),
],
providers: [
AuthService,
LocalStrategy,
JwtStrategy,
PrismaService,
AuthResolver,
],
exports: [AuthService],
})
export class AuthModule {}
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaService } from './prisma.service';
import { AuthModule } from './auth/auth.module';
import { ConfigModule } from '@nestjs/config';
import { UserModule } from './users/users.module';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
context: ({ req }) => ({ req }),
driver: ApolloDriver,
autoSchemaFile: 'src/schema.gql',
}),
ConfigModule.forRoot({
isGlobal: true,
}),
AuthModule, //追加
UserModule,
],
controllers: [AppController],
providers: [AppService, PrismaService],
})
export class AppModule {}
access_token生成のための秘密鍵も、.envに登録しておきましょう。
ここまで実装ができれば、ログイン・新規登録のクエリを実行できるようになるかと思います。
認証の利用
これ以降実装を行う機能については、認証が前提となります。
詳細な実装については省きますが、jwt認証の一例を挙げておきます。
というわけで、jwt認証を使ったユーザー詳細の取得処理を追加してみます。
import { UseGuards } from '@nestjs/common';
import { Query, Resolver } from '@nestjs/graphql';
import { CurrentUser, JwtAuthGuard } from '../auth/guards/jwt-guard';
import { PrismaService } from '../prisma.service';
import { User } from './models/user.model';
@Resolver(() => User)
export class UserResolver {
constructor(
private prisma: PrismaService,
) {}
@Query(() => User)
@UseGuards(JwtAuthGuard) //先ほど定義したGuardを利用する
async user(@CurrentUser() user: User) {
return this.prisma.user.findFirst({ where: { id: user.id } });
}
}
このように実装することで、
- 認証成功時
- CurrentUserデコレーターをつけた引数に認証情報が渡される
- 認証失敗時
- Guardで弾かれ、認証エラーがクライアントに返される
といった形になります。
ここまでの実装ができれば、同じようにその他の処理も作成できると思います。
APIのデプロイ
想定している処理が全て完成したとしましょう!
あとは完成物をデプロイするだけです。(ここで割と詰まっちゃいましたが)
デプロイ方法
今回はデプロイ先にherokuを採用しました。
herokuのデプロイ方法については、コチラを参考にしてみてください。
まずは、デプロイに必要なファイルを作成します。
- Dockerfile
- デプロイ実行時に、デプロイ先でビルドするための処理を記述する
- heroku.yml
- herokuにデプロイする際に必要な情報を記述する
- 使用するDockerfileの指定など
- herokuにデプロイする際に必要な情報を記述する
- .dockerignore
- DockerfileのCOPYコマンドの対象にしたくないファイルの記述
- .env-production
- .envのデプロイ用ファイル
# ローカルと合わせておく
FROM node:16.14.0-alpine
# 任意のtime zone設定にする
ENV TZ=Asia/Tokyo
RUN apk --no-cache add tzdata && \
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY prisma ./prisma
RUN npm run prisma:generate
COPY . ./
RUN npm run build
RUN npm run prisma:migrate-deploy
EXPOSE 3000
ENTRYPOINT [ "npm", "run", "start:prod" ]
build:
docker:
web: Dockerfile
run:
web: npm run start:prod
node_modules
DATABASE_URL=デプロイ先のDBのURL
この辺も私自身経験がなく、もっと良い書き方を模索していきたいところです。
特に、.env-productionについては次項で説明しますが、
プロジェクトに直接DBのURLを記述するのは良くない方式だと思います。
Heroku CLIの準備
herokuのデプロイには、Heroku CLIを利用するのが一番楽だと思います。
Heroku CLIのインストールは環境ごとに方法が異なります。(インストール方法)
今回はMacの環境で行うので、以下のコマンドで準備します。
brew tap heroku/brew && brew install heroku
今後のherokuの操作にはログインが必要となるので、
以下のコマンドでログインをしておきましょう。
heroku login
デプロイ後のDBについて
herokuにはClearDBと呼ばれるDBを提供する機能があり、MySQLにも対応しているため、今回はその機能を使うことにします。
コチラの機能は、herokuのアカウントにクレジットカード登録(無料)が必要となるので、登録しておきましょう。
専用のアドオンが必要になるので、追加します。
heroku addons:create cleardb:ignite
これで、デプロイ先のMySQLが利用可能となりました。
アクセスするために、以下のコマンドでURLを確認しておきましょう。
heroku config | grep CLEARDB_DATABASE_URL
ここで生成されたURLを、先ほどの.env-productionのDATABASE_URLに記述しておけば、デプロイ後もMySQLを問題なく利用できるはずです。
(補足)DATABASE_URLについて
今回は問題が発生したため、直接アプリケーション内のenvファイルに記述することにしましたが、本来はheroku側の環境変数にURLを登録すべきです。
以下のコマンドを使うか、直接herokuのサイトから環境変数を登録できます。
heroku config:set DATABASE_URL='生成されたURL'
デプロイ確認
設定が完了しましたので、実際にデプロイします。
今回の方法では、herokuにgitのbranchをpushする必要があります。
デプロイ用のブランチはあらかじめ決めておくと良いかと思います。
git push heroku ブランチ名
dev環境で起動した際と同じように、デプロイ先にアクセスしてみてください。
GraphQL Playgroundが起動されればOKです!
まとめ
以上で環境構築 〜 デプロイまで、サラッとではありますが書いてみました。
今回のAPI開発で感じたことを少しまとめてみます。
良かったなってこと
NestJSがすごく使いやすい!
主にNest CLIのおかげですが、環境構築がスピーディーに行うことができ、機能追加によるファイル追加についても、Nest CLIを利用することでテンプレート生成と修正を自動的にやってくれるため、非常に開発しやすかったです。
学習コストに関しても、そこまで高くないと思います。
PrismaがTSでのバックエンド開発で優秀!
利用できるDBの幅が広く、スキーマを定義するだけでDBのマイグレーションファイルを作成してくれるなど、優秀な点は多いですが、以下の点が特にPrismaを利用してて魅力的でした。
- スキーマに合わせて、自動でDB操作用のメソッドを作ってくれる
- 作成されたメソッドは型安全で、堅牢に開発できる
特にメソッドを自動的に作ってくれる点は、非常に開発を楽にしてくれた印象です。私自身SQL文を書くのが得意でなく、その手間を完全に省いてくれるのはめちゃくちゃ助かりました。
今後もバックエンド開発では、積極的にPrismaを使うつもりです。
GraphQLの学習が実践的にできた!
趣味開発とはいえ、実際にAPIを開発しながらGraphQLの学習を行なったので、本を読んだりするより効果的にGraphQLについて学べました。
とはいえまだまだ知識不足な段階ですので、今後開発を重ねるうちに、RESTとGraphQLについての知識をさらに深めたいな、というお気持ちです。
APIが開発できるようになり、開発の幅が広がった!
私はフロントエンドエンジニアですが、今回の開発を通じてAPI開発についての知見を増やすことができ、やれることの幅が広がった印象です。
NestJSは比較的とっつきやすいフレームワークとなってますので、
フロントエンドだけど、バックエンドもできるようになりたいなぁ...
そんな方には、是非一度NestJSを触ってみてほしいな、と私は思います。
良くなかったなってこと
(これは僕自身の反省点のまとめです)
ResolverとServiceで役割分担をしたかった
エラーが頻発して手を焼いてしまったため、今回はほぼResolverに処理を書いてましたが、本当なら役割分担をすべきだと思います。
というのも、今回はそこまで複雑なAPIを作ったわけではないのであまり関係ないですが、機能追加によっては再利用が必要な機能も出てくるかと思います。
ResolverはQuery定義、ServiceはDBの詳細な操作、といった感じで分けていくのが一番なのかな、という印象です。
認証周りの理解を曖昧にしたままにしてしまった
認証の手順については理解できましたが、内部の詳細な処理については全く理解が深まらないまま、開発を進めてしまいました。
これについては今後何かしらの開発をしながら学習したいところです。
デプロイ先のDBのURLをenvファイルに書いてしまった
これも本来の方法で上手く動作しなかったため、仕方なくURLを直接書きましたが、これについてはherokuの機能を使って環境変数として定義すべきです。
(実際、Githubから一度怒られました...)
これについては今度修正しようかなと考えてます。
終わりに
長くなりましたが、NestJSとGraphQLを使ってAPIを開発してみたお話でした。
NestJS、結構使いやすいので是非使ってみてください🙆♂️
良い機会ですので、作ったAPIを使ってApollo Clientの学習もしてみようかと考えてます。