📜

SimplenoteライクなREST APIをTypeScriptでつくる記事

2023/10/17に公開

はじめに

今回の記事では以下の技術を使ってSimplenoteのデータを模倣したREST APIを開発する手順を解説する。

なお、今回の記事ではデータモデルの設計からテストまでを網羅するやや本格的なREST APIの開発方法を簡潔に解説する。

今回取り扱う技術の説明

NestJS

TypeScriptで開発されたサーバサイドフレームワーク。拡張性が高く、コマンド入力一つで開発に必要な機能を簡単にインストールできる。

Prisma

TypeScript(JavaScript)に対応しているORM。ORMは、SQL以外でデータベースの設計や操作ができる言語を意味する。

SQLite

環境構築が不要で設定できる軽量のデータベース。小規模のアプリケーションの開発やデータベースの学習に最適である。

Swagger

APIドキュメンテーションに特化したツール。自分が開発したAPIと連動することで、自動でその仕様を記述したAPIドキュメントを作ってくれる。NestJSにはデフォルトでこれが搭載されている。

実装の流れ

  1. 新規のNestJSアプリケーションを作成する
  2. Prismaを導入し、初期化する
  3. NestJSとPrismaを統合し、アプリケーション全体でPrisma Clientを使えるようにする
  4. DTOを作る
  5. ServiceとControllerを作る
  6. Swaggerとアプリケーションを統合する
  7. エラーハンドリングを実装する
  8. テストコードを書く

アーキテクチャ図

今回の記事で作成するアプリケーションはハンバーガーのような層状アーキテクチャとなっている。上述のMermaidの図を軽く説明する。

  • NestJS Application:今回の記事で作るアプリケーション
  • NotesController:クライアントからのリクエストを受け取り、適切なサービスメソッドを呼び出す役割を果たす
  • NotesService:実際のビジネスロジックを持つサービス層
  • PrismaService:Prisma Clientのラッパーとして機能するサービス
  • SQLite Database:実際に使われるデータベース

手順

(1) NestJSの導入

最初に、以下のコマンドを入力する。

npm i -g @nestjs/cli
nest new simplenote-api

次に、該当のディレクトリに移動する。

cd simplenote-api

(2) Prismaの導入と設定

以下のコマンドでPrismaをインストールする。

npm i prisma --save-dev
npm i @prisma/client

以下のコマンドでPrismaを初期化する。

npx prisma init

prisma/schema.prismaファイルを開く。このファイルでは、データベースのモデルを書く。大雑把ではあるが、Simplenoteのデータのモデルは以下のようになる。

  • ノートのID
  • ノートのタイトル
  • ノートの内容
  • ノートが作成された日時
  • ノートが更新された日時

上述の情報をPrismaに落とし込んでいく。

prisma/schema.prisma
datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

generator client {
  provider = "prisma-client-js"
}

model Note {
  id        Int      @id @default(autoincrement()) // autoincrement()は自動で値が入る関数
  title     String
  content   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

データベースをマイグレーションし、更新する。

npx prisma migrate dev --name init

(3) NestJSのServiceとControllerの実装

以下のコマンドを入力し、NestJSアプリケーションの動作に必要なModule、ServiceやControllerの雛形を出力する。

npx nest g module notes
npx nest g service notes
npx nest g controller notes

上述のコマンドで雛形を生成した後、Serviceのコードを書く。NestJSにおけるServiceは、データベースの操作や外部APIの呼び出し、計算ロジックなど具体的な処理を行う。本コードではデータベースの操作をコードに落とし込む。

notes/notes.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient, Note } from '@prisma/client';

// Prismaのデータ操作は非同期処理で実装する。
@Injectable()
export class NotesService {
  private prisma = new PrismaClient();

  // 新規データを作成する関数。
  async create(data: { title: string; content: string }): Promise<Note> {
    return this.prisma.note.create({ data });
  }

  // findAll():データベースに格納されているすべてのデータを取り出す
  async findAll(): Promise<Note[]> {
    return this.prisma.note.findMany();
  }

  // findOne():データベースに格納されている特定のデータを取り出す。(ID)
  async findOne(id: number): Promise<Note> {
    return this.prisma.note.findUnique({ where: { id } });
  }
  
  // update():データベースに格納されているデータを上書きする。
  // dataのオブジェクト内にあるプロパティには「?」を付ける。これがないとtitleだけを変更して上書きするようなことができない。
  async update(id: number, data: { title?: string; content?: string }): Promise<Note> {
    return this.prisma.note.update({ where: { id }, data });
  }

  // remove():データベースに格納されているデータを削除する。
  // delete({ where: { id }})というように、きちんとIDを指定して消すようにしよう。
  async remove(id: number): Promise<Note> {
    return this.prisma.note.delete({ where: { id } });
  }
}

次はControllerを実装する。NestJSにおけるControllerは、HTTPリクエストを受け取って適切なレスポンスを返す役割を果たす。

notes/notes.controller.ts
// Serviceで実装したデータベースの操作を、いかにしてHTTPリクエストに落とし込むかがController実装のカギ。
import { Controller, Get, Post, Body, Put, Param, Delete } from '@nestjs/common';
import { NotesService } from './notes.service';
import { Note } from '@prisma/client';

@Controller('notes')
export class NotesController {
  constructor(private readonly notesService: NotesService) {}

  // HTTPリクエストのPOSTを担当
  @Post()
  create(@Body() createNoteDto: { title: string; content: string }): Promise<Note> {
    return this.notesService.create(createNoteDto);
  }

  // HTTPリクエストのGETを担当
  @Get()
  findAll(): Promise<Note[]> {
    return this.notesService.findAll();
  }

  // HTTPリクエストのGET /:idを担当
  @Get(':id')
  findOne(@Param('id') id: string): Promise<Note> {
    return this.notesService.findOne(+id);
  }

  // HTTPリクエストのPUT /:idを担当
  @Put(':id')
  update(@Param('id') id: string, @Body() updateNoteDto: { title?: string; content?: string }): Promise<Note> {
    return this.notesService.update(+id, updateNoteDto);
  }

  // HTTPリクエストのDELETE /:idを担当
  @Delete(':id')
  remove(@Param('id') id: string): Promise<Note> {
    return this.notesService.remove(+id);
  }
}

(4) DTOの作成

DTO(Data Transfer Object)は、APIリクエストで送信されるデータの形式を定義するオブジェクトだ。

src/note/dto/create-note.dto.ts
// 新しいノートを作成するときに、クライアントから受け取るデータの形状を定義する
import { IsString, IsNotEmpty } from 'class-validator';

export class CreateNoteDto {
    @IsString()
    @IsNotEmpty()
    title: string;

    @IsString()
    @IsNotEmpty()
    content: string;
}
src/note/dto/update-note.dto.ts
// ノートの更新に関するデータの形状を定義する
// データを更新するときは、すべてのフィールドが変更される必要はないので「?」をフィールド名の末尾につける
import { IsString, IsOptional } from 'class-validator';

export class UpdateNoteDto {
    @IsString()
    @IsOptional()
    title?: string;

    @IsString()
    @IsOptional()
    content?: string;
}

(5) ダミーデータでテスト

実際にダミーデータをPrismaでデータベースに挿入し、CRUD機能をテストする流れを解説する。Prismaでダミーデータを作る流れは以下の通りだ。

  1. PrismaClient()のインスタンスprismaをつくる
  2. 実際に作るダミーデータをmain()関数でつくる
  3. エラーが出力されたら.catchでエラーをキャッチし、console.errorでエラーを出力させる
seed.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
    const note1 = await prisma.note.create({
        data: {
            title: "Sample Note 1",
            content: "This is a sample note content 1.",
        },
    });

    const note2 = await prisma.note.create({
        data: {
            title: "Sample Note 2",
            content: "This is another sample note content 2.",
        },
    });

    // console.log()が成功したら、ダミーデータがサンプル上に出力される
    console.log("Inserted sample notes:", note1, note2);
}

main()
    .catch((e) => {
        console.error(e);
        process.exit(1);
    })
    .finally(async () => {
        await prisma.$disconnect();
    });

上述のseed.tsスクリプトを実行して、ダミーデータをデータベースに挿入する。

ts-node seed.ts

(6) Swaggerの導入

まずは、必要なパッケージをインストールする。

npm i @nestjs/swagger swagger-ui-express

アプリケーションをSwaggerと連携するために、src/main.tsファイルを作る。

src/main.ts
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle('Simplenote API')
    .setDescription('The Simplenote API description')
    .setVersion('1.0')
    .addTag('notes')
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

  await app.listen(3000);
}

bootstrap();

上述のコードはSwaggerの設定を定義して、/apiのエンドポイントでSwagger UIをホストしている。

SwaggerでAPIのエンドポイントとモデルを正確に表現するために、デコレータを使ってアノテーションを追加する。

notes/notes.controller.ts
// もともとあるControllerに、@nestjs/swaggerにあるライブラリが加わるような形になる。
// デコレータが多用されるので煩雑になるが、それぞれのデコレータの役割を理解することが重要。
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Param,
  Body,
  NotFoundException,
} from '@nestjs/common';
import { NotesService } from './notes.service';

// (4)で作成したDTOをここで使う。
import { CreateNoteDto } from './dto/create-note.dto';
import { UpdateNoteDto } from './dto/update-note.dto';

import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; // こちらのコードが新規で加わる。HTTPレスポンスをSwagger上で定義する上で必須になる。複雑になるので注意が必要だ。

// @ApiTags('notes')とすることで、Controllerとタグを連携できる
@ApiTags('notes')
@Controller('notes')
export class NotesController {
  constructor(private readonly notesService: NotesService) {}

  @Get()
  @ApiOperation({ summary: 'Get all notes' })
  @ApiResponse({ status: 200, description: 'List of notes' })
  async findAll() {
    return this.notesService.findAll();
  }

  // コードが大きく変わっているので注意
  @Get(':id')
  @ApiOperation({ summary: 'Get a note by id' })
  @ApiResponse({ status: 200, description: 'The found note' })
  @ApiResponse({ status: 404, description: 'Not Found' })
  async findOne(@Param('id') id: string) {
    // ここで例外処理を行う。
    // データが発見されていればデータを出力し、そうでなければ404エラーを出力する
    const note = await this.notesService.findOne(+id);
    if (!note) {
      throw new NotFoundException(`Note with ID ${id} not found`);
    }
    return note;
  }

  @Post()
  @ApiOperation({ summary: 'Create a new note' })
  @ApiBody({ type: CreateNoteDto })
  @ApiResponse({ status: 201, description: 'The created note' })
  create(@Body() createNoteDto: CreateNoteDto) {
    return this.notesService.create(createNoteDto);
  }

  @Put(':id')
  @ApiOperation({ summary: 'Update a note by id' })
  @ApiBody({ type: UpdateNoteDto })
  @ApiResponse({ status: 200, description: 'The updated note' })
  @ApiResponse({ status: 404, description: 'Not Found' })
  async update(@Param('id') id: string, @Body() updateNoteDto: UpdateNoteDto) {
    const updatedNote = await this.notesService.update(+id, updateNoteDto);
    if (!updatedNote) {
      throw new NotFoundException(`Note with ID ${id} not found`);
    }
    return updatedNote;
  }

  // HTTPリクエストのDELETEを司る関数では、@ApiBodyのデコレータをつけない
  // 削除されるデータはあらかじめデータの方が決まっているからである
  @Delete(':id')
  @ApiOperation({ summary: 'Delete a note by id' })
  @ApiResponse({ status: 200, description: 'The deleted note' })
  @ApiResponse({ status: 404, description: 'Not Found' })
  async remove(@Param('id') id: string) {
    const deletedNote = await this.notesService.remove(+id);
    if (!deletedNote) {
      throw new NotFoundException(`Note with ID ${id} not found`);
    }
    return deletedNote;
  }
}

上述のコードを見ると冗長に感じられるかもしれない。これは、NestJSアプリケーションのControllerにあるHTTPレスポンスをSwagger上で定義している。

例えば、上述のcreate()関数を見てみよう。Swaggerが定義されていない場合は以下の通り。

@Post()
create(@Body() createNoteDto: { title: string; content: string }): Promise<Note> {
  return this.notesService.create(createNoteDto);
}

上述のコードにSwaggerを組み込むと以下のようになる。他の関数でも同様だ。

@Post()
@ApiOperation({ summary: 'Create a new note' }) 
@ApiBody({ type: CreateNoteDto })
@ApiResponse({ status: 201, description: 'The created note' })
create(@Body() createNoteDto: CreateNoteDto) {
  return this.notesService.create(createNoteDto);
}

上述のコードにおけるデコレータとその説明を以下に示す。

  • @ApiOperation({ summary: 'Create a new note' }):HTTPリクエストの処理を行う関数(create()関数)につけて、その関数が行っていることをSwaggerドキュメント上に定義する
  • @ApiBody({ type: CreateNoteDto }):API上でやり取りするデータの型を定義する(type上に定義する形になる)
  • @ApiResponse({ status: 201, description: 'The created note' }):ステータスとレスポンスを定義する

上述のControllerのコードにある他の関数も同様にして実装する。

(7) テストの実装

notes/notes.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { NotesService } from './notes.service';
import { PrismaClient, Note } from '@prisma/client';

describe('NotesService', () => {
  let service: NotesService;
  let prisma: PrismaClient;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [NotesService, PrismaClient],
    }).compile();

    service = module.get<NotesService>(NotesService);
    prisma = module.get<PrismaClient>(PrismaClient);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('create', () => {
    it('should create a note', async () => {
      const noteData = {
        title: 'Test Note',
        content: 'This is a test note content',
      };

      const note = await service.create(noteData);
      expect(note).toHaveProperty('id');
      expect(note.title).toEqual(noteData.title);
      expect(note.content).toEqual(noteData.content);
    });
  });

  describe('findAll', () => {
    it('should return an array of notes', async () => {
      const result: Note[] = [];
      jest.spyOn(service, 'findAll').mockImplementation(async () => result);

      expect(await service.findAll()).toBe(result);
    });
  });

  describe('update', () => {
    it('should update a note', async () => {
      const noteData = { title: 'Updated Test Note', content: 'Updated content' };
      const noteId = 1; // 有効なIDでなければならない

      const updatedNote = await service.update(noteId, noteData);
      expect(updatedNote.title).toEqual(noteData.title);
      expect(updatedNote.content).toEqual(noteData.content);
    });
  });

  describe('remove', () => {
    it('should delete a note', async () => {
      const noteId = 1; // 有効なIDかどうかを確認する

      const deletedNote = await service.remove(noteId);
      expect(deletedNote).toBe(null); 
    });
  });

  afterAll(async () => {
    // テストデータの削除
    await prisma.note.deleteMany();
    await prisma.$disconnect();
  });
});

(8) エラーハンドリング

エラーハンドリングを付け足した際のテストコードを以下に示す。

notes/notes.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { NotesService } from './notes.service';
import { PrismaClient, Note } from '@prisma/client';

describe('NotesService', () => {
  let service: NotesService;
  let prisma: PrismaClient;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [NotesService, PrismaClient],
    }).compile();

    service = module.get<NotesService>(NotesService);
    prisma = module.get<PrismaClient>(PrismaClient);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('create', () => {
    it('should create a note', async () => {
      const noteData = {
        title: 'Test Note',
        content: 'This is a test note content',
      };

      const note = await service.create(noteData);
      expect(note).toHaveProperty('id');
      expect(note.title).toEqual(noteData.title);
      expect(note.content).toEqual(noteData.content);
    });

    it('should throw an error when data is invalid', async () => {
      except(service.create({} as any)).rejects.toThrow();
    });
  });

  describe('findAll', () => {
    it('should return an array of notes', async () => {
      const result: Note[] = [];
      jest.spyOn(service, 'findAll').mockImplementation(async () => result);

      expect(await service.findAll()).toBe(result);
    });
  });

  describe('update', () => {
    it('should update a note', async () => {
      const noteData = { title: 'Updated Test Note', content: 'Updated content' };
      const noteId = 1; // このIDは有効なものを使う

      const updatedNote = await service.update(noteId, noteData);
      expect(updatedNote.title).toEqual(noteData.title);
      expect(updatedNote.content).toEqual(noteData.content);
    });

    it('should throw an error when updating non-exsisting note', async () => {
      const noteData = { title: 'Invalid Test Note', content: 'Invalid content' };
      const invalidNoteId = 9999;

      except(service.update(invalidNoteId, noteData)).rejects.toThrow();
    });
  });

  describe('remove', () => {
    it('should delete a note', async () => {
      const noteId = 1; // 有効なIDかどうかを再確認する

      const deletedNote = await service.remove(noteId);
      expect(deletedNote).toBe(null); 
    });
  });

  afterAll(async () => {
    // テストデータを削除する
    await prisma.note.deleteMany();
    await prisma.$disconnect();
  });
});

(8).ex テストを書く上で重要なポイント

(7)と(8)のコードは長いので、テストを書く上で重要なポイントを説明する。

Serviceを分離する

notes/notes.service.spec.ts
const module: TestingModule = await Test.createTestingModule({
  providers: [NotesService, PrismaClient],
}).compile();

NestJSでは、ロジックはService内に格納される。これにより、コントローラーはリクエストとレスポンスを処理するだけになり、アプリケーションのロジックを分割できる。

エラーハンドリング

notes/notes.service.spec.ts
it('should throw an error when data is invalid', async () => {
  expect(service.create({} as any)).rejects.toThrow();
});

上述のコードは、ノートのデータが無効だったときに適切なエラーが出力されるかどうかを確認している。

データのクリーンアップ

notes/notes.service.spec.ts
afterAll(async () => {
  await prisma.note.deleteMany(); // テストのために作られたデータを削除する
  await prisma.$disconnect(); // テストが終了した後にPrismaとの接続を閉じるために使われる
});

テストの後、使ったデータを削除する(いわゆるクリーンアップ)必要がある。これにより、テストが他のテストや実際の動作に影響を得耐えることがなくなる。

(9) アプリケーションの起動

npm run dev

今回のプロジェクトにおけるディレクトリ

/note-app
│
├── /src
│   ├── /notes
│   │   ├── /dto
│   │   │   └── create-note.dto.ts
│   │   │   └── update-note.dto.ts
│   │   ├── notes.controller.ts
│   │   ├── notes.service.ts
│   │   └── notes.module.ts
│   │
│   └── main.ts (アプリケーションのエントリーポイント)
│
├── /prisma
│   ├── prisma.schema
│   └── .env
│
├── /test
│   ├── notes.service.spec.ts
│   └── ... (他のテスト関連のファイル)
│
├── package.json
├── tsconfig.json
└── README.md

参考サイト

https://www.prisma.io/docs/guides/migrate/seed-database

https://www.prisma.io/docs/guides/migrate/prototyping-schema-db-push

https://docs.nestjs.com/recipes/prisma

https://docs.nestjs.com/fundamentals/testing

GitHubで編集を提案

Discussion