😼

NestJSでMySQLを利用したGraphQLなAPIを構築してみる

16 min read

はじめに

NestJSでMySQLを利用したGraphQLなAPIを構築する際の備忘録になります。
と思ったら、手元のm1 macではDockerのmysqlイメージがarm64用がないようで利用できなかったため、mariaDBで代用しています。お試しの際は予めDockerを利用できる環境をご用意ください。

NestJSのインストール

https://nestjs.com/

NestJSとはTypeScriptで開発されたバックエンド向きのフルスタックフレームワークです。内部的にはExpressを使って処理を行っています。Fastifyをコアにさせることもできますが、GraphQLと若干相性が悪い?ようなので個人的にはもう少し様子見です。とにかくTypeScript全開のフレームワークで各種ORMやclass-validatorなどとも相性バツグンです😎

まずはNestJSのCLIをグローバルにインストールします。(このあたりはお好みで)

$ npm i -g @nestjs/cli

CLIを利用して新規プロジェクトを作成します。
今回はtodo-appとしてみました。
最初にパッケージマネージャとしてnpmかyarnを使うか聞かれますが今回はyarnで進めています。

$ nest new todo-app

? Which package manager would you ❤️  to use? yarn
✔ Installation in progress... ☕

🚀  Successfully created project todo-app
👉  Get started with the following commands:

$ cd todo-app
$ yarn run start

新規プロジェクトが終わると、実行コマンドが表示されますので、ディレクトリに移動して試しに起動してみましょう。

$ cd todo-app
$ yarn start

ブラウザで、http://localhost:3000を開いて、お約束の画面が表示されればまずはセットアップ成功です。

DBの用意

今回はDBとしてmariaDBをDockerイメージから利用します。
GUIの管理ツールとしてadminerもついでに用意しておきます。
なお、テスト用とのためデータの永続化は行っていませんのでご注意ください。

docker-compose.yml
version: '3.8'

services:
  mysql:
    image: mariadb:10.6.1
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MARIADB_ROOT_PASSWORD: example
    ports:
      - 3306:3306

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

設定ファイルを作成したらDockerを構築、起動します。
イメージが手元にない場合はダウンロード、構築に時間がかかります。

$ docker compose up

無事に起動したら動作をみておきましょう。
ブラウザでhttp://localhost:8080に接続してadminerの管理画面を開きます。

データベース種類: MySQL
サーバ: mysql
ユーザ名: root
パスワード: example
データベース: 空白

でログインできればMySQLが利用可能です。
あわせて今回利用するデータベースtodoを作成しておきます。

NestJSの初期設定

NestJSで.envなどの環境設定ファイルを利用するためのパッケージ@nestjs/configをインスールしておきます。(すみません今回の記事内では結局利用しませんでした。。)

$ yarn add @nestjs/config

app.module.tsファイルに記載を追記することで、.envファイルに記載した情報をprocess.env環境変数経由で取得可能です。

src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config'; // 追加

@Module({
  imports: [ConfigModule.forRoot()], // 追加
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

TypeORMのインストール

TypeScript製ORMであるTypeORMをインストールします。
今回データベースにはmariaDBを利用しているので、mysqlパッケージもあわせてインストールします。

$ yarn add typeorm @nestjs/typeorm mysql

データベースの接続に必要になる設定ファイルを作成します。
synchronizeオプションはentitiesファイルを編集するごとにDBの構造を自動的に変更してくれますが、意図しない変更を防ぐためにも開発環境でのみtrueとするようにしてください。
プロダクション利用を行うアプリケーションなどでは別途環境変数から設定を読み込む形にするのが理想かと思います。

ormconfig.json
{
  "type": "mysql",
  "host": "localhost",
  "port": 3306,
  "username": "root",
  "password": "example",
  "database": "todo",
  "entities": ["dist/**/entities/*{.ts,.js}"],
  "synchronize": true,
}

データベースへの接続

データベースに接続するためのModuleファイルsrc/database/database.module.tsを作成します。
NestJSにはModuleやControllerなどをCLIで作成することができ、依存関係も自動的に追記してくれる上にテスト用のファイルまで作成してくれます。

nest g コマンド一覧
│ name          │ alias       │ description                                  │
│ application   │ application │ Generate a new application workspace         │
│ class         │ cl          │ Generate a new class                         │
│ configuration │ config      │ Generate a CLI configuration file            │
│ controller    │ co          │ Generate a controller declaration            │
│ decorator     │ d           │ Generate a custom decorator                  │
│ filter        │ f           │ Generate a filter declaration                │
│ gateway       │ ga          │ Generate a gateway declaration               │
│ guard         │ gu          │ Generate a guard declaration                 │
│ interceptor   │ in          │ Generate an interceptor declaration          │
│ interface     │ interface   │ Generate an interface                        │
│ middleware    │ mi          │ Generate a middleware declaration            │
│ module        │ mo          │ Generate a module declaration                │
│ pipe          │ pi          │ Generate a pipe declaration                  │
│ provider      │ pr          │ Generate a provider declaration              │
│ resolver      │ r           │ Generate a GraphQL resolver declaration      │
│ service       │ s           │ Generate a service declaration               │
│ library       │ lib         │ Generate a new library within a monorepo     │
│ sub-app       │ app         │ Generate a new application within a monorepo │
│ resource      │ res         │ Generate a new CRUD resource                 │

Moduleファイルを作成します。

$ nest g mo database

作成されたファイルを編集します。

src/database/database.module.ts
import { Module } from '@nestjs/common';
import { Connection } from 'typeorm';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [TypeOrmModule.forRoot()],
})
export class DatabaseModule {
  constructor(connection: Connection) {
    if (connection.isConnected) {
      console.log('DB connected!');
    }
  }
}

ここまで準備したところで開発サーバーを起動してDBへの接続ができているか試してみましょう。

$ yarn start:dev # :devをつけることでwatchモードで起動します

コンソールにDB connected!が表示されていれば接続に成功しています。

なお、コンソールに下記のエラーが出てしまった場合は、adminerでtodoデータベースを作成できているか確認してみてください。

Error: ER_BAD_DB_ERROR: Unknown database 'todo'

GraphQLのセットアップ

GraphQLの利用に必要なパッケージをインストールします。

$ yarn add @nestjs/graphql graphql-tools graphql apollo-server-express

CLIでModule、Resolver、Serviceファイルを作成します。

$ nest g mo todos 
$ nest g r todos 
$ nest g s todos 

一旦動作テストのためResolverに仮の処理を記載します。

src/todos/todos.resolver.ts
import { Query, Resolver } from '@nestjs/graphql';
import { TodosService } from './todos.service';

@Resolver()
export class TodosResolver {
  constructor(private todosService: TodosService) {}

  @Query(() => String)
  public async todos() {
    return 'All todos';
  }
}

作成したTodosModuleとインストールしたGraphQLModuleをapp.module.tsに読み込みます。

src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { GraphQLModule } from '@nestjs/graphql';
import { TodosModule } from './todos/todos.module';

@Module({
  imports: [
    ConfigModule.forRoot(),
    DatabaseModule,
    GraphQLModule.forRoot({
      playground: true,
      debug: true,
      // 下記に設定したファイル名でスキーマファイルが書き出されます
      autoSchemaFile: 'schema.graphql', 
    }),
    TodosModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

各ファイルの準備ができてところでhttp://localhost:3000/graphqlへアクセスしてGraphQL Playgroundが起動するか試してみましょう。
左側に下記クエリーを入力してAll todosというメッセージが返ってくれば接続成功です。

query {
  todos
}

TODO Modelの作成

データベースに格納されるTODOのModelを作成していきます。
今回はEntityというディレクトリにModelを用意します。
今回は単純な項目として、下記を用意してみました。

  • id 自動的に付与される文字列
  • name 文字列
  • priority 数値
  • completed 真偽値
  • createdAt 日時(自動更新)
  • updatedAt 日時(自動更新)

利用できるデコレーター等はTypeORMのドキュメンを参照ください。

https://typeorm.io/#/entities
src/todos/entities/todo.ts
import { Field, ObjectType } from '@nestjs/graphql';
import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity({ name: 'todos' })
@ObjectType()
export class Todo {
  @PrimaryGeneratedColumn('uuid')
  @Field()
  id: string;

  @Column()
  @Field()
  name: string;

  @Column()
  @Field()
  priority: number;

  @Column()
  @Field()
  completed: boolean;

  @CreateDateColumn()
  @Field()
  createdAt: Date;

  @UpdateDateColumn()
  @Field()
  updatedAt: Date;
}

なお、開発サーバーを起動している状態でEntityファイルにModelデータを記載して編集、保存するたびに自動的にデータベースに反映がされていきます。都度マイグレーションの必要がないので効率よく開発を進められます。

Moduleファイル、Serviceファイルを編集して作成したTODO Entityを紐付けていきます。

src/todos/todos.module.ts
import { Module } from '@nestjs/common';
import { TodosService } from './todos.service';
import { TodosResolver } from './todos.resolver';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Todo } from './entities/todo';

@Module({
  imports: [TypeOrmModule.forFeature([Todo])],
  providers: [TodosService, TodosResolver],
  exports: [TodosService],
})
export class TodosModule {}
src/todos/todos.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Todo } from './entities/todo';

@Injectable()
export class TodosService {
  constructor(
    @InjectRepository(Todo) private todoRepository: Repository<Todo>,
  ) {}

  public async getAllTodos(): Promise<Todo[]> {
    const todos = await this.todoRepository.find({});

    // Nest.jsにはエラーハンドリング用のクラスがありその1つを利用しています
    // 
    if (!todos) throw new NotFoundException();

    return todos;
  }
}

Resolverを編集してServiceで設定したgetAllTodosの実行結果を返すように変更します。

src/todos/todos/resolver.ts
import { Query, Resolver } from '@nestjs/graphql';
import { Todo } from './entities/todo';
import { TodosService } from './todos.service';

@Resolver()
export class TodosResolver {
  constructor(private todosService: TodosService) {}

  @Query(() => [Todo])
  public async todos(): Promise<Todo[]> {
    return await this.todosService.getAllTodos().catch((err) => {
      throw err;
    });
  }
}

ここまで記載したところで再度GraphQL Playgroundを利用して、動作を試してみましょう。
まだデータは空なので空の配列が返ってくれば成功です。

query {
  todos {
    id
  }
}

クエリーの作成

一覧の取得ができたところで次はデータの書き込みMutationクエリーを作成していきます。
まずクエリーの型情報となるDTOを作成します。

src/todos/dto/new-todo.input.ts
import { Field, InputType, Int } from '@nestjs/graphql';

@InputType()
export class NewTodoInput {
  @Field()
  name: string;

  @Field(() => Int)
  priority: number;

  @Field()
  completed: boolean;
}

ServiceとResolverにTODOの追加ロジックを追加します。

src/todos/todos.service.ts
import {
  Injectable,
  InternalServerErrorException,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NewTodoInput } from './dto/new-todo.input';
import { Todo } from './entities/todo';

@Injectable()
export class TodosService {
  constructor(
    @InjectRepository(Todo) private todoRepository: Repository<Todo>,
  ) {}

  public async getAllTodos(): Promise<Todo[]> {
    const todos = await this.todoRepository.find({});

    if (!todos) throw new NotFoundException();

    return todos;
  }

  public async addTodo(newTodoData: NewTodoInput): Promise<Todo> {
    const newTodo = this.todoRepository.create(newTodoData);
    await this.todoRepository.save(newTodo).catch((err) => {
      new InternalServerErrorException();
    });
    return newTodo;
  }
}
src/todos/todos.resolver.ts
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { NewTodoInput } from './dto/new-todo.input';
import { Todo } from './entities/todo';
import { TodosService } from './todos.service';

@Resolver()
export class TodosResolver {
  constructor(private todosService: TodosService) {}

  @Query(() => [Todo])
  public async todos(): Promise<Todo[]> {
    return await this.todosService.getAllTodos().catch((err) => {
      throw err;
    });
  }

  @Mutation(() => Todo)
  public async addNewTodo(
    @Args('newTodoData') newTodoData: NewTodoInput,
  ): Promise<Todo> {
    return await this.todosService.addTodo(newTodoData).catch((err) => {
      throw err;
    });
  }
}

それではデータが問題なく書き込めるか試してみます。
http://localhost:3000/graphqlを開いて、左側にMutationクエリーを書き込み実行します。

mutation {
  addNewTodo(newTodoData: {
    name: "牛乳を買ってくる",
    priority: 1,
    completed: false,
  }) {
    id
    name
    priority
    completed
    createdAt
    updatedAt
  }
}

結果投稿したデータにidとcreatedAt、updatedAtが自動的に追加されたデータが返ってくれば登録完了です。
念の為一覧の取得とadminerからデータベースの中身も確認してみます。

さいごに

これで一覧の取得とTODOの投稿ができるようになりました。
同じ用に更新や削除を追加していくことでGraphQLを利用した簡単なCRUD APIが完成します。

個人的にはMongoDBとの組み合わせが好みではありますが、自分の環境だとなかなかプロダクションとしての理解を得られないので通りやすいMySQL(mariaDB)を想定したものにしてみました😅

バリデーションや入力データの加工などまだまだ機能としては足りていないものばかりですが、
class-validatorclass-transformerパッケージを利用することでデコレーターを利用して簡単に実装していくことが可能です。

https://www.npmjs.com/package/class-validator
$yarn add class-validator class-transformer
src/todos/dto/new-todo.input.ts
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsString, Length } from 'class-validator';

@InputType()
export class NewTodoInput {
  @Field()
  @IsString() // 文字列に制限する
  @Length(5, 140, { message: '5文字以上140文字以内です' }) // 最低5文字、最長140文字
  name: string;

  @Field(() => Int)
  priority: number;

  @Field()
  completed: boolean;
}

main.tsファイルにGlobalPipesを追加

src/main.ts
import { ValidationPipe } from '@nestjs/common'; // 追加
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe()); // 追加
  app.enableCors();
  // await app.listen(3000);
  // 他のホストから参照したい場合は0.0.0.0を追記
  await app.listen(3000, '0.0.0.0');
}
bootstrap();

おまけ

NestJSはデフォルトではExpressをコアとして動作しますが、より高速に動作するFastifyをコアとして動作させることもできます。apollo-server-fastifyパッケージがまだpreview版のためその点だけご注意ください。

$ yarn add @nestjs/platform-fastify apollo-server-fastify@3.0.0-preview.3
src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  app.useGlobalPipes(new ValidationPipe());
  app.enableCors();
  // await app.listen(3000);
  // 他のホストから参照したい場合は0.0.0.0を追記
  await app.listen(3000, '0.0.0.0');
}
bootstrap();
# yarn start:dev

http://localhost:3000/graphqlにアクセスするとIDEとして最新版のApollo Sandboxが起動します。スキーマと連携してクエリの入力がかなり便利になっています。

https://github.com/himorishige/nestjs-todo-sample

Discussion

ログインするとコメントできます