2023年3月23日

NestJSとPrismaでREST APIを構築する:リレーショナルデータの扱い

8分で読めます

NestJS、Prisma、PostgreSQLでREST APIを構築するシリーズの第4回へようこそ!このチュートリアルでは、NestJS REST APIでリレーショナルデータを扱う方法を学びます。

Building a REST API with NestJS and Prisma: Handling Relational Data

目次

はじめに

このシリーズの最初の章では、新しいNestJSプロジェクトを作成し、Prisma、PostgreSQL、Swaggerと統合しました。その後、ブログアプリケーションのバックエンドとして基本的なREST APIを構築しました。第2章では、入力の検証と変換を行う方法を学びました。

この章では、データ層とAPI層でリレーショナルデータを扱う方法を学びます。

  1. まず、データベーススキーマにUserモデルを追加します。これはArticleレコードとの一対多の関係(つまり、一人のユーザーが複数の記事を持つことができる)を持ちます。
  2. 次に、Userレコードに対してCRUD(作成、読み取り、更新、削除)操作を実行するためのUserエンドポイントのAPIルートを実装します。
  3. 最後に、API層でUser-Articleの関係をモデル化する方法を学びます。

このチュートリアルでは、第2章で構築したREST APIを使用します。

開発環境

このチュートリアルを進めるには、以下の準備が必要です。

  • ... Node.jsがインストールされていること。
  • ... DockerDocker Composeがインストールされていること。Linuxをご利用の場合は、Dockerのバージョンが20.10.0以降であることを確認してください。ターミナルでdocker versionを実行することでDockerのバージョンを確認できます。
  • ... 任意で Prisma VS Code拡張機能がインストールされていること。Prisma VS Code拡張機能は、Prismaの非常に優れたIntelliSenseとシンタックスハイライトを追加します。
  • ... 任意で このシリーズで提供されるコマンドを実行するために、Unixシェル(LinuxやmacOSのターミナル/シェルなど)にアクセスできること。

Unixシェルがない場合(例えば、Windowsマシンを使用している場合)でも、チュートリアルを進めることはできますが、シェルコマンドをマシンに合わせて変更する必要があるかもしれません。

リポジトリをクローンする

このチュートリアルの出発点は、このシリーズの第2章の終わりです。NestJSで構築された基本的なREST APIが含まれています。

このチュートリアルの出発点は、GitHubリポジトリend-validationブランチで利用できます。開始するには、リポジトリをクローンし、end-validationブランチをチェックアウトしてください。

さて、始めるために以下の操作を行ってください。

  1. クローンしたディレクトリに移動する
  1. 依存関係をインストールする
  1. DockerでPostgreSQLデータベースを起動する
  1. データベースマイグレーションを適用する
  1. プロジェクトを開始する

: ステップ4では、Prisma Clientの生成とデータベースのシードも行われます。

これで、https://:3000/api/でAPIドキュメントにアクセスできるはずです。

プロジェクトの構造とファイル

クローンしたリポジトリは以下の構造を持つはずです。

: このフォルダーにはtestディレクトリも含まれていることに気づくかもしれません。テストはこのチュートリアルでは扱いません。ただし、Prismaでアプリケーションをテストするためのベストプラクティスについて学びたい場合は、このチュートリアルシリーズを確認してください:Prismaを使ったテストの究極ガイド

このリポジトリの注目すべきファイルとディレクトリは以下のとおりです。

  • srcディレクトリにはアプリケーションのソースコードが含まれています。3つのモジュールがあります。
    • appモジュールはsrcディレクトリのルートにあり、アプリケーションのエントリーポイントです。Webサーバーの起動を担当します。
    • prismaモジュールには、データベースへのインターフェースであるPrisma Clientが含まれています。
    • articlesモジュールは、/articlesルートのエンドポイントと、それに付随するビジネスロジックを定義します。
  • prismaフォルダーには以下が含まれます。
    • schema.prismaファイルはデータベーススキーマを定義します。
    • migrationsディレクトリにはデータベースマイグレーション履歴が含まれています。
    • seed.tsファイルには、開発データベースにダミーデータを投入するためのスクリプトが含まれています。
  • docker-compose.ymlファイルは、PostgreSQLデータベース用のDockerイメージを定義します。
  • .envファイルには、PostgreSQLデータベースへの接続文字列が含まれています。

: これらのコンポーネントの詳細については、このチュートリアルシリーズの第1章を参照してください。

データベースにUserモデルを追加する

現在、データベーススキーマにはArticleという単一のモデルしかありません。記事は登録ユーザーによって書かれることがあります。そのため、この関係を反映するために、データベーススキーマにUserモデルを追加します。

まず、Prismaスキーマを更新します。

Userモデルには、idemailpasswordなど、予想されるいくつかのフィールドがあります。また、Articleモデルとの一対多の関係も持っています。これは、ユーザーが複数の記事を持つことができる一方で、記事は一人の著者しか持てないことを意味します。簡略化のため、authorリレーションはオプションとされており、著者なしで記事を作成することも可能です。

さて、データベースに変更を適用するには、マイグレーションコマンドを実行します。

マイグレーションが正常に実行されると、以下の出力が表示されるはずです。

シードスクリプトを更新する

シードスクリプトは、データベースにダミーデータを投入する役割を担っています。データベースにいくつかのユーザーを作成するために、シードスクリプトを更新します。

prisma/seed.tsファイルを開き、次のように更新します。

シードスクリプトは現在、2人のユーザーと3つの記事を作成します。最初の記事は最初のユーザーによって書かれ、2番目の記事は2番目のユーザーによって書かれ、3番目の記事は誰も書いていません。

: 現時点では、パスワードを平文で保存しています。実際のアプリケーションでは決してこれを行ってはいけません。次の章で、パスワードのソルト化とハッシュ化について詳しく学びます。

シードスクリプトを実行するには、以下のコマンドを実行します。

シードスクリプトが正常に実行されると、以下の出力が表示されるはずです。

ArticleEntityauthorIdフィールドを追加する

マイグレーションを実行した後、新しいTypeScriptエラーに気づいたかもしれません。ArticleEntityクラスはPrismaによって生成されたArticle型をimplementsしています。Article型には新しいauthorIdフィールドがありますが、ArticleEntityクラスにはそのフィールドが定義されていません。TypeScriptはこの型の不一致を認識し、エラーを発生させています。このエラーを修正するには、ArticleEntityクラスにauthorIdフィールドを追加します。

ArticleEntity内に新しいauthorIdフィールドを追加します。

JavaScriptのような弱型付け言語では、このような問題を自分で特定して修正する必要があります。TypeScriptのような強型付け言語の大きな利点の1つは、型関連の問題を迅速に検出できることです。

ユーザーのCRUDエンドポイントを実装する

このセクションでは、REST APIに/usersリソースを実装します。これにより、データベース内のユーザーに対してCRUD操作を実行できるようになります。

: このセクションの内容は、このシリーズの最初の章にある記事モデルのCRUD操作を実装するセクションの内容と類似しています。そのセクションでは、このトピックがより深くカバーされているため、概念的な理解を深めるために読むことができます。

新しいusers RESTリソースを生成する

usersの新しいRESTリソースを生成するには、以下のコマンドを実行します。

いくつかCLIのプロンプトが表示されます。質問に適切に回答してください。

  1. What name would you like to use for this resource (plural, e.g., "users")? users
  2. What transport layer do you use? REST API
  3. Would you like to generate CRUD entry points? Yes

これで、src/usersディレクトリに新しいusersモジュールが見つかるはずです。そこには、RESTエンドポイントのすべてのボイラープレートが含まれています。

src/users/users.controller.tsファイル内には、さまざまなルート(ルートハンドラとも呼ばれます)の定義が表示されます。各リクエストを処理するためのビジネスロジックは、src/users/users.service.tsファイルにカプセル化されています。

Swaggerが生成したAPIページを開くと、以下のようなものが表示されるはずです。

Auto-generated "users" endpoints

UsersモジュールにPrismaClientを追加する

Usersモジュール内でPrismaClientにアクセスするには、PrismaModuleをインポートとして追加する必要があります。UsersModuleに以下のimportsを追加します。

これで、UsersService内でPrismaServiceを注入し、それを使用してデータベースにアクセスできます。これを行うには、users.service.tsに次のようにコンストラクタを追加します。

UserエンティティとDTOクラスを定義する

ArticleEntityと同様に、API層でUserエンティティを表すために使用されるUserEntityクラスを定義します。user.entity.tsファイルにUserEntityクラスを次のように定義します。

@ApiPropertyデコレータは、プロパティをSwaggerに表示するために使用されます。passwordフィールドに@ApiPropertyデコレータを追加しなかったことに注意してください。これは、このフィールドが機密情報であり、APIで公開したくないためです。

: @ApiPropertyデコレータを省略すると、passwordプロパティはSwaggerドキュメントからのみ隠されます。プロパティはレスポンスボディには引き続き表示されます。この問題は後のセクションで扱います。

DTO(データ転送オブジェクト)は、ネットワーク上でデータがどのように送信されるかを定義するオブジェクトです。ユーザーの作成時と更新時にAPIに送信されるデータを定義するために、CreateUserDtoクラスとUpdateUserDtoクラスを実装する必要があります。create-user.dto.tsファイル内にCreateUserDtoクラスを次のように定義します。

@IsString@MinLength、および@IsNotEmptyは、APIに送信されるデータを検証するために使用されるバリデーションデコレータです。バリデーションについては、このシリーズの第2章でさらに詳しく説明されています。

UpdateUserDtoの定義はCreateUserDtoの定義から自動的に推論されるため、明示的に定義する必要はありません。

UsersServiceクラスを定義する

UsersServiceは、Prisma Clientを使用してデータベースからデータを変更および取得し、UsersControllerに提供する役割を担っています。このクラスでcreate()findAll()findOne()update()、およびremove()メソッドを実装します。

UsersControllerクラスを定義する

UsersControllerは、usersエンドポイントへのリクエストとレスポンスを処理する役割を担っています。UsersServiceを活用してデータベースにアクセスし、UserEntityでレスポンスボディを定義し、CreateUserDtoUpdateUserDtoでリクエストボディを定義します。

コントローラは異なるルートハンドラで構成されています。このクラスでは、5つのエンドポイントに対応する5つのルートハンドラを実装します。

  • create() - POST /users
  • findAll() - GET /users
  • findOne() - GET /users/:id
  • update() - PATCH /users/:id
  • remove() - DELETE /users/:id

users.controller.tsでこれらのルートハンドラの実装を次のように更新します。

更新されたコントローラは、@ApiTagsデコレータを使用してエンドポイントをusersタグの下にグループ化します。また、@ApiCreatedResponseおよび@ApiOkResponseデコレータを使用して、各エンドポイントのレスポンスボディを定義します。

更新されたSwaggerのAPIページは次のようになるはずです。

Updated swagger page

期待どおりに動作することを確認するために、さまざまなエンドポイントを自由にテストしてください。

レスポンスボディからpasswordフィールドを除外する

users APIは期待どおりに動作しますが、大きなセキュリティ上の欠陥があります。passwordフィールドがさまざまなエンドポイントのレスポンスボディで返されています。

GET /users/:id reveals password

この問題を修正するには2つのオプションがあります。

  1. コントローラのルートハンドラで、レスポンスボディからパスワードを手動で削除する
  2. インターセプターを使用して、レスポンスボディからパスワードを自動的に削除する

最初のオプションはエラーが発生しやすく、不要なコードの重複を招きます。そのため、2番目の方法を使用します。

ClassSerializerInterceptorを使用してレスポンスからフィールドを除外する

NestJSのインターセプターは、リクエスト・レスポンスサイクルにフックして、ルートハンドラが実行される前後に追加のロジックを実行できるようにします。この場合、レスポンスボディからpasswordフィールドを削除するためにこれを使用します。

NestJSには、オブジェクトを変換するために使用できる組み込みのClassSerializerInterceptorがあります。このインターセプターを使用して、レスポンスオブジェクトからpasswordフィールドを削除します。

まず、main.tsを更新してClassSerializerInterceptorをグローバルに有効にします。

注: インターセプターをグローバルではなく、メソッドまたはコントローラにバインドすることも可能です。詳細については、NestJSのドキュメントを参照してください。

ClassSerializerInterceptorclass-transformerパッケージを使用してオブジェクトの変換方法を定義します。@Exclude()デコレータを使用して、UserEntityクラスのpasswordフィールドを除外します。

もう一度GET /users/:idエンドポイントを試すと、passwordフィールドがまだ公開されていることに気づくでしょう🤔。これは、現在、コントローラのルートハンドラがPrisma Clientによって生成されたUser型を返しているためです。ClassSerializerInterceptor@Exclude()デコレータで装飾されたクラスでのみ機能します。この場合、それはUserEntityクラスです。したがって、ルートハンドラを更新して、代わりにUserEntity型を返すようにする必要があります。

まず、UserEntityオブジェクトをインスタンス化するコンストラクタを作成する必要があります。

コンストラクタはオブジェクトを受け取り、Object.assign()メソッドを使用してpartialオブジェクトからUserEntityインスタンスにプロパティをコピーします。partialの型はPartial<UserEntity>です。これは、partialオブジェクトがUserEntityクラスで定義されたプロパティの任意のサブセットを含むことができることを意味します。

次に、UsersControllerのルートハンドラを更新して、Prisma.Userオブジェクトの代わりにUserEntityを返すようにします。

これで、パスワードはレスポンスオブジェクトから省略されるはずです。

GET /users/:id does not reveal password

記事とともに著者を返す

第1章では、単一の記事を取得するためのGET /articles/:idエンドポイントを実装しました。現在、このエンドポイントは記事の著者を返さず、authorIdのみを返します。著者を取得するためには、GET /users/:idエンドポイントに追加のリクエストを行う必要があります。記事とその著者の両方が必要な場合、2つのAPIリクエストを行う必要があるため、これは理想的ではありません。Articleオブジェクトとともに著者を返すことで、これを改善できます。

データアクセスロジックはArticlesService内に実装されています。findOne()メソッドを更新して、Articleオブジェクトとともに著者を返すようにします。

GET /articles/:idエンドポイントをテストすると、記事の著者が存在する場合、レスポンスオブジェクトに含まれていることに気づくでしょう。しかし、問題があります。passwordフィールドが再び公開されています🤦。

GET /articles/:id reveals password

この問題の原因は前回と非常に似ています。現在、ArticlesControllerはPrismaによって生成された型のインスタンスを返しますが、ClassSerializerInterceptorUserEntityクラスで機能します。これを修正するには、ArticleEntityクラスの実装を更新し、authorプロパティをUserEntityのインスタンスで初期化するようにします。

再び、Object.assign()メソッドを使用して、dataオブジェクトからArticleEntityインスタンスにプロパティをコピーしています。authorプロパティが存在する場合は、UserEntityのインスタンスとして初期化されます。

さて、ArticlesControllerを更新して、ArticleEntityオブジェクトのインスタンスを返すようにします。

これで、GET /articles/:idpasswordフィールドなしでauthorオブジェクトを返します。

GET /articles/:id does not reveal password

まとめと最後のコメント

この章では、Prismaを使用してNestJSアプリケーションでリレーショナルデータをモデル化する方法を学びました。また、ClassSerializerInterceptorと、エンティティクラスを使用してクライアントに返されるデータを制御する方法についても学びました。

このチュートリアルの完成したコードは、GitHubリポジトリend-relational-dataブランチにあります。問題を発見した場合は、遠慮なくリポジトリでIssueを立てるか、PRを送信してください。Twitterで私に直接連絡することもできます。

次の投稿をお見逃しなく!

Prismaニュースレターに登録する

© . All rights reserved.